From 4bb814c47395e0a2fd1434c0e36da509205d5455 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Tue, 13 Sep 2022 13:18:46 +0200 Subject: [PATCH 001/357] feat: auto detect `db_driver` from `connect_url` --- src/config.rs | 3 -- src/databases/database.rs | 106 ++++++++++++++++++-------------------- src/errors.rs | 3 +- src/main.rs | 6 ++- tests/databases/mod.rs | 10 ++-- tests/databases/mysql.rs | 3 +- tests/databases/sqlite.rs | 3 +- 7 files changed, 67 insertions(+), 67 deletions(-) diff --git a/src/config.rs b/src/config.rs index 89078f2d..cb3b5546 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,6 @@ use config::{ConfigError, Config, File}; use std::path::Path; use serde::{Serialize, Deserialize}; use tokio::sync::RwLock; -use crate::databases::database::DatabaseDriver; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Website { @@ -50,7 +49,6 @@ pub struct Auth { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Database { - pub db_driver: DatabaseDriver, pub connect_url: String, pub torrent_info_update_interval: u64, } @@ -105,7 +103,6 @@ impl Configuration { secret_key: "MaxVerstappenWC2021".to_string() }, database: Database { - db_driver: DatabaseDriver::Sqlite3, connect_url: "sqlite://data.db?mode=rwc".to_string(), torrent_info_update_interval: 3600 }, diff --git a/src/databases/database.rs b/src/databases/database.rs index c22f8202..856092d5 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use chrono::{NaiveDateTime}; use serde::{Serialize, Deserialize}; + use crate::databases::mysql::MysqlDatabase; use crate::databases::sqlite::SqliteDatabase; use crate::models::response::{TorrentsResponse}; @@ -9,18 +10,21 @@ use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; +/// Database drivers. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] pub enum DatabaseDriver { Sqlite3, Mysql } +/// Compact representation of torrent. #[derive(Debug, Serialize, sqlx::FromRow)] pub struct TorrentCompact { pub torrent_id: i64, pub info_hash: String, } +/// Torrent category. #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Category { pub category_id: i64, @@ -28,6 +32,7 @@ pub struct Category { pub num_torrents: i64 } +/// Sorting options for torrents. #[derive(Clone, Copy, Debug, Deserialize)] pub enum Sorting { UploadedAsc, @@ -42,9 +47,11 @@ pub enum Sorting { SizeDesc, } +/// Database errors. #[derive(Debug)] pub enum DatabaseError { Error, + UnrecognizedDatabaseDriver, // when the db path does not start with sqlite or mysql UsernameTaken, EmailTaken, UserNotFound, @@ -55,127 +62,116 @@ pub enum DatabaseError { TorrentTitleAlreadyExists, } -pub async fn connect_database(db_driver: &DatabaseDriver, db_path: &str) -> Box { - // match &db_path.chars().collect::>() as &[char] { - // ['s', 'q', 'l', 'i', 't', 'e', ..] => { - // let db = SqliteDatabase::new(db_path).await; - // Ok(Box::new(db)) - // } - // ['m', 'y', 's', 'q', 'l', ..] => { - // let db = MysqlDatabase::new(db_path).await; - // Ok(Box::new(db)) - // } - // _ => { - // Err(()) - // } - // } - - match db_driver { - DatabaseDriver::Sqlite3 => { +/// Connect to a database. +pub async fn connect_database(db_path: &str) -> Result, DatabaseError> { + match &db_path.chars().collect::>() as &[char] { + ['s', 'q', 'l', 'i', 't', 'e', ..] => { let db = SqliteDatabase::new(db_path).await; - Box::new(db) + Ok(Box::new(db)) } - DatabaseDriver::Mysql => { + ['m', 'y', 's', 'q', 'l', ..] => { let db = MysqlDatabase::new(db_path).await; - Box::new(db) + Ok(Box::new(db)) + } + _ => { + Err(DatabaseError::UnrecognizedDatabaseDriver) } } } +/// Trait for database implementations. #[async_trait] pub trait Database: Sync + Send { - // return current database driver + /// Return current database driver. fn get_database_driver(&self) -> DatabaseDriver; - // add new user and get the newly inserted user_id + /// Add new user and return the newly inserted `user_id`. async fn insert_user_and_get_id(&self, username: &str, email: &str, password: &str) -> Result; - // get user profile by user_id + /// Get `User` from `user_id`. async fn get_user_from_id(&self, user_id: i64) -> Result; - // get user authentication by user_id + /// Get `UserAuthentication` from `user_id`. async fn get_user_authentication_from_id(&self, user_id: i64) -> Result; - // get user profile by username + /// Get `UserProfile` from `username`. async fn get_user_profile_from_username(&self, username: &str) -> Result; - // get user compact by user_id + /// Get `UserCompact` from `user_id`. async fn get_user_compact_from_id(&self, user_id: i64) -> Result; - // todo: change to get all tracker keys of user, no matter if they are still valid - // get a user's tracker key + /// Get a user's `TrackerKey`. async fn get_user_tracker_key(&self, user_id: i64) -> Option; - // count users + /// Get total user count. async fn count_users(&self) -> Result; - // todo: make DateTime struct for the date_expiry - // ban user + /// Ban user with `user_id`, `reason` and `date_expiry`. async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), DatabaseError>; - // give a user administrator rights + /// Grant a user the administrator role. async fn grant_admin_role(&self, user_id: i64) -> Result<(), DatabaseError>; - // verify email + /// Verify a user's email with `user_id`. async fn verify_email(&self, user_id: i64) -> Result<(), DatabaseError>; - // create a new tracker key for a certain user + /// Link a `TrackerKey` to a certain user with `user_id`. async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), DatabaseError>; - // delete user + /// Delete user and all related user data with `user_id`. async fn delete_user(&self, user_id: i64) -> Result<(), DatabaseError>; - // add new category + /// Add a new category and return `category_id`. async fn insert_category_and_get_id(&self, category_name: &str) -> Result; - // get category by id - async fn get_category_from_id(&self, id: i64) -> Result; + /// Get `Category` from `category_id`. + async fn get_category_from_id(&self, category_id: i64) -> Result; - // get category by name - async fn get_category_from_name(&self, category: &str) -> Result; + /// Get `Category` from `category_name`. + async fn get_category_from_name(&self, category_name: &str) -> Result; - // get all categories + /// Get all categories as `Vec`. async fn get_categories(&self) -> Result, DatabaseError>; - // delete category + /// Delete category with `category_name`. async fn delete_category(&self, category_name: &str) -> Result<(), DatabaseError>; - // get results of a torrent search in a paginated and sorted form + /// Get results of a torrent search in a paginated and sorted form as `TorrentsResponse` from `search`, `categories`, `sort`, `offset` and `page_size`. async fn get_torrents_search_sorted_paginated(&self, search: &Option, categories: &Option>, sort: &Sorting, offset: u64, page_size: u8) -> Result; - // add new torrent and get the newly inserted torrent_id + /// Add new torrent and return the newly inserted `torrent_id` with `torrent`, `uploader_id`, `category_id`, `title` and `description`. async fn insert_torrent_and_get_id(&self, torrent: &Torrent, uploader_id: i64, category_id: i64, title: &str, description: &str) -> Result; - // get torrent by id + /// Get `Torrent` from `torrent_id`. async fn get_torrent_from_id(&self, torrent_id: i64) -> Result; - // get torrent info by id + /// Get torrent's info as `DbTorrentInfo` from `torrent_id`. async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; - // get torrent files by id + /// Get all torrent's files as `Vec` from `torrent_id`. async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError>; - // get torrent announce urls by id + /// Get all torrent's announce urls as `Vec>` from `torrent_id`. async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, DatabaseError>; - // get torrent listing by id + /// Get `TorrentListing` from `torrent_id`. async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result; - // get all torrents (torrent_id + info_hash) + /// Get all torrents as `Vec`. async fn get_all_torrents_compact(&self) -> Result, DatabaseError>; - // update a torrent's title + /// Update a torrent's title with `torrent_id` and `title`. async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), DatabaseError>; - // update a torrent's description + /// Update a torrent's description with `torrent_id` and `description`. async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), DatabaseError>; - // update the seeders and leechers info for a particular torrent + /// Update the seeders and leechers info for a torrent with `torrent_id`, `tracker_url`, `seeders` and `leechers`. async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &str, seeders: i64, leechers: i64) -> Result<(), DatabaseError>; - // delete a torrent + /// Delete a torrent with `torrent_id`. async fn delete_torrent(&self, torrent_id: i64) -> Result<(), DatabaseError>; - // DELETES ALL DATABASE ROWS, ONLY CALL THIS IF YOU KNOW WHAT YOU'RE DOING! + /// DELETES ALL DATABASE ROWS, ONLY CALL THIS IF YOU KNOW WHAT YOU'RE DOING! async fn delete_all_database_rows(&self) -> Result<(), DatabaseError>; } diff --git a/src/errors.rs b/src/errors.rs index eef4f851..675ca05f 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -222,7 +222,8 @@ impl From for ServiceError { DatabaseError::CategoryNotFound => ServiceError::InvalidCategory, DatabaseError::TorrentNotFound => ServiceError::TorrentNotFound, DatabaseError::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, - DatabaseError::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists + DatabaseError::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists, + DatabaseError::UnrecognizedDatabaseDriver => ServiceError::InternalServerError, } } } diff --git a/src/main.rs b/src/main.rs index 06304307..7fc5d0f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,11 @@ async fn main() -> std::io::Result<()> { let settings = cfg.settings.read().await; - let database = Arc::new(connect_database(&settings.database.db_driver, &settings.database.connect_url).await); + let database = Arc::new(connect_database(&settings.database.connect_url) + .await + .expect("Database error.") + ); + let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); diff --git a/tests/databases/mod.rs b/tests/databases/mod.rs index 66f90e92..adf8bc52 100644 --- a/tests/databases/mod.rs +++ b/tests/databases/mod.rs @@ -1,5 +1,5 @@ use std::future::Future; -use torrust_index_backend::databases::database::{connect_database, Database, DatabaseDriver}; +use torrust_index_backend::databases::database::{connect_database, Database}; mod mysql; mod tests; @@ -19,8 +19,12 @@ async fn run_test<'a, T, F>(db_fn: T, db: &'a Box) } // runs all tests -pub async fn run_tests(db_driver: DatabaseDriver, db_path: &str) { - let db = connect_database(&db_driver, db_path).await; +pub async fn run_tests(db_path: &str) { + let db_res = connect_database(db_path).await; + + assert!(db_res.is_ok()); + + let db = db_res.unwrap(); run_test(tests::it_can_add_a_user, &db).await; run_test(tests::it_can_add_a_torrent_category, &db).await; diff --git a/tests/databases/mysql.rs b/tests/databases/mysql.rs index d64ac1b3..c0f78429 100644 --- a/tests/databases/mysql.rs +++ b/tests/databases/mysql.rs @@ -1,11 +1,10 @@ -use torrust_index_backend::databases::database::{DatabaseDriver}; use crate::databases::{run_tests}; const DATABASE_URL: &str = "mysql://root:password@localhost:3306/torrust-index_test"; #[tokio::test] async fn run_mysql_tests() { - run_tests(DatabaseDriver::Mysql, DATABASE_URL).await; + run_tests(DATABASE_URL).await; } diff --git a/tests/databases/sqlite.rs b/tests/databases/sqlite.rs index 7aab5b1d..940d7e6b 100644 --- a/tests/databases/sqlite.rs +++ b/tests/databases/sqlite.rs @@ -1,11 +1,10 @@ -use torrust_index_backend::databases::database::{DatabaseDriver}; use crate::databases::{run_tests}; const DATABASE_URL: &str = "sqlite::memory:"; #[tokio::test] async fn run_sqlite_tests() { - run_tests(DatabaseDriver::Sqlite3, DATABASE_URL).await; + run_tests(DATABASE_URL).await; } From 4e729958dedb739ef450d8d04a97cf63062b2926 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 9 Nov 2022 13:49:40 +0100 Subject: [PATCH 002/357] fix: [#79] prioritize `announce-list` over `announce` --- src/databases/mysql.rs | 22 ++++++++++++---------- src/databases/sqlite.rs | 22 ++++++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index c1fd6b6b..679bd35e 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -446,17 +446,9 @@ impl Database for MysqlDatabase { return Err(e) } - let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(tracker_url) = &torrent.announce { - query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") - .bind(torrent_id) - .bind(tracker_url) - .execute(&mut tx) - .await - .map(|_| ()) - .map_err(|_| DatabaseError::Error) - } else { + let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(announce_urls) = &torrent.announce_list { // flatten the nested vec (this will however remove the) - let announce_urls = torrent.announce_list.clone().unwrap().into_iter().flatten().collect::>(); + let announce_urls = announce_urls.iter().flatten().collect::>(); for tracker_url in announce_urls.iter() { let _ = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") @@ -469,6 +461,16 @@ impl Database for MysqlDatabase { } Ok(()) + } else { + let tracker_url = torrent.announce.as_ref().unwrap(); + + query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") + .bind(torrent_id) + .bind(tracker_url) + .execute(&mut tx) + .await + .map(|_| ()) + .map_err(|_| DatabaseError::Error) }; // rollback transaction on error diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 8ac8deab..6b65eeb9 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -442,17 +442,9 @@ impl Database for SqliteDatabase { return Err(e) } - let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(tracker_url) = &torrent.announce { - query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") - .bind(torrent_id) - .bind(tracker_url) - .execute(&mut tx) - .await - .map(|_| ()) - .map_err(|_| DatabaseError::Error) - } else { + let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(announce_urls) = &torrent.announce_list { // flatten the nested vec (this will however remove the) - let announce_urls = torrent.announce_list.clone().unwrap().into_iter().flatten().collect::>(); + let announce_urls = announce_urls.iter().flatten().collect::>(); for tracker_url in announce_urls.iter() { let _ = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") @@ -465,6 +457,16 @@ impl Database for SqliteDatabase { } Ok(()) + } else { + let tracker_url = torrent.announce.as_ref().unwrap(); + + query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") + .bind(torrent_id) + .bind(tracker_url) + .execute(&mut tx) + .await + .map(|_| ()) + .map_err(|_| DatabaseError::Error) }; // rollback transaction on error From 06bb34bfd2e8a8465df367197df3eca2fb61b9de Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 14:34:29 +0100 Subject: [PATCH 003/357] fmt: include rust format, same as torrust-index --- rustfmt.toml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 rustfmt.toml diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..3e878b27 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +max_width = 130 +imports_granularity = "Module" +group_imports = "StdExternalCrate" + From 9ddc079b00fc5d6ecd80199edc078d6793fb0a9c Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 14:35:23 +0100 Subject: [PATCH 004/357] fmt: format the world --- src/auth.rs | 42 +++---- src/common.rs | 15 ++- src/config.rs | 39 +++--- src/databases/database.rs | 40 +++++-- src/databases/mod.rs | 2 +- src/databases/mysql.rs | 239 +++++++++++++++++++++---------------- src/databases/sqlite.rs | 238 ++++++++++++++++++++---------------- src/errors.rs | 23 ++-- src/lib.rs | 26 ++-- src/mailer.rs | 56 ++++----- src/main.rs | 38 +++--- src/models/mod.rs | 4 +- src/models/response.rs | 13 +- src/models/torrent.rs | 1 + src/models/torrent_file.rs | 27 +++-- src/models/tracker_key.rs | 2 +- src/models/user.rs | 2 +- src/routes/about.rs | 16 +-- src/routes/category.rs | 38 +++--- src/routes/mod.rs | 8 +- src/routes/root.rs | 8 +- src/routes/settings.rs | 50 ++++---- src/routes/torrent.rs | 191 ++++++++++++++++------------- src/routes/user.rs | 122 ++++++++++--------- src/tracker.rs | 81 ++++++++----- src/utils/hex.rs | 2 +- src/utils/mod.rs | 4 +- src/utils/parse_torrent.rs | 4 +- tests/databases/mod.rs | 10 +- tests/databases/mysql.rs | 4 +- tests/databases/sqlite.rs | 4 +- tests/databases/tests.rs | 88 ++++++++------ 32 files changed, 790 insertions(+), 647 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index c13e5da0..2b38c14e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,23 +1,22 @@ -use actix_web::HttpRequest; -use crate::models::user::{UserClaims, UserCompact}; -use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm, encode, Header, EncodingKey}; -use crate::utils::time::current_time; -use crate::errors::ServiceError; use std::sync::Arc; + +use actix_web::HttpRequest; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; + use crate::config::Configuration; use crate::databases::database::Database; +use crate::errors::ServiceError; +use crate::models::user::{UserClaims, UserCompact}; +use crate::utils::time::current_time; pub struct AuthorizationService { cfg: Arc, - database: Arc> + database: Arc>, } impl AuthorizationService { pub fn new(cfg: Arc, database: Arc>) -> AuthorizationService { - AuthorizationService { - cfg, - database - } + AuthorizationService { cfg, database } } pub async fn sign_jwt(&self, user: UserCompact) -> String { @@ -28,17 +27,9 @@ impl AuthorizationService { // TODO: create config option for setting the token validity in seconds let exp_date = current_time() + 1_209_600; // two weeks from now - let claims = UserClaims { - user, - exp: exp_date, - }; + let claims = UserClaims { user, exp: exp_date }; - let token = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(key), - ) - .unwrap(); + let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); token } @@ -53,11 +44,11 @@ impl AuthorizationService { ) { Ok(token_data) => { if token_data.claims.exp < current_time() { - return Err(ServiceError::TokenExpired) + return Err(ServiceError::TokenExpired); } Ok(token_data.claims) - }, - Err(_) => Err(ServiceError::TokenInvalid) + } + Err(_) => Err(ServiceError::TokenInvalid), } } @@ -73,14 +64,15 @@ impl AuthorizationService { Err(e) => Err(e), } } - None => Err(ServiceError::TokenNotFound) + None => Err(ServiceError::TokenNotFound), } } pub async fn get_user_compact_from_request(&self, req: &HttpRequest) -> Result { let claims = self.get_claims_from_request(req).await?; - self.database.get_user_compact_from_id(claims.user.user_id) + self.database + .get_user_compact_from_id(claims.user.user_id) .await .map_err(|_| ServiceError::UserNotFound) } diff --git a/src/common.rs b/src/common.rs index 2f11f6ec..9bd43dd9 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,9 +1,10 @@ use std::sync::Arc; -use crate::config::Configuration; + use crate::auth::AuthorizationService; +use crate::config::Configuration; use crate::databases::database::Database; -use crate::tracker::TrackerService; use crate::mailer::MailerService; +use crate::tracker::TrackerService; pub type Username = String; @@ -14,11 +15,17 @@ pub struct AppData { pub database: Arc>, pub auth: Arc, pub tracker: Arc, - pub mailer: Arc + pub mailer: Arc, } impl AppData { - pub fn new(cfg: Arc, database: Arc>, auth: Arc, tracker: Arc, mailer: Arc) -> AppData { + pub fn new( + cfg: Arc, + database: Arc>, + auth: Arc, + tracker: Arc, + mailer: Arc, + ) -> AppData { AppData { cfg, database, diff --git a/src/config.rs b/src/config.rs index cb3b5546..00d390dc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,8 @@ use std::fs; -use config::{ConfigError, Config, File}; use std::path::Path; -use serde::{Serialize, Deserialize}; + +use config::{Config, ConfigError, File}; +use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -14,7 +15,7 @@ pub enum TrackerMode { Public, Private, Whitelisted, - PrivateWhitelisted + PrivateWhitelisted, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -36,7 +37,7 @@ pub struct Network { pub enum EmailOnSignup { Required, Optional, - None + None, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -76,35 +77,35 @@ pub struct TorrustConfig { #[derive(Debug)] pub struct Configuration { - pub settings: RwLock + pub settings: RwLock, } impl Configuration { pub fn default() -> Configuration { let torrust_config = TorrustConfig { website: Website { - name: "Torrust".to_string() + name: "Torrust".to_string(), }, tracker: Tracker { url: "udp://localhost:6969".to_string(), mode: TrackerMode::Public, api_url: "http://localhost:1212".to_string(), token: "MyAccessToken".to_string(), - token_valid_seconds: 7257600 + token_valid_seconds: 7257600, }, net: Network { port: 3000, - base_url: None + base_url: None, }, auth: Auth { email_on_signup: EmailOnSignup::Optional, min_password_length: 6, max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string() + secret_key: "MaxVerstappenWC2021".to_string(), }, database: Database { connect_url: "sqlite://data.db?mode=rwc".to_string(), - torrent_info_update_interval: 3600 + torrent_info_update_interval: 3600, }, mail: Mail { email_verification_enabled: false, @@ -113,12 +114,12 @@ impl Configuration { username: "".to_string(), password: "".to_string(), server: "".to_string(), - port: 25 - } + port: 25, + }, }; Configuration { - settings: RwLock::new(torrust_config) + settings: RwLock::new(torrust_config), } } @@ -134,7 +135,9 @@ impl Configuration { eprintln!("Creating config file.."); let config = Configuration::default(); let _ = config.save_to_file().await; - return Err(ConfigError::Message(format!("Please edit the config.TOML in the root folder and restart the tracker."))) + return Err(ConfigError::Message(format!( + "Please edit the config.TOML in the root folder and restart the tracker." + ))); } let torrust_config: TorrustConfig = match config.try_into() { @@ -143,11 +146,11 @@ impl Configuration { }?; Ok(Configuration { - settings: RwLock::new(torrust_config) + settings: RwLock::new(torrust_config), }) } - pub async fn save_to_file(&self) -> Result<(), ()>{ + pub async fn save_to_file(&self) -> Result<(), ()> { let settings = self.settings.read().await; let toml_string = toml::to_string(&*settings).expect("Could not encode TOML value"); @@ -178,7 +181,7 @@ impl Configuration { website_name: settings_lock.website.name.clone(), tracker_url: settings_lock.tracker.url.clone(), tracker_mode: settings_lock.tracker.mode.clone(), - email_on_signup: settings_lock.auth.email_on_signup.clone() + email_on_signup: settings_lock.auth.email_on_signup.clone(), } } } @@ -188,5 +191,5 @@ pub struct ConfigurationPublic { website_name: String, tracker_url: String, tracker_mode: TrackerMode, - email_on_signup: EmailOnSignup + email_on_signup: EmailOnSignup, } diff --git a/src/databases/database.rs b/src/databases/database.rs index 856092d5..c4dad043 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -1,10 +1,10 @@ use async_trait::async_trait; -use chrono::{NaiveDateTime}; -use serde::{Serialize, Deserialize}; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; use crate::databases::mysql::MysqlDatabase; use crate::databases::sqlite::SqliteDatabase; -use crate::models::response::{TorrentsResponse}; +use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; @@ -14,7 +14,7 @@ use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] pub enum DatabaseDriver { Sqlite3, - Mysql + Mysql, } /// Compact representation of torrent. @@ -29,7 +29,7 @@ pub struct TorrentCompact { pub struct Category { pub category_id: i64, pub name: String, - pub num_torrents: i64 + pub num_torrents: i64, } /// Sorting options for torrents. @@ -73,9 +73,7 @@ pub async fn connect_database(db_path: &str) -> Result, Databa let db = MysqlDatabase::new(db_path).await; Ok(Box::new(db)) } - _ => { - Err(DatabaseError::UnrecognizedDatabaseDriver) - } + _ => Err(DatabaseError::UnrecognizedDatabaseDriver), } } @@ -137,10 +135,24 @@ pub trait Database: Sync + Send { async fn delete_category(&self, category_name: &str) -> Result<(), DatabaseError>; /// Get results of a torrent search in a paginated and sorted form as `TorrentsResponse` from `search`, `categories`, `sort`, `offset` and `page_size`. - async fn get_torrents_search_sorted_paginated(&self, search: &Option, categories: &Option>, sort: &Sorting, offset: u64, page_size: u8) -> Result; + async fn get_torrents_search_sorted_paginated( + &self, + search: &Option, + categories: &Option>, + sort: &Sorting, + offset: u64, + page_size: u8, + ) -> Result; /// Add new torrent and return the newly inserted `torrent_id` with `torrent`, `uploader_id`, `category_id`, `title` and `description`. - async fn insert_torrent_and_get_id(&self, torrent: &Torrent, uploader_id: i64, category_id: i64, title: &str, description: &str) -> Result; + async fn insert_torrent_and_get_id( + &self, + torrent: &Torrent, + uploader_id: i64, + category_id: i64, + title: &str, + description: &str, + ) -> Result; /// Get `Torrent` from `torrent_id`. async fn get_torrent_from_id(&self, torrent_id: i64) -> Result; @@ -167,7 +179,13 @@ pub trait Database: Sync + Send { async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), DatabaseError>; /// Update the seeders and leechers info for a torrent with `torrent_id`, `tracker_url`, `seeders` and `leechers`. - async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &str, seeders: i64, leechers: i64) -> Result<(), DatabaseError>; + async fn update_tracker_info( + &self, + torrent_id: i64, + tracker_url: &str, + seeders: i64, + leechers: i64, + ) -> Result<(), DatabaseError>; /// Delete a torrent with `torrent_id`. async fn delete_torrent(&self, torrent_id: i64) -> Result<(), DatabaseError>; diff --git a/src/databases/mod.rs b/src/databases/mod.rs index 9340e821..169d99f4 100644 --- a/src/databases/mod.rs +++ b/src/databases/mod.rs @@ -1,3 +1,3 @@ pub mod database; -pub mod sqlite; pub mod mysql; +pub mod sqlite; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 679bd35e..75afe1c8 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -1,19 +1,19 @@ -use sqlx::{Acquire, MySqlPool, query, query_as}; use async_trait::async_trait; -use chrono::{NaiveDateTime}; +use chrono::NaiveDateTime; use sqlx::mysql::MySqlPoolOptions; +use sqlx::{query, query_as, Acquire, MySqlPool}; -use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; -use crate::models::torrent::TorrentListing; -use crate::utils::time::current_time; -use crate::models::tracker_key::{TrackerKey}; use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; -use crate::models::response::{TorrentsResponse}; -use crate::models::torrent_file::{DbTorrentInfo, Torrent, DbTorrentFile, DbTorrentAnnounceUrl, TorrentFile}; +use crate::models::response::TorrentsResponse; +use crate::models::torrent::TorrentListing; +use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::tracker_key::TrackerKey; +use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; use crate::utils::hex::bytes_to_hex; +use crate::utils::time::current_time; pub struct MysqlDatabase { - pub pool: MySqlPool + pub pool: MySqlPool, } impl MysqlDatabase { @@ -28,9 +28,7 @@ impl MysqlDatabase { .await .expect("Could not run database migrations."); - Self { - pool: db - } + Self { pool: db } } } @@ -41,16 +39,11 @@ impl Database for MysqlDatabase { } async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { - // open pool connection - let mut conn = self.pool.acquire() - .await - .map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; // start db transaction - let mut tx = conn.begin() - .await - .map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; // create the user account and get the user id let user_id = query("INSERT INTO torrust_users (date_registered) VALUES (UTC_TIMESTAMP())") @@ -70,7 +63,7 @@ impl Database for MysqlDatabase { // rollback transaction on error if let Err(e) = insert_user_auth_result { let _ = tx.rollback().await; - return Err(e) + return Err(e); } // add account profile details @@ -181,10 +174,12 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::UserNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::UserNotFound) + } }) } @@ -194,10 +189,12 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::UserNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::UserNotFound) + } }) } @@ -220,10 +217,12 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::UserNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::UserNotFound) + } }) } @@ -240,8 +239,8 @@ impl Database for MysqlDatabase { } else { DatabaseError::Error } - }, - _ => DatabaseError::Error + } + _ => DatabaseError::Error, }) } @@ -274,18 +273,27 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::CategoryNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::CategoryNotFound) + } }) } // TODO: refactor this - async fn get_torrents_search_sorted_paginated(&self, search: &Option, categories: &Option>, sort: &Sorting, offset: u64, page_size: u8) -> Result { + async fn get_torrents_search_sorted_paginated( + &self, + search: &Option, + categories: &Option>, + sort: &Sorting, + offset: u64, + page_size: u8, + ) -> Result { let title = match search { None => "%".to_string(), - Some(v) => format!("%{}%", v) + Some(v) => format!("%{}%", v), }; let sort_query: String = match sort { @@ -308,13 +316,18 @@ impl Database for MysqlDatabase { // don't take user input in the db query if let Ok(sanitized_category) = self.get_category_from_name(category).await { let mut str = format!("tc.name = '{}'", sanitized_category.name); - if i > 0 { str = format!(" OR {}", str); } + if i > 0 { + str = format!(" OR {}", str); + } category_filters.push_str(&str); i += 1; } } if category_filters.len() > 0 { - format!("INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", category_filters) + format!( + "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", + category_filters + ) } else { String::new() } @@ -358,22 +371,25 @@ impl Database for MysqlDatabase { Ok(TorrentsResponse { total: count as u32, - results: res + results: res, }) } - async fn insert_torrent_and_get_id(&self, torrent: &Torrent, uploader_id: i64, category_id: i64, title: &str, description: &str) -> Result { + async fn insert_torrent_and_get_id( + &self, + torrent: &Torrent, + uploader_id: i64, + category_id: i64, + title: &str, + description: &str, + ) -> Result { let info_hash = torrent.info_hash(); // open pool connection - let mut conn = self.pool.acquire() - .await - .map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; // start db transaction - let mut tx = conn.begin() - .await - .map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { @@ -443,7 +459,7 @@ impl Database for MysqlDatabase { // rollback transaction on error if let Err(e) = insert_torrent_files_result { let _ = tx.rollback().await; - return Err(e) + return Err(e); } let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(announce_urls) = &torrent.announce_list { @@ -476,27 +492,28 @@ impl Database for MysqlDatabase { // rollback transaction on error if let Err(e) = insert_torrent_announce_urls_result { let _ = tx.rollback().await; - return Err(e) + return Err(e); } - let insert_torrent_info_result = query(r#"INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, NULLIF(?, ""))"#) - .bind(torrent_id) - .bind(title) - .bind(description) - .execute(&mut tx) - .await - .map_err(|e| match e { - sqlx::Error::Database(err) => { - if err.message().contains("info_hash") { - DatabaseError::TorrentAlreadyExists - } else if err.message().contains("title") { - DatabaseError::TorrentTitleAlreadyExists - } else { - DatabaseError::Error + let insert_torrent_info_result = + query(r#"INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, NULLIF(?, ""))"#) + .bind(torrent_id) + .bind(title) + .bind(description) + .execute(&mut tx) + .await + .map_err(|e| match e { + sqlx::Error::Database(err) => { + if err.message().contains("info_hash") { + DatabaseError::TorrentAlreadyExists + } else if err.message().contains("title") { + DatabaseError::TorrentTitleAlreadyExists + } else { + DatabaseError::Error + } } - } - _ => DatabaseError::Error - }); + _ => DatabaseError::Error, + }); // commit or rollback transaction and return user_id on success match insert_torrent_info_result { @@ -518,43 +535,45 @@ impl Database for MysqlDatabase { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - Ok(Torrent::from_db_info_files_and_announce_urls(torrent_info, torrent_files, torrent_announce_urls)) + Ok(Torrent::from_db_info_files_and_announce_urls( + torrent_info, + torrent_files, + torrent_announce_urls, + )) } async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( - "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?" + "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) - .bind(torrent_id) - .fetch_one(&self.pool) - .await - .map_err(|_| DatabaseError::TorrentNotFound) + .bind(torrent_id) + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) } async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { - let db_torrent_files = query_as::<_, DbTorrentFile>( - "SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?" - ) - .bind(torrent_id) - .fetch_all(&self.pool) - .await - .map_err(|_| DatabaseError::TorrentNotFound)?; + let db_torrent_files = + query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound)?; - let torrent_files: Vec = db_torrent_files.into_iter().map(|tf| { - TorrentFile { + let torrent_files: Vec = db_torrent_files + .into_iter() + .map(|tf| TorrentFile { path: tf.path.unwrap_or("".to_string()).split('/').map(|v| v.to_string()).collect(), length: tf.length, - md5sum: tf.md5sum - } - }).collect(); + md5sum: tf.md5sum, + }) + .collect(); Ok(torrent_files) } async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, DatabaseError> { - query_as::<_, DbTorrentAnnounceUrl>( - "SELECT tracker_url FROM torrust_torrent_announce_urls WHERE torrent_id = ?" - ) + query_as::<_, DbTorrentAnnounceUrl>("SELECT tracker_url FROM torrust_torrent_announce_urls WHERE torrent_id = ?") .bind(torrent_id) .fetch_all(&self.pool) .await @@ -601,12 +620,14 @@ impl Database for MysqlDatabase { DatabaseError::Error } } - _ => DatabaseError::Error + _ => DatabaseError::Error, }) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::TorrentNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::TorrentNotFound) + } }) } @@ -617,14 +638,22 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::TorrentNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::TorrentNotFound) + } }) } - async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &str, seeders: i64, leechers: i64) -> Result<(), DatabaseError> { + async fn update_tracker_info( + &self, + torrent_id: i64, + tracker_url: &str, + seeders: i64, + leechers: i64, + ) -> Result<(), DatabaseError> { query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers) VALUES (?, ?, ?, ?)") .bind(torrent_id) .bind(tracker_url) @@ -642,10 +671,12 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::TorrentNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::TorrentNotFound) + } }) } diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 6b65eeb9..44e297a2 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -1,19 +1,19 @@ -use sqlx::{Acquire, query, query_as, SqlitePool}; -use sqlx::sqlite::SqlitePoolOptions; use async_trait::async_trait; -use chrono::{NaiveDateTime}; +use chrono::NaiveDateTime; +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::{query, query_as, Acquire, SqlitePool}; -use crate::models::torrent::TorrentListing; -use crate::utils::time::current_time; -use crate::models::tracker_key::{TrackerKey}; use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; -use crate::models::response::{TorrentsResponse}; +use crate::models::response::TorrentsResponse; +use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; use crate::utils::hex::bytes_to_hex; +use crate::utils::time::current_time; pub struct SqliteDatabase { - pub pool: SqlitePool + pub pool: SqlitePool, } impl SqliteDatabase { @@ -28,9 +28,7 @@ impl SqliteDatabase { .await .expect("Could not run database migrations."); - Self { - pool: db - } + Self { pool: db } } } @@ -41,23 +39,19 @@ impl Database for SqliteDatabase { } async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { - // open pool connection - let mut conn = self.pool.acquire() - .await - .map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; // start db transaction - let mut tx = conn.begin() - .await - .map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; // create the user account and get the user id - let user_id = query("INSERT INTO torrust_users (date_registered) VALUES (strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") - .execute(&mut tx) - .await - .map(|v| v.last_insert_rowid()) - .map_err(|_| DatabaseError::Error)?; + let user_id = + query("INSERT INTO torrust_users (date_registered) VALUES (strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") + .execute(&mut tx) + .await + .map(|v| v.last_insert_rowid()) + .map_err(|_| DatabaseError::Error)?; // add password hash for account let insert_user_auth_result = query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") @@ -70,7 +64,7 @@ impl Database for SqliteDatabase { // rollback transaction on error if let Err(e) = insert_user_auth_result { let _ = tx.rollback().await; - return Err(e) + return Err(e); } // add account profile details @@ -181,10 +175,12 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::UserNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::UserNotFound) + } }) } @@ -216,10 +212,12 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::UserNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::UserNotFound) + } }) } @@ -236,8 +234,8 @@ impl Database for SqliteDatabase { } else { DatabaseError::Error } - }, - _ => DatabaseError::Error + } + _ => DatabaseError::Error, }) } @@ -270,18 +268,27 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::CategoryNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::CategoryNotFound) + } }) } // TODO: refactor this - async fn get_torrents_search_sorted_paginated(&self, search: &Option, categories: &Option>, sort: &Sorting, offset: u64, page_size: u8) -> Result { + async fn get_torrents_search_sorted_paginated( + &self, + search: &Option, + categories: &Option>, + sort: &Sorting, + offset: u64, + page_size: u8, + ) -> Result { let title = match search { None => "%".to_string(), - Some(v) => format!("%{}%", v) + Some(v) => format!("%{}%", v), }; let sort_query: String = match sort { @@ -304,13 +311,18 @@ impl Database for SqliteDatabase { // don't take user input in the db query if let Ok(sanitized_category) = self.get_category_from_name(category).await { let mut str = format!("tc.name = '{}'", sanitized_category.name); - if i > 0 { str = format!(" OR {}", str); } + if i > 0 { + str = format!(" OR {}", str); + } category_filters.push_str(&str); i += 1; } } if category_filters.len() > 0 { - format!("INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", category_filters) + format!( + "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", + category_filters + ) } else { String::new() } @@ -354,22 +366,25 @@ impl Database for SqliteDatabase { Ok(TorrentsResponse { total: count as u32, - results: res + results: res, }) } - async fn insert_torrent_and_get_id(&self, torrent: &Torrent, uploader_id: i64, category_id: i64, title: &str, description: &str) -> Result { + async fn insert_torrent_and_get_id( + &self, + torrent: &Torrent, + uploader_id: i64, + category_id: i64, + title: &str, + description: &str, + ) -> Result { let info_hash = torrent.info_hash(); // open pool connection - let mut conn = self.pool.acquire() - .await - .map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; // start db transaction - let mut tx = conn.begin() - .await - .map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { @@ -439,7 +454,7 @@ impl Database for SqliteDatabase { // rollback transaction on error if let Err(e) = insert_torrent_files_result { let _ = tx.rollback().await; - return Err(e) + return Err(e); } let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(announce_urls) = &torrent.announce_list { @@ -472,27 +487,28 @@ impl Database for SqliteDatabase { // rollback transaction on error if let Err(e) = insert_torrent_announce_urls_result { let _ = tx.rollback().await; - return Err(e) + return Err(e); } - let insert_torrent_info_result = query(r#"INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, NULLIF(?, ""))"#) - .bind(torrent_id) - .bind(title) - .bind(description) - .execute(&mut tx) - .await - .map_err(|e| match e { - sqlx::Error::Database(err) => { - if err.message().contains("info_hash") { - DatabaseError::TorrentAlreadyExists - } else if err.message().contains("title") { - DatabaseError::TorrentTitleAlreadyExists - } else { - DatabaseError::Error + let insert_torrent_info_result = + query(r#"INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, NULLIF(?, ""))"#) + .bind(torrent_id) + .bind(title) + .bind(description) + .execute(&mut tx) + .await + .map_err(|e| match e { + sqlx::Error::Database(err) => { + if err.message().contains("info_hash") { + DatabaseError::TorrentAlreadyExists + } else if err.message().contains("title") { + DatabaseError::TorrentTitleAlreadyExists + } else { + DatabaseError::Error + } } - } - _ => DatabaseError::Error - }); + _ => DatabaseError::Error, + }); // commit or rollback transaction and return user_id on success match insert_torrent_info_result { @@ -514,43 +530,45 @@ impl Database for SqliteDatabase { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - Ok(Torrent::from_db_info_files_and_announce_urls(torrent_info, torrent_files, torrent_announce_urls)) + Ok(Torrent::from_db_info_files_and_announce_urls( + torrent_info, + torrent_files, + torrent_announce_urls, + )) } async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( - "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?" + "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) - .bind(torrent_id) - .fetch_one(&self.pool) - .await - .map_err(|_| DatabaseError::TorrentNotFound) + .bind(torrent_id) + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) } async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { - let db_torrent_files = query_as::<_, DbTorrentFile>( - "SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?" - ) - .bind(torrent_id) - .fetch_all(&self.pool) - .await - .map_err(|_| DatabaseError::TorrentNotFound)?; + let db_torrent_files = + query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound)?; - let torrent_files: Vec = db_torrent_files.into_iter().map(|tf| { - TorrentFile { + let torrent_files: Vec = db_torrent_files + .into_iter() + .map(|tf| TorrentFile { path: tf.path.unwrap_or("".to_string()).split('/').map(|v| v.to_string()).collect(), length: tf.length, - md5sum: tf.md5sum - } - }).collect(); + md5sum: tf.md5sum, + }) + .collect(); Ok(torrent_files) } async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, DatabaseError> { - query_as::<_, DbTorrentAnnounceUrl>( - "SELECT tracker_url FROM torrust_torrent_announce_urls WHERE torrent_id = ?" - ) + query_as::<_, DbTorrentAnnounceUrl>("SELECT tracker_url FROM torrust_torrent_announce_urls WHERE torrent_id = ?") .bind(torrent_id) .fetch_all(&self.pool) .await @@ -597,12 +615,14 @@ impl Database for SqliteDatabase { DatabaseError::Error } } - _ => DatabaseError::Error + _ => DatabaseError::Error, }) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::TorrentNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::TorrentNotFound) + } }) } @@ -613,14 +633,22 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::TorrentNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::TorrentNotFound) + } }) } - async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &str, seeders: i64, leechers: i64) -> Result<(), DatabaseError> { + async fn update_tracker_info( + &self, + torrent_id: i64, + tracker_url: &str, + seeders: i64, + leechers: i64, + ) -> Result<(), DatabaseError> { query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers) VALUES ($1, $2, $3, $4)") .bind(torrent_id) .bind(tracker_url) @@ -638,10 +666,12 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map_err(|_| DatabaseError::Error) - .and_then(|v| if v.rows_affected() > 0 { - Ok(()) - } else { - Err(DatabaseError::TorrentNotFound) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(DatabaseError::TorrentNotFound) + } }) } diff --git a/src/errors.rs b/src/errors.rs index 675ca05f..6f5ee0a2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,9 +1,11 @@ use std::borrow::Cow; -use derive_more::{Display, Error}; -use actix_web::{ResponseError, HttpResponse, HttpResponseBuilder}; +use std::error; + use actix_web::http::{header, StatusCode}; +use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError}; +use derive_more::{Display, Error}; use serde::{Deserialize, Serialize}; -use std::error; + use crate::databases::database::DatabaseError; pub type ServiceResult = Result; @@ -14,9 +16,7 @@ pub enum ServiceError { #[display(fmt = "internal server error")] InternalServerError, - #[display( - fmt = "This server is is closed for registration. Contact admin if this is unexpected" - )] + #[display(fmt = "This server is is closed for registration. Contact admin if this is unexpected")] ClosedForRegistration, #[display(fmt = "Email is required")] //405j @@ -174,19 +174,14 @@ impl ResponseError for ServiceError { ServiceError::CategoryExists => StatusCode::BAD_REQUEST, - _ => StatusCode::INTERNAL_SERVER_ERROR + _ => StatusCode::INTERNAL_SERVER_ERROR, } } fn error_response(&self) -> HttpResponse { HttpResponseBuilder::new(self.status_code()) .append_header((header::CONTENT_TYPE, "application/json; charset=UTF-8")) - .body( - serde_json::to_string(&ErrorToResponse { - error: self.to_string(), - }) - .unwrap(), - ) + .body(serde_json::to_string(&ErrorToResponse { error: self.to_string() }).unwrap()) .into() } } @@ -204,7 +199,7 @@ impl From for ServiceError { } } else { ServiceError::TorrentNotFound - } + }; } ServiceError::InternalServerError diff --git a/src/lib.rs b/src/lib.rs index a4f34c34..5a0100c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,27 @@ -pub mod routes; -pub mod models; -pub mod utils; +pub mod auth; +pub mod common; pub mod config; +pub mod databases; pub mod errors; -pub mod common; -pub mod auth; -pub mod tracker; pub mod mailer; -pub mod databases; +pub mod models; +pub mod routes; +pub mod tracker; +pub mod utils; trait AsCSV { fn as_csv(&self) -> Result>, ()> - where - T: std::str::FromStr; + where + T: std::str::FromStr; } impl AsCSV for Option - where - S: AsRef, +where + S: AsRef, { fn as_csv(&self) -> Result>, ()> - where - T: std::str::FromStr, + where + T: std::str::FromStr, { match self { Some(ref s) if !s.as_ref().trim().is_empty() => { diff --git a/src/mailer.rs b/src/mailer.rs index e71f213c..94dd8325 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -1,17 +1,19 @@ -use crate::config::Configuration; use std::sync::Arc; -use crate::errors::ServiceError; -use serde::{Serialize, Deserialize}; -use lettre::{AsyncSmtpTransport, Tokio1Executor, Message, AsyncTransport}; -use lettre::transport::smtp::authentication::{Credentials, Mechanism}; + +use jsonwebtoken::{encode, EncodingKey, Header}; use lettre::message::{MessageBuilder, MultiPart, SinglePart}; -use jsonwebtoken::{encode, Header, EncodingKey}; +use lettre::transport::smtp::authentication::{Credentials, Mechanism}; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use sailfish::TemplateOnce; +use serde::{Deserialize, Serialize}; + +use crate::config::Configuration; +use crate::errors::ServiceError; use crate::utils::time::current_time; pub struct MailerService { cfg: Arc, - mailer: Arc + mailer: Arc, } #[derive(Debug, Serialize, Deserialize)] @@ -28,15 +30,11 @@ struct VerifyTemplate { verification_url: String, } - impl MailerService { pub async fn new(cfg: Arc) -> MailerService { let mailer = Arc::new(Self::get_mailer(&cfg).await); - Self { - cfg, - mailer, - } + Self { cfg, mailer } } async fn get_mailer(cfg: &Configuration) -> Mailer { @@ -47,15 +45,17 @@ impl MailerService { AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) .port(settings.mail.port) .credentials(creds) - .authentication(vec![ - Mechanism::Login, - Mechanism::Xoauth2, - Mechanism::Plain, - ]) + .authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain]) .build() } - pub async fn send_verification_mail(&self, to: &str, username: &str, user_id: i64, base_url: &str) -> Result<(), ServiceError> { + pub async fn send_verification_mail( + &self, + to: &str, + username: &str, + user_id: i64, + base_url: &str, + ) -> Result<(), ServiceError> { let builder = self.get_builder(to).await; let verification_url = self.get_verification_url(user_id, base_url).await; @@ -68,8 +68,7 @@ impl MailerService { If this account wasn't made by you, you can ignore this email. "#, - username, - verification_url + username, verification_url ); let ctx = VerifyTemplate { @@ -84,13 +83,13 @@ impl MailerService { .singlepart( SinglePart::builder() .header(lettre::message::header::ContentType::TEXT_PLAIN) - .body(mail_body) + .body(mail_body), ) .singlepart( SinglePart::builder() .header(lettre::message::header::ContentType::TEXT_HTML) - .body(ctx.render_once().unwrap()) - ) + .body(ctx.render_once().unwrap()), + ), ) .unwrap(); @@ -99,7 +98,7 @@ impl MailerService { Err(e) => { eprintln!("Failed to send email: {}", e); Err(ServiceError::FailedToSendVerificationEmail) - }, + } } } @@ -122,15 +121,10 @@ impl MailerService { let claims = VerifyClaims { iss: String::from("email-verification"), sub: user_id, - exp: current_time() + 315_569_260 // 10 years from now + exp: current_time() + 315_569_260, // 10 years from now }; - let token = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(key), - ) - .unwrap(); + let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); let mut base_url = base_url.clone(); if let Some(cfg_base_url) = &settings.net.base_url { diff --git a/src/main.rs b/src/main.rs index 7fc5d0f6..ce7bf581 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,14 @@ use std::sync::Arc; -use actix_web::{App, HttpServer, middleware, web}; + use actix_cors::Cors; -use torrust_index_backend::{routes}; -use torrust_index_backend::config::{Configuration}; -use torrust_index_backend::common::AppData; +use actix_web::{middleware, web, App, HttpServer}; use torrust_index_backend::auth::AuthorizationService; +use torrust_index_backend::common::AppData; +use torrust_index_backend::config::Configuration; use torrust_index_backend::databases::database::connect_database; -use torrust_index_backend::tracker::TrackerService; use torrust_index_backend::mailer::MailerService; +use torrust_index_backend::routes; +use torrust_index_backend::tracker::TrackerService; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -20,23 +21,22 @@ async fn main() -> std::io::Result<()> { let settings = cfg.settings.read().await; - let database = Arc::new(connect_database(&settings.database.connect_url) + let database = Arc::new( + connect_database(&settings.database.connect_url) .await - .expect("Database error.") + .expect("Database error."), ); let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); - let app_data = Arc::new( - AppData::new( - cfg.clone(), - database.clone(), - auth.clone(), - tracker_service.clone(), - mailer_service.clone(), - ) - ); + let app_data = Arc::new(AppData::new( + cfg.clone(), + database.clone(), + auth.clone(), + tracker_service.clone(), + mailer_service.clone(), + )); let interval = settings.database.torrent_info_update_interval; let weak_tracker_service = Arc::downgrade(&tracker_service); @@ -69,7 +69,7 @@ async fn main() -> std::io::Result<()> { .wrap(middleware::Logger::default()) .configure(routes::init_routes) }) - .bind(("0.0.0.0", port))? - .run() - .await + .bind(("0.0.0.0", port))? + .run() + .await } diff --git a/src/models/mod.rs b/src/models/mod.rs index cb31379a..acfccf77 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,5 @@ -pub mod user; +pub mod response; pub mod torrent; pub mod torrent_file; -pub mod response; pub mod tracker_key; +pub mod user; diff --git a/src/models/response.rs b/src/models/response.rs index ac34a34b..059e1c3b 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -1,20 +1,21 @@ use serde::{Deserialize, Serialize}; + use crate::databases::database::Category; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::TorrentFile; pub enum OkResponses { - TokenResponse(TokenResponse) + TokenResponse(TokenResponse), } #[derive(Serialize, Deserialize, Debug)] pub struct OkResponse { - pub data: T + pub data: T, } #[derive(Serialize, Deserialize, Debug)] pub struct ErrorResponse { - pub errors: Vec + pub errors: Vec, } #[derive(Serialize, Deserialize, Debug)] @@ -54,7 +55,11 @@ impl TorrentResponse { info_hash: torrent_listing.info_hash, title: torrent_listing.title, description: torrent_listing.description, - category: Category { category_id: 0, name: "".to_string(), num_torrents: 0 }, + category: Category { + category_id: 0, + name: "".to_string(), + num_torrents: 0, + }, upload_date: torrent_listing.date_uploaded, file_size: torrent_listing.file_size, seeders: torrent_listing.seeders, diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 0395b985..4adab162 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; + use crate::models::torrent_file::Torrent; use crate::routes::torrent::CreateTorrent; diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index c659b67f..98cd4c00 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; -use crate::config::Configuration; use serde_bencode::ser; use serde_bytes::ByteBuf; use sha1::{Digest, Sha1}; + +use crate::config::Configuration; use crate::utils::hex::{bytes_to_hex, hex_to_bytes}; #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] @@ -21,7 +22,7 @@ pub struct TorrentInfo { pub name: String, #[serde(default)] pub pieces: Option, - #[serde(rename="piece length")] + #[serde(rename = "piece length")] pub piece_length: i64, #[serde(default)] pub md5sum: Option, @@ -34,7 +35,7 @@ pub struct TorrentInfo { #[serde(default)] pub path: Option>, #[serde(default)] - #[serde(rename="root hash")] + #[serde(rename = "root hash")] pub root_hash: Option, } @@ -50,20 +51,24 @@ pub struct Torrent { #[serde(default)] pub httpseeds: Option>, #[serde(default)] - #[serde(rename="announce-list")] + #[serde(rename = "announce-list")] pub announce_list: Option>>, #[serde(default)] - #[serde(rename="creation date")] + #[serde(rename = "creation date")] pub creation_date: Option, - #[serde(rename="comment")] + #[serde(rename = "comment")] pub comment: Option, #[serde(default)] - #[serde(rename="created by")] + #[serde(rename = "created by")] pub created_by: Option, } impl Torrent { - pub fn from_db_info_files_and_announce_urls(torrent_info: DbTorrentInfo, torrent_files: Vec, torrent_announce_urls: Vec>) -> Self { + pub fn from_db_info_files_and_announce_urls( + torrent_info: DbTorrentInfo, + torrent_files: Vec, + torrent_announce_urls: Vec>, + ) -> Self { let private = if let Some(private_i64) = torrent_info.private { // must fit in a byte let private = if (0..256).contains(&private_i64) { private_i64 } else { 0 }; @@ -82,7 +87,7 @@ impl Torrent { files: None, private, path: None, - root_hash: None + root_hash: None, }; // a torrent file has a root hash or a pieces key, but not both. @@ -122,7 +127,7 @@ impl Torrent { announce_list: Some(torrent_announce_urls), creation_date: None, comment: None, - created_by: None + created_by: None, } } @@ -155,7 +160,7 @@ impl Torrent { pub fn file_size(&self) -> i64 { if self.info.length.is_some() { - return self.info.length.unwrap() + return self.info.length.unwrap(); } else { match &self.info.files { None => 0, diff --git a/src/models/tracker_key.rs b/src/models/tracker_key.rs index d130cd6d..71bf51c3 100644 --- a/src/models/tracker_key.rs +++ b/src/models/tracker_key.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use sqlx::FromRow; #[derive(Debug, Serialize, Deserialize, FromRow)] diff --git a/src/models/user.rs b/src/models/user.rs index 3885aaa2..53d0a4e0 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct User { diff --git a/src/routes/about.rs b/src/routes/about.rs index 9f23c4d1..2a632c85 100644 --- a/src/routes/about.rs +++ b/src/routes/about.rs @@ -1,17 +1,13 @@ -use actix_web::{Responder, web, HttpResponse}; use actix_web::http::StatusCode; +use actix_web::{web, HttpResponse, Responder}; use crate::errors::ServiceResult; pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/about") - .service(web::resource("") - .route(web::get().to(get_about)) - ) - .service(web::resource("/license") - .route(web::get().to(get_license)) - ) + .service(web::resource("").route(web::get().to(get_about))) + .service(web::resource("/license").route(web::get().to(get_license))), ); } @@ -36,8 +32,7 @@ const ABOUT: &str = r#" pub async fn get_about() -> ServiceResult { Ok(HttpResponse::build(StatusCode::OK) .content_type("text/html; charset=utf-8") - .body(ABOUT) - ) + .body(ABOUT)) } const LICENSE: &str = r#" @@ -71,6 +66,5 @@ const LICENSE: &str = r#" pub async fn get_license() -> ServiceResult { Ok(HttpResponse::build(StatusCode::OK) .content_type("text/html; charset=utf-8") - .body(LICENSE) - ) + .body(LICENSE)) } diff --git a/src/routes/category.rs b/src/routes/category.rs index 34db369d..8bcd0348 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -1,33 +1,31 @@ -use actix_web::{HttpRequest, HttpResponse, Responder, web}; -use serde::{Serialize, Deserialize}; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; -use crate::models::response::{OkResponse}; +use crate::models::response::OkResponse; pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/category") - .service(web::resource("") + web::scope("/category").service( + web::resource("") .route(web::get().to(get_categories)) .route(web::post().to(add_category)) - .route(web::delete().to(delete_category)) - ) + .route(web::delete().to(delete_category)), + ), ); } pub async fn get_categories(app_data: WebAppData) -> ServiceResult { let categories = app_data.database.get_categories().await?; - Ok(HttpResponse::Ok().json(OkResponse { - data: categories - })) + Ok(HttpResponse::Ok().json(OkResponse { data: categories })) } #[derive(Debug, Serialize, Deserialize)] pub struct Category { pub name: String, - pub icon: Option + pub icon: Option, } pub async fn add_category(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { @@ -35,25 +33,33 @@ pub async fn add_category(req: HttpRequest, payload: web::Json, app_da let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator - if !user.administrator { return Err(ServiceError::Unauthorized) } + if !user.administrator { + return Err(ServiceError::Unauthorized); + } let _ = app_data.database.insert_category_and_get_id(&payload.name).await?; Ok(HttpResponse::Ok().json(OkResponse { - data: payload.name.clone() + data: payload.name.clone(), })) } -pub async fn delete_category(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { +pub async fn delete_category( + req: HttpRequest, + payload: web::Json, + app_data: WebAppData, +) -> ServiceResult { // check for user let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator - if !user.administrator { return Err(ServiceError::Unauthorized) } + if !user.administrator { + return Err(ServiceError::Unauthorized); + } let _ = app_data.database.delete_category(&payload.name).await?; Ok(HttpResponse::Ok().json(OkResponse { - data: payload.name.clone() + data: payload.name.clone(), })) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index dbbcc31a..5761390a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,11 +1,11 @@ use actix_web::web; -pub mod user; -pub mod torrent; -pub mod category; -pub mod settings; pub mod about; +pub mod category; pub mod root; +pub mod settings; +pub mod torrent; +pub mod user; pub fn init_routes(cfg: &mut web::ServiceConfig) { user::init_routes(cfg); diff --git a/src/routes/root.rs b/src/routes/root.rs index 9ae00e4e..69f11fd6 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -1,11 +1,7 @@ use actix_web::web; + use crate::routes::about; pub fn init_routes(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("/") - .service(web::resource("") - .route(web::get().to(about::get_about)) - ) - ); + cfg.service(web::scope("/").service(web::resource("").route(web::get().to(about::get_about)))); } diff --git a/src/routes/settings.rs b/src/routes/settings.rs index fc48a7e1..6ba5d2aa 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -1,22 +1,20 @@ -use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; + use crate::common::WebAppData; -use crate::config::{TorrustConfig}; +use crate::config::TorrustConfig; use crate::errors::{ServiceError, ServiceResult}; -use crate::models::response::{OkResponse}; +use crate::models::response::OkResponse; pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/settings") - .service(web::resource("") - .route(web::get().to(get_settings)) - .route(web::post().to(update_settings)) - ) - .service(web::resource("/name") - .route(web::get().to(get_site_name)) - ) - .service(web::resource("/public") - .route(web::get().to(get_public_settings)) + .service( + web::resource("") + .route(web::get().to(get_settings)) + .route(web::post().to(update_settings)), ) + .service(web::resource("/name").route(web::get().to(get_site_name))) + .service(web::resource("/public").route(web::get().to(get_public_settings))), ); } @@ -25,43 +23,45 @@ pub async fn get_settings(req: HttpRequest, app_data: WebAppData) -> ServiceResu let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator - if !user.administrator { return Err(ServiceError::Unauthorized) } + if !user.administrator { + return Err(ServiceError::Unauthorized); + } let settings = app_data.cfg.settings.read().await; - Ok(HttpResponse::Ok().json(OkResponse { - data: &*settings - })) + Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) } pub async fn get_public_settings(app_data: WebAppData) -> ServiceResult { let public_settings = app_data.cfg.get_public().await; - Ok(HttpResponse::Ok().json(OkResponse { - data: public_settings - })) + Ok(HttpResponse::Ok().json(OkResponse { data: public_settings })) } pub async fn get_site_name(app_data: WebAppData) -> ServiceResult { let settings = app_data.cfg.settings.read().await; Ok(HttpResponse::Ok().json(OkResponse { - data: &settings.website.name + data: &settings.website.name, })) } -pub async fn update_settings(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { +pub async fn update_settings( + req: HttpRequest, + payload: web::Json, + app_data: WebAppData, +) -> ServiceResult { // check for user let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator - if !user.administrator { return Err(ServiceError::Unauthorized) } + if !user.administrator { + return Err(ServiceError::Unauthorized); + } let _ = app_data.cfg.update_settings(payload.into_inner()).await; let settings = app_data.cfg.settings.read().await; - Ok(HttpResponse::Ok().json(OkResponse { - data: &*settings - })) + Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) } diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index ea43b3d9..5d87e8b2 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -1,37 +1,33 @@ +use std::io::{Cursor, Write}; + use actix_multipart::Multipart; -use actix_web::{HttpRequest, HttpResponse, Responder, web}; -use actix_web::web::{Query}; +use actix_web::web::Query; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; use futures::{StreamExt, TryStreamExt}; -use serde::{Deserialize}; -use std::io::Cursor; -use std::io::{Write}; -use sqlx::{FromRow}; +use serde::Deserialize; +use sqlx::FromRow; -use crate::AsCSV; +use crate::common::WebAppData; use crate::databases::database::Sorting; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::{NewTorrentResponse, OkResponse, TorrentResponse}; use crate::models::torrent::TorrentRequest; use crate::utils::parse_torrent; -use crate::common::{WebAppData}; +use crate::AsCSV; pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/torrent") - .service(web::resource("/upload") - .route(web::post().to(upload_torrent))) - .service(web::resource("/download/{id}") - .route(web::get().to(download_torrent))) - .service(web::resource("/{id}") - .route(web::get().to(get_torrent)) - .route(web::put().to(update_torrent)) - .route(web::delete().to(delete_torrent))) - ); - cfg.service( - web::scope("/torrents") - .service(web::resource("") - .route(web::get().to(get_torrents))) + .service(web::resource("/upload").route(web::post().to(upload_torrent))) + .service(web::resource("/download/{id}").route(web::get().to(download_torrent))) + .service( + web::resource("/{id}") + .route(web::get().to(get_torrent)) + .route(web::put().to(update_torrent)) + .route(web::delete().to(delete_torrent)), + ), ); + cfg.service(web::scope("/torrents").service(web::resource("").route(web::get().to(get_torrents)))); } #[derive(FromRow)] @@ -47,9 +43,9 @@ pub struct CreateTorrent { } impl CreateTorrent { - pub fn verify(&self) -> Result<(), ServiceError>{ + pub fn verify(&self) -> Result<(), ServiceError> { if !self.title.is_empty() && !self.category.is_empty() { - return Ok(()) + return Ok(()); } Err(ServiceError::BadRequest) @@ -69,7 +65,7 @@ pub struct TorrentSearch { #[derive(Debug, Deserialize)] pub struct TorrentUpdate { title: Option, - description: Option + description: Option, } pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { @@ -82,20 +78,30 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA torrent_request.torrent.set_torrust_config(&app_data.cfg).await; // get the correct category name from database - let category = app_data.database.get_category_from_name(&torrent_request.fields.category).await + let category = app_data + .database + .get_category_from_name(&torrent_request.fields.category) + .await .map_err(|_| ServiceError::InvalidCategory)?; // insert entire torrent in database - let torrent_id = app_data.database.insert_torrent_and_get_id( - &torrent_request.torrent, - user.user_id, - category.category_id, - &torrent_request.fields.title, - &torrent_request.fields.description - ).await?; + let torrent_id = app_data + .database + .insert_torrent_and_get_id( + &torrent_request.torrent, + user.user_id, + category.category_id, + &torrent_request.fields.title, + &torrent_request.fields.description, + ) + .await?; // whitelist info hash on tracker - if let Err(e) = app_data.tracker.whitelist_info_hash(torrent_request.torrent.info_hash()).await { + if let Err(e) = app_data + .tracker + .whitelist_info_hash(torrent_request.torrent.info_hash()) + .await + { // if the torrent can't be whitelisted somehow, remove the torrent from database let _ = app_data.database.delete_torrent(torrent_id).await; return Err(e); @@ -103,9 +109,7 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA // respond with the newly uploaded torrent id Ok(HttpResponse::Ok().json(OkResponse { - data: NewTorrentResponse { - torrent_id - } + data: NewTorrentResponse { torrent_id }, })) } @@ -126,7 +130,11 @@ pub async fn download_torrent(req: HttpRequest, app_data: WebAppData) -> Service // add personal tracker url or default tracker url match user { Ok(user) => { - let personal_announce_url = app_data.tracker.get_personal_announce_url(user.user_id).await.unwrap_or(tracker_url); + let personal_announce_url = app_data + .tracker + .get_personal_announce_url(user.user_id) + .await + .unwrap_or(tracker_url); torrent.announce = Some(personal_announce_url.clone()); if let Some(list) = &mut torrent.announce_list { let vec = vec![personal_announce_url]; @@ -140,10 +148,7 @@ pub async fn download_torrent(req: HttpRequest, app_data: WebAppData) -> Service let buffer = parse_torrent::encode_torrent(&torrent).map_err(|_| ServiceError::InternalServerError)?; - Ok(HttpResponse::Ok() - .content_type("application/x-bittorrent") - .body(buffer) - ) + Ok(HttpResponse::Ok().content_type("application/x-bittorrent").body(buffer)) } pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResult { @@ -171,10 +176,15 @@ pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResul if torrent_response.files.len() == 1 { let torrent_info = app_data.database.get_torrent_info_from_id(torrent_id).await?; - torrent_response.files.iter_mut().for_each(|v| v.path = vec![torrent_info.name.to_string()]); + torrent_response + .files + .iter_mut() + .for_each(|v| v.path = vec![torrent_info.name.to_string()]); } - torrent_response.trackers = app_data.database.get_torrent_announce_urls_from_id(torrent_id) + torrent_response.trackers = app_data + .database + .get_torrent_announce_urls_from_id(torrent_id) .await .map(|v| v.into_iter().flatten().collect())?; @@ -182,17 +192,25 @@ pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResul match user { Ok(user) => { // if no user owned tracker key can be found, use default tracker url - let personal_announce_url = app_data.tracker.get_personal_announce_url(user.user_id).await.unwrap_or(tracker_url); + let personal_announce_url = app_data + .tracker + .get_personal_announce_url(user.user_id) + .await + .unwrap_or(tracker_url); // add personal tracker url to front of vec torrent_response.trackers.insert(0, personal_announce_url); - }, + } Err(_) => { torrent_response.trackers.insert(0, tracker_url); } } // add magnet link - let mut magnet = format!("magnet:?xt=urn:btih:{}&dn={}", torrent_response.info_hash, urlencoding::encode(&torrent_response.title)); + let mut magnet = format!( + "magnet:?xt=urn:btih:{}&dn={}", + torrent_response.info_hash, + urlencoding::encode(&torrent_response.title) + ); // add trackers from torrent file to magnet link for tracker in &torrent_response.trackers { @@ -202,17 +220,23 @@ pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResul torrent_response.magnet_link = magnet; // get realtime seeders and leechers - if let Ok(torrent_info) = app_data.tracker.get_torrent_info(torrent_response.torrent_id, &torrent_response.info_hash).await { + if let Ok(torrent_info) = app_data + .tracker + .get_torrent_info(torrent_response.torrent_id, &torrent_response.info_hash) + .await + { torrent_response.seeders = torrent_info.seeders; torrent_response.leechers = torrent_info.leechers; } - Ok(HttpResponse::Ok().json(OkResponse { - data: torrent_response - })) + Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } -pub async fn update_torrent(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { +pub async fn update_torrent( + req: HttpRequest, + payload: web::Json, + app_data: WebAppData, +) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; let torrent_id = get_torrent_id_from_request(&req)?; @@ -220,7 +244,9 @@ pub async fn update_torrent(req: HttpRequest, payload: web::Json, let torrent_listing = app_data.database.get_torrent_listing_from_id(torrent_id).await?; // check if user is owner or administrator - if torrent_listing.uploader != user.username && !user.administrator { return Err(ServiceError::Unauthorized) } + if torrent_listing.uploader != user.username && !user.administrator { + return Err(ServiceError::Unauthorized); + } // update torrent title if let Some(title) = &payload.title { @@ -236,16 +262,16 @@ pub async fn update_torrent(req: HttpRequest, payload: web::Json, let torrent_response = TorrentResponse::from_listing(torrent_listing); - Ok(HttpResponse::Ok().json(OkResponse { - data: torrent_response - })) + Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } pub async fn delete_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator - if !user.administrator { return Err(ServiceError::Unauthorized) } + if !user.administrator { + return Err(ServiceError::Unauthorized); + } let torrent_id = get_torrent_id_from_request(&req)?; @@ -255,12 +281,13 @@ pub async fn delete_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceRe let _res = app_data.database.delete_torrent(torrent_id).await?; // remove info_hash from tracker whitelist - let _ = app_data.tracker.remove_info_hash_from_whitelist(torrent_listing.info_hash).await; + let _ = app_data + .tracker + .remove_info_hash_from_whitelist(torrent_listing.info_hash) + .await; Ok(HttpResponse::Ok().json(OkResponse { - data: NewTorrentResponse { - torrent_id - } + data: NewTorrentResponse { torrent_id }, })) } @@ -272,30 +299,29 @@ pub async fn get_torrents(params: Query, app_data: WebAppData) -> // make sure the min page size = 10 let page_size = match params.page_size.unwrap_or(30) { - 0 ..= 9 => 10, - v => v + 0..=9 => 10, + v => v, }; let offset = (page * page_size as u32) as u64; let categories = params.categories.as_csv::().unwrap_or(None); - let torrents_response = app_data.database.get_torrents_search_sorted_paginated(¶ms.search, &categories, &sort, offset, page_size as u8).await?; + let torrents_response = app_data + .database + .get_torrents_search_sorted_paginated(¶ms.search, &categories, &sort, offset, page_size as u8) + .await?; - Ok(HttpResponse::Ok().json(OkResponse { - data: torrents_response - })) + Ok(HttpResponse::Ok().json(OkResponse { data: torrents_response })) } fn get_torrent_id_from_request(req: &HttpRequest) -> Result { match req.match_info().get("id") { None => Err(ServiceError::BadRequest), - Some(torrent_id) => { - match torrent_id.parse() { - Err(_) => Err(ServiceError::BadRequest), - Ok(v) => Ok(v) - } - } + Some(torrent_id) => match torrent_id.parse() { + Err(_) => Err(ServiceError::BadRequest), + Ok(v) => Ok(v), + }, } } @@ -314,20 +340,22 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let data = field.next().await; - if data.is_none() { continue } + if data.is_none() { + continue; + } let wrapped_data = &data.unwrap().unwrap(); let parsed_data = std::str::from_utf8(&wrapped_data).unwrap(); match name { - "title" => { title = parsed_data.to_string() } - "description" => { description = parsed_data.to_string() } - "category" => { category = parsed_data.to_string() } + "title" => title = parsed_data.to_string(), + "description" => description = parsed_data.to_string(), + "category" => category = parsed_data.to_string(), _ => {} } } "torrent" => { if *field.content_type() != "application/x-bittorrent" { - return Err(ServiceError::InvalidFileType) + return Err(ServiceError::InvalidFileType); } while let Some(chunk) = field.next().await { @@ -354,11 +382,10 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result, app_da match settings.auth.email_on_signup { EmailOnSignup::Required => { - if payload.email.is_none() { return Err(ServiceError::EmailMissing) } - } - EmailOnSignup::None => { - payload.email = None + if payload.email.is_none() { + return Err(ServiceError::EmailMissing); + } } + EmailOnSignup::None => payload.email = None, _ => {} } if let Some(email) = &payload.email { // check if email address is valid if !validate_email_address(email) { - return Err(ServiceError::EmailInvalid) + return Err(ServiceError::EmailInvalid); } } if payload.password != payload.confirm_password { - return Err(ServiceError::PasswordsDontMatch) + return Err(ServiceError::PasswordsDontMatch); } let password_length = payload.password.len(); if password_length <= settings.auth.min_password_length { - return Err(ServiceError::PasswordTooShort) + return Err(ServiceError::PasswordTooShort); } if password_length >= settings.auth.max_password_length { - return Err(ServiceError::PasswordTooLong) + return Err(ServiceError::PasswordTooLong); } let salt = SaltString::generate(&mut OsRng); @@ -94,12 +87,15 @@ pub async fn register(req: HttpRequest, mut payload: web::Json, app_da let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt)?.to_string(); if payload.username.contains('@') { - return Err(ServiceError::UsernameInvalid) + return Err(ServiceError::UsernameInvalid); } let email = payload.email.as_ref().unwrap_or(&"".to_string()).to_string(); - let user_id = app_data.database.insert_user_and_get_id(&payload.username, &email, &password_hash).await?; + let user_id = app_data + .database + .insert_user_and_get_id(&payload.username, &email, &password_hash) + .await?; // if this is the first created account, give administrator rights if user_id == 1 { @@ -109,17 +105,19 @@ pub async fn register(req: HttpRequest, mut payload: web::Json, app_da let conn_info = req.connection_info(); if settings.mail.email_verification_enabled && payload.email.is_some() { - let mail_res = app_data.mailer.send_verification_mail( - payload.email.as_ref().unwrap(), - &payload.username, - user_id, - format!("{}://{}", conn_info.scheme(), conn_info.host()).as_str() - ) + let mail_res = app_data + .mailer + .send_verification_mail( + payload.email.as_ref().unwrap(), + &payload.username, + user_id, + format!("{}://{}", conn_info.scheme(), conn_info.host()).as_str(), + ) .await; if mail_res.is_err() { let _ = app_data.database.delete_user(user_id).await; - return Err(ServiceError::FailedToSendVerificationEmail) + return Err(ServiceError::FailedToSendVerificationEmail); } } @@ -128,12 +126,16 @@ pub async fn register(req: HttpRequest, mut payload: web::Json, app_da pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceResult { // get the user profile from database - let user_profile = app_data.database.get_user_profile_from_username(&payload.login) + let user_profile = app_data + .database + .get_user_profile_from_username(&payload.login) .await .map_err(|_| ServiceError::WrongPasswordOrUsername)?; // should not be able to fail if user_profile succeeded - let user_authentication = app_data.database.get_user_authentication_from_id(user_profile.user_id) + let user_authentication = app_data + .database + .get_user_authentication_from_id(user_profile.user_id) .await .map_err(|_| ServiceError::InternalServerError)?; @@ -141,15 +143,18 @@ pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceRe let parsed_hash = PasswordHash::new(&user_authentication.password_hash)?; // verify if the user supplied and the database supplied passwords match - if Argon2::default().verify_password(payload.password.as_bytes(), &parsed_hash).is_err() { - return Err(ServiceError::WrongPasswordOrUsername) + if Argon2::default() + .verify_password(payload.password.as_bytes(), &parsed_hash) + .is_err() + { + return Err(ServiceError::WrongPasswordOrUsername); } let settings = app_data.cfg.settings.read().await; // fail login if email verification is required and this email is not verified if settings.mail.email_verification_enabled && !user_profile.email_verified { - return Err(ServiceError::EmailNotVerified) + return Err(ServiceError::EmailNotVerified); } // drop read lock on settings @@ -160,13 +165,12 @@ pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceRe // sign jwt with compact user details as payload let token = app_data.auth.sign_jwt(user_compact.clone()).await; - Ok(HttpResponse::Ok().json(OkResponse { data: TokenResponse { token, username: user_compact.username, - admin: user_compact.administrator - } + admin: user_compact.administrator, + }, })) } @@ -175,7 +179,7 @@ pub async fn verify_token(payload: web::Json, app_data: WebAppData) -> Se let _claims = app_data.auth.verify_jwt(&payload.token).await?; Ok(HttpResponse::Ok().json(OkResponse { - data: format!("Token is valid.") + data: format!("Token is valid."), })) } @@ -190,15 +194,15 @@ pub async fn renew_token(payload: web::Json, app_data: WebAppData) -> Ser // renew token if it is valid for less than one week let token = match claims.exp - current_time() { x if x < ONE_WEEK_IN_SECONDS => app_data.auth.sign_jwt(user_compact.clone()).await, - _ => payload.token.clone() + _ => payload.token.clone(), }; Ok(HttpResponse::Ok().json(OkResponse { data: TokenResponse { token, username: user_compact.username, - admin: user_compact.administrator - } + admin: user_compact.administrator, + }, })) } @@ -213,18 +217,18 @@ pub async fn verify_email(req: HttpRequest, app_data: WebAppData) -> String { ) { Ok(token_data) => { if !token_data.claims.iss.eq("email-verification") { - return ServiceError::TokenInvalid.to_string() + return ServiceError::TokenInvalid.to_string(); } token_data.claims - }, - Err(_) => return ServiceError::TokenInvalid.to_string() + } + Err(_) => return ServiceError::TokenInvalid.to_string(), }; drop(settings); if app_data.database.verify_email(token_data.sub).await.is_err() { - return ServiceError::InternalServerError.to_string() + return ServiceError::InternalServerError.to_string(); }; String::from("Email verified, you can close this page.") @@ -235,20 +239,26 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult, - pub client: Option + pub client: Option, } pub struct TrackerService { cfg: Arc, - database: Arc> + database: Arc>, } impl TrackerService { pub fn new(cfg: Arc, database: Arc>) -> TrackerService { - TrackerService { - cfg, - database - } + TrackerService { cfg, database } } pub async fn whitelist_info_hash(&self, info_hash: String) -> Result<(), ServiceError> { let settings = self.cfg.settings.read().await; - let request_url = format!("{}/api/whitelist/{}?token={}", settings.tracker.api_url, info_hash, settings.tracker.token); + let request_url = format!( + "{}/api/whitelist/{}?token={}", + settings.tracker.api_url, info_hash, settings.tracker.token + ); drop(settings); let client = reqwest::Client::new(); - let response = client.post(request_url).send().await.map_err(|_| ServiceError::TrackerOffline)?; + let response = client + .post(request_url) + .send() + .await + .map_err(|_| ServiceError::TrackerOffline)?; if response.status().is_success() { Ok(()) @@ -67,8 +71,10 @@ impl TrackerService { pub async fn remove_info_hash_from_whitelist(&self, info_hash: String) -> Result<(), ServiceError> { let settings = self.cfg.settings.read().await; - let request_url = - format!("{}/api/whitelist/{}?token={}", settings.tracker.api_url, info_hash, settings.tracker.token); + let request_url = format!( + "{}/api/whitelist/{}?token={}", + settings.tracker.api_url, info_hash, settings.tracker.token + ); drop(settings); @@ -76,11 +82,11 @@ impl TrackerService { let response = match client.delete(request_url).send().await { Ok(v) => Ok(v), - Err(_) => Err(ServiceError::InternalServerError) + Err(_) => Err(ServiceError::InternalServerError), }?; if response.status().is_success() { - return Ok(()) + return Ok(()); } Err(ServiceError::InternalServerError) @@ -95,13 +101,11 @@ impl TrackerService { let tracker_key = self.database.get_user_tracker_key(user_id).await; match tracker_key { - Some(v) => { Ok(format!("{}/{}", settings.tracker.url, v.key)) } - None => { - match self.retrieve_new_tracker_key(user_id).await { - Ok(v) => { Ok(format!("{}/{}", settings.tracker.url, v.key)) }, - Err(_) => { Err(ServiceError::TrackerOffline) } - } - } + Some(v) => Ok(format!("{}/{}", settings.tracker.url, v.key)), + None => match self.retrieve_new_tracker_key(user_id).await { + Ok(v) => Ok(format!("{}/{}", settings.tracker.url, v.key)), + Err(_) => Err(ServiceError::TrackerOffline), + }, } } @@ -109,17 +113,27 @@ impl TrackerService { pub async fn retrieve_new_tracker_key(&self, user_id: i64) -> Result { let settings = self.cfg.settings.read().await; - let request_url = format!("{}/api/key/{}?token={}", settings.tracker.api_url, settings.tracker.token_valid_seconds, settings.tracker.token); + let request_url = format!( + "{}/api/key/{}?token={}", + settings.tracker.api_url, settings.tracker.token_valid_seconds, settings.tracker.token + ); drop(settings); let client = reqwest::Client::new(); // issue new tracker key - let response = client.post(request_url).send().await.map_err(|_| ServiceError::InternalServerError)?; + let response = client + .post(request_url) + .send() + .await + .map_err(|_| ServiceError::InternalServerError)?; // get tracker key from response - let tracker_key = response.json::().await.map_err(|_| ServiceError::InternalServerError)?; + let tracker_key = response + .json::() + .await + .map_err(|_| ServiceError::InternalServerError)?; // add tracker key to database (tied to a user) self.database.add_tracker_key(user_id, &tracker_key).await?; @@ -134,24 +148,27 @@ impl TrackerService { let tracker_url = settings.tracker.url.clone(); - let request_url = - format!("{}/api/torrent/{}?token={}", settings.tracker.api_url, info_hash, settings.tracker.token); + let request_url = format!( + "{}/api/torrent/{}?token={}", + settings.tracker.api_url, info_hash, settings.tracker.token + ); drop(settings); let client = reqwest::Client::new(); - let response = match client.get(request_url) - .send() - .await { + let response = match client.get(request_url).send().await { Ok(v) => Ok(v), - Err(_) => Err(ServiceError::InternalServerError) + Err(_) => Err(ServiceError::InternalServerError), }?; let torrent_info = match response.json::().await { Ok(torrent_info) => { - let _ = self.database.update_tracker_info(torrent_id, &tracker_url, torrent_info.seeders, torrent_info.leechers).await; + let _ = self + .database + .update_tracker_info(torrent_id, &tracker_url, torrent_info.seeders, torrent_info.leechers) + .await; Ok(torrent_info) - }, + } Err(_) => { let _ = self.database.update_tracker_info(torrent_id, &tracker_url, 0, 0).await; Err(ServiceError::TorrentNotFound) diff --git a/src/utils/hex.rs b/src/utils/hex.rs index 1432a474..7903c741 100644 --- a/src/utils/hex.rs +++ b/src/utils/hex.rs @@ -6,7 +6,7 @@ pub fn bytes_to_hex(bytes: &[u8]) -> String { for byte in bytes { write!(s, "{:02X}", byte).unwrap(); - }; + } s } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 7226920a..53ec37a3 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ -pub mod parse_torrent; -pub mod time; pub mod hex; +pub mod parse_torrent; pub mod regex; +pub mod time; diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 3b4df6f4..e272ede8 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -1,5 +1,7 @@ -use std::{error}; +use std::error; + use serde_bencode::{de, Error}; + use crate::models::torrent_file::Torrent; pub fn decode_torrent(bytes: &[u8]) -> Result> { diff --git a/tests/databases/mod.rs b/tests/databases/mod.rs index adf8bc52..c5a03519 100644 --- a/tests/databases/mod.rs +++ b/tests/databases/mod.rs @@ -1,15 +1,16 @@ use std::future::Future; + use torrust_index_backend::databases::database::{connect_database, Database}; mod mysql; -mod tests; mod sqlite; +mod tests; // used to run tests with a clean database async fn run_test<'a, T, F>(db_fn: T, db: &'a Box) - where - T: FnOnce(&'a Box) -> F + 'a, - F: Future +where + T: FnOnce(&'a Box) -> F + 'a, + F: Future, { // cleanup database before testing assert!(db.delete_all_database_rows().await.is_ok()); @@ -30,4 +31,3 @@ pub async fn run_tests(db_path: &str) { run_test(tests::it_can_add_a_torrent_category, &db).await; run_test(tests::it_can_add_a_torrent_and_tracker_stats_to_that_torrent, &db).await; } - diff --git a/tests/databases/mysql.rs b/tests/databases/mysql.rs index c0f78429..28aa92a3 100644 --- a/tests/databases/mysql.rs +++ b/tests/databases/mysql.rs @@ -1,4 +1,4 @@ -use crate::databases::{run_tests}; +use crate::databases::run_tests; const DATABASE_URL: &str = "mysql://root:password@localhost:3306/torrust-index_test"; @@ -6,5 +6,3 @@ const DATABASE_URL: &str = "mysql://root:password@localhost:3306/torrust-index_t async fn run_mysql_tests() { run_tests(DATABASE_URL).await; } - - diff --git a/tests/databases/sqlite.rs b/tests/databases/sqlite.rs index 940d7e6b..37d89a97 100644 --- a/tests/databases/sqlite.rs +++ b/tests/databases/sqlite.rs @@ -1,4 +1,4 @@ -use crate::databases::{run_tests}; +use crate::databases::run_tests; const DATABASE_URL: &str = "sqlite::memory:"; @@ -6,5 +6,3 @@ const DATABASE_URL: &str = "sqlite::memory:"; async fn run_sqlite_tests() { run_tests(DATABASE_URL).await; } - - diff --git a/tests/databases/tests.rs b/tests/databases/tests.rs index 0c33cba1..a38219e1 100644 --- a/tests/databases/tests.rs +++ b/tests/databases/tests.rs @@ -1,7 +1,7 @@ use serde_bytes::ByteBuf; use torrust_index_backend::databases::database::{Database, DatabaseError}; use torrust_index_backend::models::torrent::TorrentListing; -use torrust_index_backend::models::torrent_file::{TorrentInfo, Torrent}; +use torrust_index_backend::models::torrent_file::{Torrent, TorrentInfo}; use torrust_index_backend::models::user::UserProfile; // test user options @@ -20,7 +20,8 @@ const TEST_TORRENT_SEEDERS: i64 = 437; const TEST_TORRENT_LEECHERS: i64 = 1289; async fn add_test_user(db: &Box) -> Result { - db.insert_user_and_get_id(TEST_USER_USERNAME, TEST_USER_EMAIL, TEST_USER_PASSWORD).await + db.insert_user_and_get_id(TEST_USER_USERNAME, TEST_USER_EMAIL, TEST_USER_PASSWORD) + .await } async fn add_test_torrent_category(db: &Box) -> Result { @@ -42,14 +43,17 @@ pub async fn it_can_add_a_user(db: &Box) { let returned_user_profile = get_user_profile_from_username_result.unwrap(); // verify that the profile data is as we expect it to be - assert_eq!(returned_user_profile, UserProfile { - user_id: inserted_user_id, - username: TEST_USER_USERNAME.to_string(), - email: TEST_USER_EMAIL.to_string(), - email_verified: returned_user_profile.email_verified.clone(), - bio: returned_user_profile.bio.clone(), - avatar: returned_user_profile.avatar.clone() - }); + assert_eq!( + returned_user_profile, + UserProfile { + user_id: inserted_user_id, + username: TEST_USER_USERNAME.to_string(), + email: TEST_USER_EMAIL.to_string(), + email_verified: returned_user_profile.email_verified.clone(), + bio: returned_user_profile.bio.clone(), + avatar: returned_user_profile.avatar.clone() + } + ); } pub async fn it_can_add_a_torrent_category(db: &Box) { @@ -69,7 +73,9 @@ pub async fn it_can_add_a_torrent_category(db: &Box) { pub async fn it_can_add_a_torrent_and_tracker_stats_to_that_torrent(db: &Box) { // set pre-conditions let user_id = add_test_user(&db).await.expect("add_test_user failed."); - let torrent_category_id = add_test_torrent_category(&db).await.expect("add_test_torrent_category failed."); + let torrent_category_id = add_test_torrent_category(&db) + .await + .expect("add_test_torrent_category failed."); let torrent = Torrent { info: TorrentInfo { @@ -81,7 +87,7 @@ pub async fn it_can_add_a_torrent_and_tracker_stats_to_that_torrent(db: &Box Date: Tue, 29 Nov 2022 14:36:33 +0100 Subject: [PATCH 005/357] fmt: add world format to git-blame-ignore file --- .git-blame-ignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .git-blame-ignore diff --git a/.git-blame-ignore b/.git-blame-ignore new file mode 100644 index 00000000..749a0f1e --- /dev/null +++ b/.git-blame-ignore @@ -0,0 +1,4 @@ +# https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt + +# Format the world! +9ddc079b00fc5d6ecd80199edc078d6793fb0a9c \ No newline at end of file From a39a0d9b6d5ae824a6867552d582de71f3cfbc2c Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 14:47:04 +0100 Subject: [PATCH 006/357] ci: verify formating for pull requests --- .github/workflows/develop.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/develop.yml diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 00000000..f9301a6d --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,20 @@ +name: Development Checks + +on: [pull_request] + +jobs: + format: + runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Verify Formatting + uses: ClementTsang/cargo-action@main + with: + command: fmt + args: --all --check \ No newline at end of file From 63f8b6f3f9484f90d6f457fa4fce76dd6d5afbeb Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 20:44:48 +0100 Subject: [PATCH 007/357] vscode: add auto-formating, and clippy for lint --- .vscode/settings.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..f1027e9b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[rust]": { + "editor.formatOnSave": true + }, + "rust-analyzer.checkOnSave.command": "clippy", +} \ No newline at end of file From f3cc56223268297002925a7ffdb51d9e58285eaf Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 20:46:01 +0100 Subject: [PATCH 008/357] clippy: auto fix --- src/auth.rs | 4 +--- src/config.rs | 6 +++--- src/databases/database.rs | 2 +- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/errors.rs | 3 +-- src/models/torrent.rs | 2 +- src/models/torrent_file.rs | 14 +++++++------- src/models/user.rs | 2 +- src/routes/category.rs | 2 +- src/routes/torrent.rs | 8 ++++---- src/routes/user.rs | 4 ++-- tests/databases/tests.rs | 10 +++++----- 13 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 2b38c14e..57ec50e5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -29,9 +29,7 @@ impl AuthorizationService { let claims = UserClaims { user, exp: exp_date }; - let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); - - token + encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap() } pub async fn verify_jwt(&self, token: &str) -> Result { diff --git a/src/config.rs b/src/config.rs index 00d390dc..110d7fb2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -135,9 +135,9 @@ impl Configuration { eprintln!("Creating config file.."); let config = Configuration::default(); let _ = config.save_to_file().await; - return Err(ConfigError::Message(format!( - "Please edit the config.TOML in the root folder and restart the tracker." - ))); + return Err(ConfigError::Message( + "Please edit the config.TOML in the root folder and restart the tracker.".to_string(), + )); } let torrust_config: TorrustConfig = match config.try_into() { diff --git a/src/databases/database.rs b/src/databases/database.rs index c4dad043..0f06f702 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -11,7 +11,7 @@ use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; /// Database drivers. -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub enum DatabaseDriver { Sqlite3, Mysql, diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 75afe1c8..8c411eec 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -323,7 +323,7 @@ impl Database for MysqlDatabase { i += 1; } } - if category_filters.len() > 0 { + if !category_filters.is_empty() { format!( "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", category_filters diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 44e297a2..354e7bdf 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -318,7 +318,7 @@ impl Database for SqliteDatabase { i += 1; } } - if category_filters.len() > 0 { + if !category_filters.is_empty() { format!( "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", category_filters diff --git a/src/errors.rs b/src/errors.rs index 6f5ee0a2..24a413e6 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -10,7 +10,7 @@ use crate::databases::database::DatabaseError; pub type ServiceResult = Result; -#[derive(Debug, Display, PartialEq, Error)] +#[derive(Debug, Display, PartialEq, Eq, Error)] #[allow(dead_code)] pub enum ServiceError { #[display(fmt = "internal server error")] @@ -182,7 +182,6 @@ impl ResponseError for ServiceError { HttpResponseBuilder::new(self.status_code()) .append_header((header::CONTENT_TYPE, "application/json; charset=UTF-8")) .body(serde_json::to_string(&ErrorToResponse { error: self.to_string() }).unwrap()) - .into() } } diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 4adab162..9063c7f3 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -4,7 +4,7 @@ use crate::models::torrent_file::Torrent; use crate::routes::torrent::CreateTorrent; #[allow(dead_code)] -#[derive(Debug, PartialEq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] pub struct TorrentListing { pub torrent_id: i64, pub uploader: String, diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 98cd4c00..581d73a8 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -6,10 +6,10 @@ use sha1::{Digest, Sha1}; use crate::config::Configuration; use crate::utils::hex::{bytes_to_hex, hex_to_bytes}; -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TorrentNode(String, i64); -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TorrentFile { pub path: Vec, pub length: i64, @@ -17,7 +17,7 @@ pub struct TorrentFile { pub md5sum: Option, } -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TorrentInfo { pub name: String, #[serde(default)] @@ -160,7 +160,7 @@ impl Torrent { pub fn file_size(&self) -> i64 { if self.info.length.is_some() { - return self.info.length.unwrap(); + self.info.length.unwrap() } else { match &self.info.files { None => 0, @@ -176,7 +176,7 @@ impl Torrent { } } -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct DbTorrentFile { pub path: Option, pub length: i64, @@ -184,7 +184,7 @@ pub struct DbTorrentFile { pub md5sum: Option, } -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct DbTorrentInfo { pub name: String, pub pieces: String, @@ -194,7 +194,7 @@ pub struct DbTorrentInfo { pub root_hash: i64, } -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct DbTorrentAnnounceUrl { pub tracker_url: String, } diff --git a/src/models/user.rs b/src/models/user.rs index 53d0a4e0..f64b88b4 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -13,7 +13,7 @@ pub struct UserAuthentication { pub password_hash: String, } -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, sqlx::FromRow)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserProfile { pub user_id: i64, pub username: String, diff --git a/src/routes/category.rs b/src/routes/category.rs index 8bcd0348..defca8a8 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -57,7 +57,7 @@ pub async fn delete_category( return Err(ServiceError::Unauthorized); } - let _ = app_data.database.delete_category(&payload.name).await?; + app_data.database.delete_category(&payload.name).await?; Ok(HttpResponse::Ok().json(OkResponse { data: payload.name.clone(), diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 5d87e8b2..41074b4b 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -250,12 +250,12 @@ pub async fn update_torrent( // update torrent title if let Some(title) = &payload.title { - let _res = app_data.database.update_torrent_title(torrent_id, title).await?; + app_data.database.update_torrent_title(torrent_id, title).await?; } // update torrent description if let Some(description) = &payload.description { - let _res = app_data.database.update_torrent_description(torrent_id, description).await?; + app_data.database.update_torrent_description(torrent_id, description).await?; } let torrent_listing = app_data.database.get_torrent_listing_from_id(torrent_id).await?; @@ -278,7 +278,7 @@ pub async fn delete_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceRe // needed later for removing torrent from tracker whitelist let torrent_listing = app_data.database.get_torrent_listing_from_id(torrent_id).await?; - let _res = app_data.database.delete_torrent(torrent_id).await?; + app_data.database.delete_torrent(torrent_id).await?; // remove info_hash from tracker whitelist let _ = app_data @@ -344,7 +344,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result title = parsed_data.to_string(), diff --git a/src/routes/user.rs b/src/routes/user.rs index ed6b1564..6b535bc6 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -179,7 +179,7 @@ pub async fn verify_token(payload: web::Json, app_data: WebAppData) -> Se let _claims = app_data.auth.verify_jwt(&payload.token).await?; Ok(HttpResponse::Ok().json(OkResponse { - data: format!("Token is valid."), + data: "Token is valid.".to_string(), })) } @@ -256,7 +256,7 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult) -> Result) { - let add_test_user_result = add_test_user(&db).await; + let add_test_user_result = add_test_user(db).await; assert!(add_test_user_result.is_ok()); @@ -49,7 +49,7 @@ pub async fn it_can_add_a_user(db: &Box) { user_id: inserted_user_id, username: TEST_USER_USERNAME.to_string(), email: TEST_USER_EMAIL.to_string(), - email_verified: returned_user_profile.email_verified.clone(), + email_verified: returned_user_profile.email_verified, bio: returned_user_profile.bio.clone(), avatar: returned_user_profile.avatar.clone() } @@ -57,7 +57,7 @@ pub async fn it_can_add_a_user(db: &Box) { } pub async fn it_can_add_a_torrent_category(db: &Box) { - let add_test_torrent_category_result = add_test_torrent_category(&db).await; + let add_test_torrent_category_result = add_test_torrent_category(db).await; assert!(add_test_torrent_category_result.is_ok()); @@ -72,8 +72,8 @@ pub async fn it_can_add_a_torrent_category(db: &Box) { pub async fn it_can_add_a_torrent_and_tracker_stats_to_that_torrent(db: &Box) { // set pre-conditions - let user_id = add_test_user(&db).await.expect("add_test_user failed."); - let torrent_category_id = add_test_torrent_category(&db) + let user_id = add_test_user(db).await.expect("add_test_user failed."); + let torrent_category_id = add_test_torrent_category(db) .await .expect("add_test_torrent_category failed."); From 52d23ee95b4115018563cb1d43f51545683f690c Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 20:55:56 +0100 Subject: [PATCH 009/357] clippy: fix clippy errors, and most warnings --- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/mailer.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 8c411eec..ea228e3c 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -563,7 +563,7 @@ impl Database for MysqlDatabase { let torrent_files: Vec = db_torrent_files .into_iter() .map(|tf| TorrentFile { - path: tf.path.unwrap_or("".to_string()).split('/').map(|v| v.to_string()).collect(), + path: tf.path.unwrap_or_default().split('/').map(|v| v.to_string()).collect(), length: tf.length, md5sum: tf.md5sum, }) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 354e7bdf..7b39a52f 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -558,7 +558,7 @@ impl Database for SqliteDatabase { let torrent_files: Vec = db_torrent_files .into_iter() .map(|tf| TorrentFile { - path: tf.path.unwrap_or("".to_string()).split('/').map(|v| v.to_string()).collect(), + path: tf.path.unwrap_or_default().split('/').map(|v| v.to_string()).collect(), length: tf.length, md5sum: tf.md5sum, }) diff --git a/src/mailer.rs b/src/mailer.rs index 94dd8325..a8fd4de8 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -126,7 +126,7 @@ impl MailerService { let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); - let mut base_url = base_url.clone(); + let mut base_url = &base_url.to_string(); if let Some(cfg_base_url) = &settings.net.base_url { base_url = cfg_base_url; } From 84324a8bf267b9351b06b9a93518effc8df4e3a5 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 20:40:31 +0100 Subject: [PATCH 010/357] test: skip for mysql test, as db isn't setup and it hangs. --- tests/databases/mysql.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/databases/mysql.rs b/tests/databases/mysql.rs index 28aa92a3..e2b58102 100644 --- a/tests/databases/mysql.rs +++ b/tests/databases/mysql.rs @@ -1,8 +1,15 @@ +#[allow(unused_imports)] use crate::databases::run_tests; +#[allow(dead_code)] const DATABASE_URL: &str = "mysql://root:password@localhost:3306/torrust-index_test"; #[tokio::test] +#[should_panic] async fn run_mysql_tests() { - run_tests(DATABASE_URL).await; + panic!("Todo Test Times Out!"); + #[allow(unreachable_code)] + { + run_tests(DATABASE_URL).await; + } } From 50dc9d3855169de428cc418871ab588caa6fc564 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 29 Nov 2022 20:32:26 +0100 Subject: [PATCH 011/357] ci: make full test --- .github/workflows/develop.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index f9301a6d..1b27a4e7 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -1,9 +1,9 @@ name: Development Checks -on: [pull_request] +on: [push,pull_request] jobs: - format: + run: runs-on: ubuntu-latest env: CARGO_TERM_COLOR: always @@ -11,10 +11,30 @@ jobs: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@nightly with: - components: rustfmt + components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - - name: Verify Formatting + - name: Format uses: ClementTsang/cargo-action@main with: command: fmt - args: --all --check \ No newline at end of file + args: --all --check + - name: Check + uses: ClementTsang/cargo-action@main + with: + command: check + args: --all-targets + - name: Clippy + uses: ClementTsang/cargo-action@main + with: + command: clippy + args: --all-targets + - name: Build + uses: ClementTsang/cargo-action@main + with: + command: build + args: --all-targets + - name: Test + uses: ClementTsang/cargo-action@main + with: + command: test + args: --all-targets \ No newline at end of file From c29155752601993c678c1c4ee41b2aadb8db762a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 29 Nov 2022 18:48:18 +0000 Subject: [PATCH 012/357] fix: [#84] restore behavior. Update torrent stats after upload When a new torrent is uploaded we have to update the tracker torrent stats in the `torrust_torrent_tracker_stats` table. It was from the UI but not for the DB migration script becuase the frontend makes a request to get the torrent info after the upload and that endpint updates the torrent tracket stats. It must update the stats even if that endpoint is not called after uploading a new torrent. --- src/routes/torrent.rs | 6 ++++++ src/tracker.rs | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 41074b4b..c58c5599 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -96,6 +96,12 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA ) .await?; + // update torrent tracker stats + let _ = app_data + .tracker + .update_torrent_tracker_stats(torrent_id, &torrent_request.torrent.info_hash()) + .await; + // whitelist info hash on tracker if let Err(e) = app_data .tracker diff --git a/src/tracker.rs b/src/tracker.rs index 6b42dc7c..4ceae007 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -179,13 +179,19 @@ impl TrackerService { } pub async fn update_torrents(&self) -> Result<(), ServiceError> { - println!("Updating torrents.."); + println!("Updating torrents ..."); let torrents = self.database.get_all_torrents_compact().await?; for torrent in torrents { - let _ = self.get_torrent_info(torrent.torrent_id, &torrent.info_hash).await; + let _ = self + .update_torrent_tracker_stats(torrent.torrent_id, &torrent.info_hash) + .await; } Ok(()) } + + pub async fn update_torrent_tracker_stats(&self, torrent_id: i64, info_hash: &str) -> Result { + self.get_torrent_info(torrent_id, info_hash).await + } } From 3c47ffc6cbe7aa42ecc98b0a1c76f0debf485205 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 30 Nov 2022 11:59:09 +0000 Subject: [PATCH 013/357] fix: [#84] SQL query to get list of torrents without stats The query was not returning all the torrents regardless whether they have traacker stats or not. --- 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 ea228e3c..e87a5401 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -344,7 +344,7 @@ impl Database for MysqlDatabase { INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE title LIKE ? - GROUP BY torrent_id", + GROUP BY tt.torrent_id", category_filter_query ); diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 7b39a52f..835979fe 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -339,7 +339,7 @@ impl Database for SqliteDatabase { INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE title LIKE ? - GROUP BY ts.torrent_id", + GROUP BY tt.torrent_id", category_filter_query ); From c3414da31c659590548cef11b118587f805232f7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Oct 2022 12:23:50 +0100 Subject: [PATCH 014/357] feat: add target dir to .gitignore --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1952496d..a1c33ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.env -/data.db* /config.toml -/uploads/ +/data.db* +/target +/uploads/ \ No newline at end of file From 5d6dec0fcba33960be9afc21873cea327d68dcaf Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Oct 2022 12:26:05 +0100 Subject: [PATCH 015/357] refactor: allow adding more binaries This change allow adding more binaries to the crate. We want to add a new binary to execute DB upgrades that have to be executed manually. --- Cargo.toml | 1 + src/{ => bin}/main.rs | 0 2 files changed, 1 insertion(+) rename src/{ => bin}/main.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 4d43f3e7..d89251ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "torrust-index-backend" version = "2.0.0-dev.1" authors = ["Mick van Dijke ", "Wesley Bijleveld "] edition = "2021" +default-run = "main" [profile.dev.package.sqlx-macros] opt-level = 3 diff --git a/src/main.rs b/src/bin/main.rs similarity index 100% rename from src/main.rs rename to src/bin/main.rs From 7513df07d01b8aee6fb159440c059d6ec942fee3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Oct 2022 17:20:20 +0100 Subject: [PATCH 016/357] refactor: add scaffolding for database migration command --- .gitignore | 1 + db_migrate/README.md | 133 +++++++++++++++ .../db_schemas/mysql/db_migrations_v2.sql | 152 ++++++++++++++++++ .../db_schemas/sqlite3/db_migrations_v1.sql | 68 ++++++++ .../db_schemas/sqlite3/db_migrations_v2.sql | 152 ++++++++++++++++++ db_migrate/docker/start_mysql.sh | 10 ++ db_migrate/docker/start_mysql_client.sh | 3 + db_migrate/docker/stop_mysql.sh | 3 + src/bin/db_migrate.rs | 55 +++++++ src/databases/database.rs | 16 ++ src/databases/sqlite.rs | 89 +++++++--- 11 files changed, 661 insertions(+), 21 deletions(-) create mode 100644 db_migrate/README.md create mode 100644 db_migrate/db_schemas/mysql/db_migrations_v2.sql create mode 100644 db_migrate/db_schemas/sqlite3/db_migrations_v1.sql create mode 100644 db_migrate/db_schemas/sqlite3/db_migrations_v2.sql create mode 100755 db_migrate/docker/start_mysql.sh create mode 100755 db_migrate/docker/start_mysql_client.sh create mode 100755 db_migrate/docker/stop_mysql.sh create mode 100644 src/bin/db_migrate.rs diff --git a/.gitignore b/.gitignore index a1c33ca6..42a0fc28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.env /config.toml /data.db* +/data_v2.db* /target /uploads/ \ No newline at end of file diff --git a/db_migrate/README.md b/db_migrate/README.md new file mode 100644 index 00000000..617a6009 --- /dev/null +++ b/db_migrate/README.md @@ -0,0 +1,133 @@ +# DB migration + +With the console command `cargo run --bin db_migrate` you can migrate data from `v1.0.0` to `v2.0.0`. This migration includes: + +- Changing the DB schema. +- Transferring the torrent files in the dir `uploads` to the database. + +## SQLite3 + +TODO + +## MySQL8 + +Please, + +> WARNING: MySQL migration is not implemented yet. We also provide docker infrastructure to run mysql during implementation of a migration tool. + +and also: + +> WARNING: We are not using a persisted volume. If you remove the volume used by the container you lose the database data. + +Run the docker container and connect using the console client: + +```s +./db_migrate/docker/start_mysql.sh +./db_migrate/docker/mysql_client.sh +``` + +Once you are connected to the client you can create databases with: + +```s +create database torrust_v1; +create database torrust_v2; +``` + +After creating databases you should see something like this: + +```s +mysql> show databases; ++--------------------+ +| Database | ++--------------------+ +| information_schema | +| mysql | +| performance_schema | +| sys | +| torrust_v1 | +| torrust_v2 | ++--------------------+ +6 rows in set (0.001 sec) +``` + +How to connect from outside the container: + +```s +mysql -h127.0.0.1 -uroot -pdb-root-password +``` + +## Create DB for backend `v2.0.0` + +You need to create an empty new database for v2.0.0. + +You need to change the configuration in `config.toml` file to use MySQL: + +```yml +[database] +connect_url = "mysql://root:db-root-password@127.0.0.1/torrust_v2" +``` + +After running the backend with `cargo run` you should see the tables created by migrations: + +```s +mysql> show tables; ++-------------------------------+ +| Tables_in_torrust_v2 | ++-------------------------------+ +| _sqlx_migrations | +| torrust_categories | +| torrust_torrent_announce_urls | +| torrust_torrent_files | +| torrust_torrent_info | +| torrust_torrent_tracker_stats | +| torrust_torrents | +| torrust_tracker_keys | +| torrust_user_authentication | +| torrust_user_bans | +| torrust_user_invitation_uses | +| torrust_user_invitations | +| torrust_user_profiles | +| torrust_user_public_keys | +| torrust_users | ++-------------------------------+ +15 rows in set (0.001 sec) +``` + +### Create DB for backend `v1.0.0` + +The `db_migrate` command is going to import data from version `v1.0.0` (database and `uploads` folder) into the new empty database for `v2.0.0`. + +You can import data into the source database for testing with the `mysql` DB client or docker. + +Using `mysql` client: + +```s +mysql -h127.0.0.1 -uroot -pdb-root-password torrust_v1 < ./db_migrate/db_schemas/db_migrations_v1_for_mysql_8.sql +``` + +Using dockerized `mysql` client: + +```s +docker exec -i torrust-index-backend-mysql mysql torrust_v1 -uroot -pdb-root-password < ./db_migrate/db_schemas/db_migrations_v1_for_mysql_8.sql +``` + +### Commands + +Connect to `mysql` client: + +```s +mysql -h127.0.0.1 -uroot -pdb-root-password torrust_v1 +``` + +Connect to dockerized `mysql` client: + +```s +docker exec -it torrust-index-backend-mysql mysql torrust_v1 -uroot -pdb-root-password +``` + +Backup DB: + +```s +mysqldump -h127.0.0.1 torrust_v1 -uroot -pdb-root-password > ./db_migrate/db_schemas/v1_schema_dump.sql +mysqldump -h127.0.0.1 torrust_v2 -uroot -pdb-root-password > ./db_migrate/db_schemas/v2_schema_dump.sql +``` diff --git a/db_migrate/db_schemas/mysql/db_migrations_v2.sql b/db_migrate/db_schemas/mysql/db_migrations_v2.sql new file mode 100644 index 00000000..08349bb5 --- /dev/null +++ b/db_migrate/db_schemas/mysql/db_migrations_v2.sql @@ -0,0 +1,152 @@ +# 20220721205537_torrust_users.sql + +CREATE TABLE IF NOT EXISTS torrust_users ( + user_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + date_registered DATETIME NOT NULL, + administrator BOOLEAN NOT NULL DEFAULT FALSE +) + +# 20220721210530_torrust_user_authentication.sql + +CREATE TABLE IF NOT EXISTS torrust_user_authentication ( + user_id INTEGER NOT NULL PRIMARY KEY, + password_hash TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +) + +# 20220727213942_torrust_user_profiles.sql + +CREATE TABLE IF NOT EXISTS torrust_user_profiles ( + user_id INTEGER NOT NULL PRIMARY KEY, + username VARCHAR(24) NOT NULL UNIQUE, + email VARCHAR(320) UNIQUE, + email_verified BOOL NOT NULL DEFAULT FALSE, + bio TEXT, + avatar TEXT, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +) + +# 20220727222313_torrust_tracker_keys.sql + +CREATE TABLE IF NOT EXISTS torrust_tracker_keys ( + tracker_key_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + tracker_key CHAR(32) NOT NULL, + date_expiry BIGINT NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +) + +# 20220730102607_torrust_user_public_keys.sql + +CREATE TABLE IF NOT EXISTS torrust_user_public_keys ( + public_key_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + public_key CHAR(32) UNIQUE NOT NULL, + date_registered DATETIME NOT NULL, + date_expiry DATETIME NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +) + +# 20220730104552_torrust_user_invitations.sql + +CREATE TABLE IF NOT EXISTS torrust_user_invitations ( + invitation_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + public_key CHAR(32) NOT NULL, + signed_digest CHAR(32) NOT NULL, + date_begin DATETIME NOT NULL, + date_expiry DATETIME NOT NULL, + max_uses INTEGER NOT NULL, + personal_message VARCHAR(512), + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE, + FOREIGN KEY(public_key) REFERENCES torrust_user_public_keys(public_key) ON DELETE CASCADE +) + +# 20220730105501_torrust_user_invitation_uses.sql + +CREATE TABLE IF NOT EXISTS torrust_user_invitation_uses ( + invitation_use_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + invitation_id INTEGER NOT NULL, + registered_user_id INTEGER NOT NULL, + date_used DATETIME NOT NULL, + FOREIGN KEY(invitation_id) REFERENCES torrust_user_invitations(invitation_id) ON DELETE CASCADE, + FOREIGN KEY(registered_user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +) + +# 20220801201435_torrust_user_bans.sql + +CREATE TABLE IF NOT EXISTS torrust_user_bans ( + ban_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + reason TEXT NOT NULL, + date_expiry DATETIME NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +) + +# 20220802161524_torrust_categories.sql + +CREATE TABLE torrust_categories ( + category_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(64) NOT NULL UNIQUE +); + +INSERT INTO torrust_categories (name) VALUES ('movies'), ('tv shows'), ('games'), ('music'), ('software'); + +# 20220810192613_torrust_torrents.sql + +CREATE TABLE IF NOT EXISTS torrust_torrents ( + torrent_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + uploader_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + info_hash CHAR(40) UNIQUE NOT NULL, + size BIGINT NOT NULL, + name TEXT NOT NULL, + pieces LONGTEXT NOT NULL, + piece_length BIGINT NOT NULL, + private BOOLEAN NULL DEFAULT NULL, + root_hash BOOLEAN NOT NULL DEFAULT FALSE, + date_uploaded DATETIME NOT NULL, + FOREIGN KEY(uploader_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE, + FOREIGN KEY(category_id) REFERENCES torrust_categories(category_id) ON DELETE CASCADE +) + +# 20220810201538_torrust_torrent_files.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_files ( + file_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + torrent_id INTEGER NOT NULL, + md5sum TEXT NULL DEFAULT NULL, + length BIGINT NOT NULL, + path TEXT DEFAULT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +) + +# 20220810201609_torrust_torrent_announce_urls.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_announce_urls ( + announce_url_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + torrent_id INTEGER NOT NULL, + tracker_url VARCHAR(256) NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +) + +# 20220812181520_torrust_torrent_info.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_info ( + torrent_id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(256) UNIQUE NOT NULL, + description TEXT DEFAULT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +) + +# 20220812184806_torrust_torrent_tracker_stats.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_tracker_stats ( + torrent_id INTEGER NOT NULL PRIMARY KEY, + tracker_url VARCHAR(256) NOT NULL, + seeders INTEGER NOT NULL DEFAULT 0, + leechers INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE, + UNIQUE(torrent_id, tracker_url) +) diff --git a/db_migrate/db_schemas/sqlite3/db_migrations_v1.sql b/db_migrate/db_schemas/sqlite3/db_migrations_v1.sql new file mode 100644 index 00000000..214c4921 --- /dev/null +++ b/db_migrate/db_schemas/sqlite3/db_migrations_v1.sql @@ -0,0 +1,68 @@ +# 20210831113004_torrust_users.sql + +CREATE TABLE IF NOT EXISTS torrust_users ( + user_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + username VARCHAR(32) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + password TEXT NOT NULL +); + +# 20210904135524_torrust_tracker_keys.sql + +CREATE TABLE IF NOT EXISTS torrust_tracker_keys ( + key_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + key VARCHAR(32) NOT NULL, + valid_until INT(10) NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) +); + +# 20210905160623_torrust_categories.sql + +CREATE TABLE torrust_categories ( + category_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name VARCHAR(64) NOT NULL UNIQUE +); + +INSERT INTO torrust_categories (name) VALUES +('movies'), ('tv shows'), ('games'), ('music'), ('software'); + +# 20210907083424_torrust_torrent_files.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_files ( + file_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + torrent_id INTEGER NOT NULL, + number INTEGER NOT NULL, + path VARCHAR(255) NOT NULL, + length INTEGER NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) +); + +# 20211208143338_torrust_users.sql + +ALTER TABLE torrust_users; +ADD COLUMN administrator BOOLEAN NOT NULL DEFAULT FALSE; + +# 20220308083424_torrust_torrents.sql + +CREATE TABLE IF NOT EXISTS torrust_torrents ( + torrent_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + uploader VARCHAR(32) NOT NULL, + info_hash VARCHAR(20) UNIQUE NOT NULL, + title VARCHAR(256) UNIQUE NOT NULL, + category_id INTEGER NOT NULL, + description TEXT, + upload_date INT(10) NOT NULL, + file_size BIGINT NOT NULL, + seeders INTEGER NOT NULL, + leechers INTEGER NOT NULL, + FOREIGN KEY(uploader) REFERENCES torrust_users(username) ON DELETE CASCADE, + FOREIGN KEY(category_id) REFERENCES torrust_categories(category_id) ON DELETE CASCADE +); + +# 20220308170028_torrust_categories.sql + +ALTER TABLE torrust_categories +ADD COLUMN icon VARCHAR(32); + diff --git a/db_migrate/db_schemas/sqlite3/db_migrations_v2.sql b/db_migrate/db_schemas/sqlite3/db_migrations_v2.sql new file mode 100644 index 00000000..b31aea68 --- /dev/null +++ b/db_migrate/db_schemas/sqlite3/db_migrations_v2.sql @@ -0,0 +1,152 @@ +#20220721205537_torrust_users.sql + +CREATE TABLE IF NOT EXISTS torrust_users ( + user_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + date_registered TEXT NOT NULL, + administrator BOOL NOT NULL DEFAULT FALSE +); + +#20220721210530_torrust_user_authentication.sql + +CREATE TABLE IF NOT EXISTS torrust_user_authentication ( + user_id INTEGER NOT NULL PRIMARY KEY, + password_hash TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +); + +#20220727213942_torrust_user_profiles.sql + +CREATE TABLE IF NOT EXISTS torrust_user_profiles ( + user_id INTEGER NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, + email_verified BOOL NOT NULL DEFAULT FALSE, + bio TEXT, + avatar TEXT, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +); + +#20220727222313_torrust_tracker_keys.sql + +CREATE TABLE IF NOT EXISTS torrust_tracker_keys ( + tracker_key_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + tracker_key TEXT NOT NULL, + date_expiry INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +); + +#20220730102607_torrust_user_public_keys.sql + +CREATE TABLE IF NOT EXISTS torrust_user_public_keys ( + public_key_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + public_key TEXT UNIQUE NOT NULL, + date_registered TEXT NOT NULL, + date_expiry TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +); + +#20220730104552_torrust_user_invitations.sql + +CREATE TABLE IF NOT EXISTS torrust_user_invitations ( + invitation_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + public_key TEXT NOT NULL, + signed_digest TEXT NOT NULL, + date_begin TEXT NOT NULL, + date_expiry TEXT NOT NULL, + max_uses INTEGER NOT NULL, + personal_message TEXT, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE, + FOREIGN KEY(public_key) REFERENCES torrust_user_public_keys(public_key) ON DELETE CASCADE +); + +#20220730105501_torrust_user_invitation_uses.sql + +CREATE TABLE IF NOT EXISTS torrust_user_invitation_uses ( + invitation_use_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + invitation_id INTEGER NOT NULL, + registered_user_id INTEGER NOT NULL, + date_used TEXT NOT NULL, + FOREIGN KEY(invitation_id) REFERENCES torrust_user_invitations(invitation_id) ON DELETE CASCADE, + FOREIGN KEY(registered_user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +); + +#20220801201435_torrust_user_bans.sql + +CREATE TABLE IF NOT EXISTS torrust_user_bans ( + ban_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + reason TEXT NOT NULL, + date_expiry TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +); + +#20220802161524_torrust_categories.sql + +CREATE TABLE torrust_categories ( + category_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +INSERT INTO torrust_categories (name) VALUES ('movies'), ('tv shows'), ('games'), ('music'), ('software'); + +#20220810192613_torrust_torrents.sql + +CREATE TABLE IF NOT EXISTS torrust_torrents ( + torrent_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + uploader_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + info_hash TEXT UNIQUE NOT NULL, + size INTEGER NOT NULL, + name TEXT NOT NULL, + pieces TEXT NOT NULL, + piece_length INTEGER NOT NULL, + private BOOLEAN NULL DEFAULT NULL, + root_hash INT NOT NULL DEFAULT 0, + date_uploaded TEXT NOT NULL, + FOREIGN KEY(uploader_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE, + FOREIGN KEY(category_id) REFERENCES torrust_categories(category_id) ON DELETE CASCADE +); + +#20220810201538_torrust_torrent_files.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_files ( + file_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + torrent_id INTEGER NOT NULL, + md5sum TEXT NULL DEFAULT NULL, + length BIGINT NOT NULL, + path TEXT DEFAULT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +); + +#20220810201609_torrust_torrent_announce_urls.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_announce_urls ( + announce_url_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + torrent_id INTEGER NOT NULL, + tracker_url TEXT NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +); + +#20220812181520_torrust_torrent_info.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_info ( + torrent_id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(256) UNIQUE NOT NULL, + description TEXT DEFAULT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE +); + +#20220812184806_torrust_torrent_tracker_stats.sql + +CREATE TABLE IF NOT EXISTS torrust_torrent_tracker_stats ( + torrent_id INTEGER NOT NULL PRIMARY KEY, + tracker_url VARCHAR(256) NOT NULL, + seeders INTEGER NOT NULL DEFAULT 0, + leechers INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE, + UNIQUE(torrent_id, tracker_url) +); diff --git a/db_migrate/docker/start_mysql.sh b/db_migrate/docker/start_mysql.sh new file mode 100755 index 00000000..5a245d32 --- /dev/null +++ b/db_migrate/docker/start_mysql.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +docker run \ + --detach \ + --name torrust-index-backend-mysql \ + --env MYSQL_USER=db-user \ + --env MYSQL_PASSWORD=db-passwrod \ + --env MYSQL_ROOT_PASSWORD=db-root-password \ + -p 3306:3306 \ + mysql:8.0.30 # This version is used in tests diff --git a/db_migrate/docker/start_mysql_client.sh b/db_migrate/docker/start_mysql_client.sh new file mode 100755 index 00000000..fed2a877 --- /dev/null +++ b/db_migrate/docker/start_mysql_client.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker exec -it torrust-index-backend-mysql mysql -uroot -pdb-root-password diff --git a/db_migrate/docker/stop_mysql.sh b/db_migrate/docker/stop_mysql.sh new file mode 100755 index 00000000..19d7a786 --- /dev/null +++ b/db_migrate/docker/stop_mysql.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker stop torrust-index-backend-mysql diff --git a/src/bin/db_migrate.rs b/src/bin/db_migrate.rs new file mode 100644 index 00000000..693ed5e8 --- /dev/null +++ b/src/bin/db_migrate.rs @@ -0,0 +1,55 @@ +//! Migration command to migrate data from v1.0.0 to v2.0.0 +//! Run it with `cargo run --bin db_migrate` + +use std::sync::Arc; +use torrust_index_backend::config::Configuration; +use torrust_index_backend::databases::database::{ + connect_database, connect_database_without_running_migrations, +}; + +#[actix_web::main] +async fn main() { + let dest_database_connect_url = "sqlite://data_v2.db?mode=rwc"; + + let cfg = match Configuration::load_from_file().await { + Ok(config) => Arc::new(config), + Err(error) => { + panic!("{}", error) + } + }; + + let settings = cfg.settings.read().await; + + // Connect to the current v1.0.0 DB + let source_database = Arc::new( + connect_database_without_running_migrations(&settings.database.connect_url) + .await + .expect("Can't connect to source DB."), + ); + + // Connect to the new v2.0.0 DB (running migrations) + let dest_database = Arc::new( + connect_database(&dest_database_connect_url) + .await + .expect("Can't connect to dest DB."), + ); + + println!("Upgrading database from v1.0.0 to v2.0.0 ..."); + + // It's just a test for the source connection. + // Print categories in current DB + let categories = source_database.get_categories().await; + println!("[v1] categories: {:?}", &categories); + + // It's just a test for the dest connection. + // Print categories in new DB + let categories = dest_database.get_categories().await; + println!("[v2] categories: {:?}", &categories); + + // Transfer categories + + /* TODO: + - Transfer categories: remove categories from seeding, reset sequence for IDs, copy categories in the right order to keep the same ids. + - ... + */ +} diff --git a/src/databases/database.rs b/src/databases/database.rs index 0f06f702..27adde76 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -77,6 +77,22 @@ pub async fn connect_database(db_path: &str) -> Result, Databa } } +/// Connect to a database without running migrations +pub async fn connect_database_without_running_migrations(db_path: &str) -> Result, DatabaseError> { + match &db_path.chars().collect::>() as &[char] { + ['s', 'q', 'l', 'i', 't', 'e', ..] => { + let db = SqliteDatabase::new_without_running_migrations(db_path).await; + Ok(Box::new(db)) + } + ['m', 'y', 's', 'q', 'l', ..] => { + todo!() + } + _ => { + Err(DatabaseError::UnrecognizedDatabaseDriver) + } + } +} + /// Trait for database implementations. #[async_trait] pub trait Database: Sync + Send { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 835979fe..88a904ab 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -30,6 +30,14 @@ impl SqliteDatabase { Self { pool: db } } + + pub async fn new_without_running_migrations(database_url: &str) -> Self { + let db = SqlitePoolOptions::new() + .connect(database_url) + .await + .expect("Unable to create database pool."); + Self { pool: db } + } } #[async_trait] @@ -54,12 +62,13 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error)?; // add password hash for account - let insert_user_auth_result = query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") - .bind(user_id) - .bind(password_hash) - .execute(&mut tx) - .await - .map_err(|_| DatabaseError::Error); + let insert_user_auth_result = + query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") + .bind(user_id) + .bind(password_hash) + .execute(&mut tx) + .await + .map_err(|_| DatabaseError::Error); // rollback transaction on error if let Err(e) = insert_user_auth_result { @@ -108,15 +117,23 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::UserNotFound) } - async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { - query_as::<_, UserAuthentication>("SELECT * FROM torrust_user_authentication WHERE user_id = ?") - .bind(user_id) - .fetch_one(&self.pool) - .await - .map_err(|_| DatabaseError::UserNotFound) + async fn get_user_authentication_from_id( + &self, + user_id: i64, + ) -> Result { + query_as::<_, UserAuthentication>( + "SELECT * FROM torrust_user_authentication WHERE user_id = ?", + ) + .bind(user_id) + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::UserNotFound) } - async fn get_user_profile_from_username(&self, username: &str) -> Result { + async fn get_user_profile_from_username( + &self, + username: &str, + ) -> Result { query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE username = ?") .bind(username) .fetch_one(&self.pool) @@ -155,7 +172,12 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error) } - async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), DatabaseError> { + async fn ban_user( + &self, + user_id: i64, + reason: &str, + date_expiry: NaiveDateTime, + ) -> Result<(), DatabaseError> { // date needs to be in ISO 8601 format let date_expiry_string = date_expiry.format("%Y-%m-%d %H:%M:%S").to_string(); @@ -193,7 +215,11 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error) } - async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), DatabaseError> { + async fn add_tracker_key( + &self, + user_id: i64, + tracker_key: &TrackerKey, + ) -> Result<(), DatabaseError> { let key = tracker_key.key.clone(); query("INSERT INTO torrust_tracker_keys (user_id, tracker_key, date_expiry) VALUES ($1, $2, $3)") @@ -343,7 +369,10 @@ impl Database for SqliteDatabase { category_filter_query ); - let count_query = format!("SELECT COUNT(*) as count FROM ({}) AS count_table", query_string); + let count_query = format!( + "SELECT COUNT(*) as count FROM ({}) AS count_table", + query_string + ); let count_result: Result = query_as(&count_query) .bind(title.clone()) @@ -390,7 +419,11 @@ impl Database for SqliteDatabase { let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { (bytes_to_hex(pieces.as_ref()), false) } else { - let root_hash = torrent.info.root_hash.as_ref().ok_or(DatabaseError::Error)?; + let root_hash = torrent + .info + .root_hash + .as_ref() + .ok_or(DatabaseError::Error)?; (root_hash.to_string(), true) }; @@ -537,7 +570,10 @@ impl Database for SqliteDatabase { )) } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { + async fn get_torrent_info_from_id( + &self, + torrent_id: i64, + ) -> Result { query_as::<_, DbTorrentInfo>( "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) @@ -576,7 +612,10 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } - async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { + async fn get_torrent_listing_from_id( + &self, + torrent_id: i64, + ) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -601,7 +640,11 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error) } - async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), DatabaseError> { + async fn update_torrent_title( + &self, + torrent_id: i64, + title: &str, + ) -> Result<(), DatabaseError> { query("UPDATE torrust_torrent_info SET title = $1 WHERE torrent_id = $2") .bind(title) .bind(torrent_id) @@ -626,7 +669,11 @@ impl Database for SqliteDatabase { }) } - async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), DatabaseError> { + async fn update_torrent_description( + &self, + torrent_id: i64, + description: &str, + ) -> Result<(), DatabaseError> { query("UPDATE torrust_torrent_info SET description = $1 WHERE torrent_id = $2") .bind(description) .bind(torrent_id) From b92fb0834cea34e783494081373c19167ceb5dd0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 27 Oct 2022 19:04:35 +0100 Subject: [PATCH 017/357] feat: [#56] transfer categories from db v1.0.0 to v2.0.0 First action for the command to upgrade data. It transfers the categories from the current DB schema (v1.0.0) to the new DB schema. --- src/bin/db_migrate.rs | 91 +++++++++++++++++---------- src/databases/database.rs | 16 ----- src/databases/mod.rs | 2 + src/databases/sqlite.rs | 8 --- src/databases/sqlite_v1_0_0.rs | 30 +++++++++ src/databases/sqlite_v2_0_0.rs | 109 +++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 56 deletions(-) create mode 100644 src/databases/sqlite_v1_0_0.rs create mode 100644 src/databases/sqlite_v2_0_0.rs diff --git a/src/bin/db_migrate.rs b/src/bin/db_migrate.rs index 693ed5e8..fcfb7eae 100644 --- a/src/bin/db_migrate.rs +++ b/src/bin/db_migrate.rs @@ -3,14 +3,11 @@ use std::sync::Arc; use torrust_index_backend::config::Configuration; -use torrust_index_backend::databases::database::{ - connect_database, connect_database_without_running_migrations, -}; - -#[actix_web::main] -async fn main() { - let dest_database_connect_url = "sqlite://data_v2.db?mode=rwc"; +use torrust_index_backend::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use torrust_index_backend::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +async fn current_db() -> Arc { + // Connect to the old v1.0.0 DB let cfg = match Configuration::load_from_file().await { Ok(config) => Arc::new(config), Err(error) => { @@ -20,36 +17,66 @@ async fn main() { let settings = cfg.settings.read().await; - // Connect to the current v1.0.0 DB - let source_database = Arc::new( - connect_database_without_running_migrations(&settings.database.connect_url) - .await - .expect("Can't connect to source DB."), - ); + Arc::new(SqliteDatabaseV1_0_0::new(&settings.database.connect_url).await) +} + +async fn new_db(db_filename: String) -> Arc { + let dest_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); + Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) +} + +async fn reset_destiny_database(dest_database: Arc) { + println!("Truncating all tables in destiny database ..."); + dest_database + .delete_all_database_rows() + .await + .expect("Can't reset destiny database."); +} - // Connect to the new v2.0.0 DB (running migrations) - let dest_database = Arc::new( - connect_database(&dest_database_connect_url) +async fn transfer_categories( + source_database: Arc, + dest_database: Arc, +) { + let source_categories = source_database.get_categories_order_by_id().await.unwrap(); + println!("[v1] categories: {:?}", &source_categories); + + let result = dest_database.reset_categories_sequence().await.unwrap(); + println!("result {:?}", result); + + for cat in &source_categories { + println!( + "[v2] adding category: {:?} {:?} ...", + &cat.category_id, &cat.name + ); + let id = dest_database + .insert_category_and_get_id(&cat.name) .await - .expect("Can't connect to dest DB."), - ); + .unwrap(); + + if id != cat.category_id { + panic!( + "Error copying category {:?} from source DB to destiny DB", + &cat.category_id + ); + } - println!("Upgrading database from v1.0.0 to v2.0.0 ..."); + println!("[v2] category: {:?} {:?} added.", id, &cat.name); + } - // It's just a test for the source connection. - // Print categories in current DB - let categories = source_database.get_categories().await; - println!("[v1] categories: {:?}", &categories); + let dest_categories = dest_database.get_categories().await.unwrap(); + println!("[v2] categories: {:?}", &dest_categories); +} + +#[actix_web::main] +async fn main() { + // Get connections to source adn destiny databases + let source_database = current_db().await; + let dest_database = new_db("data_v2.db".to_string()).await; - // It's just a test for the dest connection. - // Print categories in new DB - let categories = dest_database.get_categories().await; - println!("[v2] categories: {:?}", &categories); + println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); - // Transfer categories + reset_destiny_database(dest_database.clone()).await; + transfer_categories(source_database.clone(), dest_database.clone()).await; - /* TODO: - - Transfer categories: remove categories from seeding, reset sequence for IDs, copy categories in the right order to keep the same ids. - - ... - */ + // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. } diff --git a/src/databases/database.rs b/src/databases/database.rs index 27adde76..0f06f702 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -77,22 +77,6 @@ pub async fn connect_database(db_path: &str) -> Result, Databa } } -/// Connect to a database without running migrations -pub async fn connect_database_without_running_migrations(db_path: &str) -> Result, DatabaseError> { - match &db_path.chars().collect::>() as &[char] { - ['s', 'q', 'l', 'i', 't', 'e', ..] => { - let db = SqliteDatabase::new_without_running_migrations(db_path).await; - Ok(Box::new(db)) - } - ['m', 'y', 's', 'q', 'l', ..] => { - todo!() - } - _ => { - Err(DatabaseError::UnrecognizedDatabaseDriver) - } - } -} - /// Trait for database implementations. #[async_trait] pub trait Database: Sync + Send { diff --git a/src/databases/mod.rs b/src/databases/mod.rs index 169d99f4..c15a2b72 100644 --- a/src/databases/mod.rs +++ b/src/databases/mod.rs @@ -1,3 +1,5 @@ pub mod database; pub mod mysql; pub mod sqlite; +pub mod sqlite_v1_0_0; +pub mod sqlite_v2_0_0; diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 88a904ab..62b197d1 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -30,14 +30,6 @@ impl SqliteDatabase { Self { pool: db } } - - pub async fn new_without_running_migrations(database_url: &str) -> Self { - let db = SqlitePoolOptions::new() - .connect(database_url) - .await - .expect("Unable to create database pool."); - Self { pool: db } - } } #[async_trait] diff --git a/src/databases/sqlite_v1_0_0.rs b/src/databases/sqlite_v1_0_0.rs new file mode 100644 index 00000000..10420128 --- /dev/null +++ b/src/databases/sqlite_v1_0_0.rs @@ -0,0 +1,30 @@ +use super::database::DatabaseError; +use serde::{Deserialize, Serialize}; +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::{query_as, SqlitePool}; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct Category { + pub category_id: i64, + pub name: String, +} +pub struct SqliteDatabaseV1_0_0 { + pub pool: SqlitePool, +} + +impl SqliteDatabaseV1_0_0 { + pub async fn new(database_url: &str) -> Self { + let db = SqlitePoolOptions::new() + .connect(database_url) + .await + .expect("Unable to create database pool."); + Self { pool: db } + } + + pub async fn get_categories_order_by_id(&self) -> Result, DatabaseError> { + query_as::<_, Category>("SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC") + .fetch_all(&self.pool) + .await + .map_err(|_| DatabaseError::Error) + } +} diff --git a/src/databases/sqlite_v2_0_0.rs b/src/databases/sqlite_v2_0_0.rs new file mode 100644 index 00000000..0a1efe33 --- /dev/null +++ b/src/databases/sqlite_v2_0_0.rs @@ -0,0 +1,109 @@ +use super::database::DatabaseError; +use serde::{Deserialize, Serialize}; +use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; +use sqlx::{query, query_as, SqlitePool}; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct Category { + pub category_id: i64, + pub name: String, +} +pub struct SqliteDatabaseV2_0_0 { + pub pool: SqlitePool, +} + +impl SqliteDatabaseV2_0_0 { + pub async fn new(database_url: &str) -> Self { + let db = SqlitePoolOptions::new() + .connect(database_url) + .await + .expect("Unable to create database pool."); + Self { pool: db } + } + + pub async fn reset_categories_sequence(&self) -> Result { + query("DELETE FROM `sqlite_sequence` WHERE `name` = 'torrust_categories'") + .execute(&self.pool) + .await + .map_err(|_| DatabaseError::Error) + } + + pub async fn get_categories(&self) -> Result, DatabaseError> { + query_as::<_, Category>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") + .fetch_all(&self.pool) + .await + .map_err(|_| DatabaseError::Error) + } + + pub async fn insert_category_and_get_id(&self, category_name: &str) -> Result { + query("INSERT INTO torrust_categories (name) VALUES (?)") + .bind(category_name) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + .map_err(|e| match e { + sqlx::Error::Database(err) => { + if err.message().contains("UNIQUE") { + DatabaseError::CategoryAlreadyExists + } else { + DatabaseError::Error + } + } + _ => DatabaseError::Error, + }) + } + + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { + query("DELETE FROM torrust_categories;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_torrents;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_tracker_keys;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_users;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_user_authentication;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_user_bans;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_user_invitations;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_user_profiles;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_torrents;") + .execute(&self.pool) + .await + .unwrap(); + + query("DELETE FROM torrust_user_public_keys;") + .execute(&self.pool) + .await + .unwrap(); + + Ok(()) + } +} From 996c7d107558352cb377f2111b2ff5caa96cf6f1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Oct 2022 13:38:40 +0000 Subject: [PATCH 018/357] refactor: [#56] rename command al dirs Make name more generic to allow addding other upgrade command in the future. --- src/bin/{db_migrate.rs => upgrade.rs} | 2 +- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 0 .../from_v1_0_0_to_v2_0_0}/README.md | 16 ++++++++-------- .../db_schemas/mysql/db_migrations_v2.sql | 0 .../db_schemas/sqlite3/db_migrations_v1.sql | 0 .../db_schemas/sqlite3/db_migrations_v2.sql | 0 .../from_v1_0_0_to_v2_0_0}/docker/start_mysql.sh | 0 .../docker/start_mysql_client.sh | 0 .../from_v1_0_0_to_v2_0_0}/docker/stop_mysql.sh | 0 9 files changed, 9 insertions(+), 9 deletions(-) rename src/bin/{db_migrate.rs => upgrade.rs} (98%) create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs rename {db_migrate => upgrades/from_v1_0_0_to_v2_0_0}/README.md (75%) rename {db_migrate => upgrades/from_v1_0_0_to_v2_0_0}/db_schemas/mysql/db_migrations_v2.sql (100%) rename {db_migrate => upgrades/from_v1_0_0_to_v2_0_0}/db_schemas/sqlite3/db_migrations_v1.sql (100%) rename {db_migrate => upgrades/from_v1_0_0_to_v2_0_0}/db_schemas/sqlite3/db_migrations_v2.sql (100%) rename {db_migrate => upgrades/from_v1_0_0_to_v2_0_0}/docker/start_mysql.sh (100%) rename {db_migrate => upgrades/from_v1_0_0_to_v2_0_0}/docker/start_mysql_client.sh (100%) rename {db_migrate => upgrades/from_v1_0_0_to_v2_0_0}/docker/stop_mysql.sh (100%) diff --git a/src/bin/db_migrate.rs b/src/bin/upgrade.rs similarity index 98% rename from src/bin/db_migrate.rs rename to src/bin/upgrade.rs index fcfb7eae..563bebdb 100644 --- a/src/bin/db_migrate.rs +++ b/src/bin/upgrade.rs @@ -1,5 +1,5 @@ //! Migration command to migrate data from v1.0.0 to v2.0.0 -//! Run it with `cargo run --bin db_migrate` +//! Run it with `cargo run --bin upgrade` use std::sync::Arc; use torrust_index_backend::config::Configuration; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs new file mode 100644 index 00000000..e69de29b diff --git a/db_migrate/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md similarity index 75% rename from db_migrate/README.md rename to upgrades/from_v1_0_0_to_v2_0_0/README.md index 617a6009..af9a9b69 100644 --- a/db_migrate/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -1,6 +1,6 @@ # DB migration -With the console command `cargo run --bin db_migrate` you can migrate data from `v1.0.0` to `v2.0.0`. This migration includes: +With the console command `cargo run --bin upgrade` you can migrate data from `v1.0.0` to `v2.0.0`. This migration includes: - Changing the DB schema. - Transferring the torrent files in the dir `uploads` to the database. @@ -22,8 +22,8 @@ and also: Run the docker container and connect using the console client: ```s -./db_migrate/docker/start_mysql.sh -./db_migrate/docker/mysql_client.sh +./upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh +./upgrades/from_v1_0_0_to_v2_0_0/docker/mysql_client.sh ``` Once you are connected to the client you can create databases with: @@ -95,20 +95,20 @@ mysql> show tables; ### Create DB for backend `v1.0.0` -The `db_migrate` command is going to import data from version `v1.0.0` (database and `uploads` folder) into the new empty database for `v2.0.0`. +The `upgrade` command is going to import data from version `v1.0.0` (database and `uploads` folder) into the new empty database for `v2.0.0`. You can import data into the source database for testing with the `mysql` DB client or docker. Using `mysql` client: ```s -mysql -h127.0.0.1 -uroot -pdb-root-password torrust_v1 < ./db_migrate/db_schemas/db_migrations_v1_for_mysql_8.sql +mysql -h127.0.0.1 -uroot -pdb-root-password torrust_v1 < ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/db_migrations_v1_for_mysql_8.sql ``` Using dockerized `mysql` client: ```s -docker exec -i torrust-index-backend-mysql mysql torrust_v1 -uroot -pdb-root-password < ./db_migrate/db_schemas/db_migrations_v1_for_mysql_8.sql +docker exec -i torrust-index-backend-mysql mysql torrust_v1 -uroot -pdb-root-password < ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/db_migrations_v1_for_mysql_8.sql ``` ### Commands @@ -128,6 +128,6 @@ docker exec -it torrust-index-backend-mysql mysql torrust_v1 -uroot -pdb-root-pa Backup DB: ```s -mysqldump -h127.0.0.1 torrust_v1 -uroot -pdb-root-password > ./db_migrate/db_schemas/v1_schema_dump.sql -mysqldump -h127.0.0.1 torrust_v2 -uroot -pdb-root-password > ./db_migrate/db_schemas/v2_schema_dump.sql +mysqldump -h127.0.0.1 torrust_v1 -uroot -pdb-root-password > ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/v1_schema_dump.sql +mysqldump -h127.0.0.1 torrust_v2 -uroot -pdb-root-password > ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/v2_schema_dump.sql ``` diff --git a/db_migrate/db_schemas/mysql/db_migrations_v2.sql b/upgrades/from_v1_0_0_to_v2_0_0/db_schemas/mysql/db_migrations_v2.sql similarity index 100% rename from db_migrate/db_schemas/mysql/db_migrations_v2.sql rename to upgrades/from_v1_0_0_to_v2_0_0/db_schemas/mysql/db_migrations_v2.sql diff --git a/db_migrate/db_schemas/sqlite3/db_migrations_v1.sql b/upgrades/from_v1_0_0_to_v2_0_0/db_schemas/sqlite3/db_migrations_v1.sql similarity index 100% rename from db_migrate/db_schemas/sqlite3/db_migrations_v1.sql rename to upgrades/from_v1_0_0_to_v2_0_0/db_schemas/sqlite3/db_migrations_v1.sql diff --git a/db_migrate/db_schemas/sqlite3/db_migrations_v2.sql b/upgrades/from_v1_0_0_to_v2_0_0/db_schemas/sqlite3/db_migrations_v2.sql similarity index 100% rename from db_migrate/db_schemas/sqlite3/db_migrations_v2.sql rename to upgrades/from_v1_0_0_to_v2_0_0/db_schemas/sqlite3/db_migrations_v2.sql diff --git a/db_migrate/docker/start_mysql.sh b/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh similarity index 100% rename from db_migrate/docker/start_mysql.sh rename to upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh diff --git a/db_migrate/docker/start_mysql_client.sh b/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql_client.sh similarity index 100% rename from db_migrate/docker/start_mysql_client.sh rename to upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql_client.sh diff --git a/db_migrate/docker/stop_mysql.sh b/upgrades/from_v1_0_0_to_v2_0_0/docker/stop_mysql.sh similarity index 100% rename from db_migrate/docker/stop_mysql.sh rename to upgrades/from_v1_0_0_to_v2_0_0/docker/stop_mysql.sh From d59097222703ccfe3d88941acc6da6a994ae6091 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Oct 2022 13:59:44 +0000 Subject: [PATCH 019/357] refactor: [#56] move upgrader from main upgrade mod to specific version upgrader mod --- src/bin/upgrade.rs | 82 ++----------------- src/databases/mod.rs | 2 - src/lib.rs | 1 + .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 2 + .../databases/sqlite_v1_0_0.rs | 13 +-- .../databases/sqlite_v2_0_0.rs | 10 ++- src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 2 + .../from_v1_0_0_to_v2_0_0/upgrader.rs | 79 ++++++++++++++++++ src/upgrades/mod.rs | 1 + 9 files changed, 105 insertions(+), 87 deletions(-) create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs rename src/{ => upgrades/from_v1_0_0_to_v2_0_0}/databases/sqlite_v1_0_0.rs (71%) rename src/{ => upgrades/from_v1_0_0_to_v2_0_0}/databases/sqlite_v2_0_0.rs (94%) create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs create mode 100644 src/upgrades/mod.rs diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index 563bebdb..15350d1d 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -1,82 +1,10 @@ -//! Migration command to migrate data from v1.0.0 to v2.0.0 -//! Run it with `cargo run --bin upgrade` +//! Upgrade command. +//! It updates the application from version v1.0.0 to v2.0.0. +//! You can execute it with: `cargo run --bin upgrade` -use std::sync::Arc; -use torrust_index_backend::config::Configuration; -use torrust_index_backend::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use torrust_index_backend::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; - -async fn current_db() -> Arc { - // Connect to the old v1.0.0 DB - let cfg = match Configuration::load_from_file().await { - Ok(config) => Arc::new(config), - Err(error) => { - panic!("{}", error) - } - }; - - let settings = cfg.settings.read().await; - - Arc::new(SqliteDatabaseV1_0_0::new(&settings.database.connect_url).await) -} - -async fn new_db(db_filename: String) -> Arc { - let dest_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); - Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) -} - -async fn reset_destiny_database(dest_database: Arc) { - println!("Truncating all tables in destiny database ..."); - dest_database - .delete_all_database_rows() - .await - .expect("Can't reset destiny database."); -} - -async fn transfer_categories( - source_database: Arc, - dest_database: Arc, -) { - let source_categories = source_database.get_categories_order_by_id().await.unwrap(); - println!("[v1] categories: {:?}", &source_categories); - - let result = dest_database.reset_categories_sequence().await.unwrap(); - println!("result {:?}", result); - - for cat in &source_categories { - println!( - "[v2] adding category: {:?} {:?} ...", - &cat.category_id, &cat.name - ); - let id = dest_database - .insert_category_and_get_id(&cat.name) - .await - .unwrap(); - - if id != cat.category_id { - panic!( - "Error copying category {:?} from source DB to destiny DB", - &cat.category_id - ); - } - - println!("[v2] category: {:?} {:?} added.", id, &cat.name); - } - - let dest_categories = dest_database.get_categories().await.unwrap(); - println!("[v2] categories: {:?}", &dest_categories); -} +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::upgrade; #[actix_web::main] async fn main() { - // Get connections to source adn destiny databases - let source_database = current_db().await; - let dest_database = new_db("data_v2.db".to_string()).await; - - println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); - - reset_destiny_database(dest_database.clone()).await; - transfer_categories(source_database.clone(), dest_database.clone()).await; - - // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. + upgrade().await; } diff --git a/src/databases/mod.rs b/src/databases/mod.rs index c15a2b72..169d99f4 100644 --- a/src/databases/mod.rs +++ b/src/databases/mod.rs @@ -1,5 +1,3 @@ pub mod database; pub mod mysql; pub mod sqlite; -pub mod sqlite_v1_0_0; -pub mod sqlite_v2_0_0; diff --git a/src/lib.rs b/src/lib.rs index 5a0100c3..d7ef0d09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod mailer; pub mod models; pub mod routes; pub mod tracker; +pub mod upgrades; pub mod utils; trait AsCSV { diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs new file mode 100644 index 00000000..fa37d81b --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs @@ -0,0 +1,2 @@ +pub mod sqlite_v1_0_0; +pub mod sqlite_v2_0_0; diff --git a/src/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs similarity index 71% rename from src/databases/sqlite_v1_0_0.rs rename to src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 10420128..a7351479 100644 --- a/src/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -1,8 +1,9 @@ -use super::database::DatabaseError; use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query_as, SqlitePool}; +use crate::databases::database::DatabaseError; + #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Category { pub category_id: i64, @@ -22,9 +23,11 @@ impl SqliteDatabaseV1_0_0 { } pub async fn get_categories_order_by_id(&self) -> Result, DatabaseError> { - query_as::<_, Category>("SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC") - .fetch_all(&self.pool) - .await - .map_err(|_| DatabaseError::Error) + query_as::<_, Category>( + "SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC", + ) + .fetch_all(&self.pool) + .await + .map_err(|_| DatabaseError::Error) } } diff --git a/src/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs similarity index 94% rename from src/databases/sqlite_v2_0_0.rs rename to src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 0a1efe33..8dce7584 100644 --- a/src/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -1,8 +1,9 @@ -use super::database::DatabaseError; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; use sqlx::{query, query_as, SqlitePool}; +use crate::databases::database::DatabaseError; + #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Category { pub category_id: i64, @@ -35,7 +36,10 @@ impl SqliteDatabaseV2_0_0 { .map_err(|_| DatabaseError::Error) } - pub async fn insert_category_and_get_id(&self, category_name: &str) -> Result { + pub async fn insert_category_and_get_id( + &self, + category_name: &str, + ) -> Result { query("INSERT INTO torrust_categories (name) VALUES (?)") .bind(category_name) .execute(&self.pool) @@ -51,7 +55,7 @@ impl SqliteDatabaseV2_0_0 { } _ => DatabaseError::Error, }) - } + } pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs new file mode 100644 index 00000000..ef4843d0 --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -0,0 +1,2 @@ +pub mod upgrader; +pub mod databases; \ No newline at end of file diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index e69de29b..1be682cd 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -0,0 +1,79 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; + +use crate::config::Configuration; + +async fn current_db() -> Arc { + // Connect to the old v1.0.0 DB + let cfg = match Configuration::load_from_file().await { + Ok(config) => Arc::new(config), + Err(error) => { + panic!("{}", error) + } + }; + + let settings = cfg.settings.read().await; + + Arc::new(SqliteDatabaseV1_0_0::new(&settings.database.connect_url).await) +} + +async fn new_db(db_filename: String) -> Arc { + let dest_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); + Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) +} + +async fn reset_destiny_database(dest_database: Arc) { + println!("Truncating all tables in destiny database ..."); + dest_database + .delete_all_database_rows() + .await + .expect("Can't reset destiny database."); +} + +async fn transfer_categories( + source_database: Arc, + dest_database: Arc, +) { + let source_categories = source_database.get_categories_order_by_id().await.unwrap(); + println!("[v1] categories: {:?}", &source_categories); + + let result = dest_database.reset_categories_sequence().await.unwrap(); + println!("result {:?}", result); + + for cat in &source_categories { + println!( + "[v2] adding category: {:?} {:?} ...", + &cat.category_id, &cat.name + ); + let id = dest_database + .insert_category_and_get_id(&cat.name) + .await + .unwrap(); + + if id != cat.category_id { + panic!( + "Error copying category {:?} from source DB to destiny DB", + &cat.category_id + ); + } + + println!("[v2] category: {:?} {:?} added.", id, &cat.name); + } + + let dest_categories = dest_database.get_categories().await.unwrap(); + println!("[v2] categories: {:?}", &dest_categories); +} + +pub async fn upgrade() { + // Get connections to source adn destiny databases + let source_database = current_db().await; + let dest_database = new_db("data_v2.db".to_string()).await; + + println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); + + reset_destiny_database(dest_database.clone()).await; + transfer_categories(source_database.clone(), dest_database.clone()).await; + + // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. +} diff --git a/src/upgrades/mod.rs b/src/upgrades/mod.rs new file mode 100644 index 00000000..736d54f6 --- /dev/null +++ b/src/upgrades/mod.rs @@ -0,0 +1 @@ +pub mod from_v1_0_0_to_v2_0_0; \ No newline at end of file From d1059f50e5ab56673cd08defd4aac457de82f033 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Oct 2022 16:55:20 +0000 Subject: [PATCH 020/357] feat: [#56] trasnfer user data from v1.0.0 to v2.0.0 TODO: transfer password --- .../databases/sqlite_v1_0_0.rs | 19 +++++ .../databases/sqlite_v2_0_0.rs | 38 ++++++++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 76 ++++++++++++++++++- 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index a7351479..b38957fd 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -9,6 +9,17 @@ pub struct Category { pub category_id: i64, pub name: String, } + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub user_id: i64, + pub username: String, + pub email: String, + pub email_verified: bool, + pub password: String, + pub administrator: bool, +} + pub struct SqliteDatabaseV1_0_0 { pub pool: SqlitePool, } @@ -30,4 +41,12 @@ impl SqliteDatabaseV1_0_0 { .await .map_err(|_| DatabaseError::Error) } + + pub async fn get_users(&self) -> Result, sqlx::Error> { + query_as::<_, User>( + "SELECT * FROM torrust_users ORDER BY user_id ASC", + ) + .fetch_all(&self.pool) + .await + } } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 8dce7584..5aa83fde 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -57,6 +57,44 @@ impl SqliteDatabaseV2_0_0 { }) } + pub async fn insert_user( + &self, + user_id: i64, + date_registered: &str, + administrator: bool, + ) -> Result { + query( + "INSERT INTO torrust_users (user_id, date_registered, administrator) VALUES (?, ?, ?)", + ) + .bind(user_id) + .bind(date_registered) + .bind(administrator) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + + pub async fn insert_user_profile( + &self, + user_id: i64, + username: &str, + email: &str, + email_verified: bool, + bio: &str, + avatar: &str, + ) -> Result { + query("INSERT INTO torrust_user_profiles (user_id, username, email, email_verified, bio, avatar) VALUES (?, ?, ?, ?, ?, ?)") + .bind(user_id) + .bind(username) + .bind(email) + .bind(email_verified) + .bind(bio) + .bind(avatar) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 1be682cd..29ad2a85 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -1,9 +1,24 @@ +//! It updates the application from version v1.0.0 to v2.0.0. +//! +//! NOTES for `torrust_users` table transfer: +//! +//! - In v2, the table `torrust_user` contains a field `date_registered` non existing in v1. +//! It's used the day when the upgrade command is executed. +//! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. +//! Empty string is used as default value. + use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -use std::sync::Arc; +use chrono::prelude::{DateTime, Utc}; +use std::{sync::Arc, time::SystemTime}; use crate::config::Configuration; +fn today_iso8601() -> String { + let dt: DateTime = SystemTime::now().into(); + format!("{}", dt.format("%Y-%m-%d")) +} + async fn current_db() -> Arc { // Connect to the old v1.0.0 DB let cfg = match Configuration::load_from_file().await { @@ -75,5 +90,64 @@ pub async fn upgrade() { reset_destiny_database(dest_database.clone()).await; transfer_categories(source_database.clone(), dest_database.clone()).await; + // Transfer `torrust_users` + + let users = source_database.get_users().await.unwrap(); + + for user in &users { + // [v2] table torrust_users + + println!( + "[v2][torrust_users] adding user: {:?} {:?} ...", + &user.user_id, &user.username + ); + + let default_data_registered = today_iso8601(); + + let id = dest_database + .insert_user(user.user_id, &default_data_registered, user.administrator) + .await + .unwrap(); + + if id != user.user_id { + panic!( + "Error copying user {:?} from source DB to destiny DB", + &user.user_id + ); + } + + println!( + "[v2][torrust_users] user: {:?} {:?} added.", + &user.user_id, &user.username + ); + + // [v2] table torrust_user_profiles + + println!( + "[v2][torrust_user_profiles] adding user: {:?} {:?} ...", + &user.user_id, &user.username + ); + + let default_user_bio = "".to_string(); + let default_user_avatar = "".to_string(); + + dest_database + .insert_user_profile( + user.user_id, + &user.username, + &user.email, + user.email_verified, + &default_user_bio, + &default_user_avatar, + ) + .await + .unwrap(); + + println!( + "[v2][torrust_user_profiles] user: {:?} {:?} added.", + &user.user_id, &user.username + ); + } + // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. } From cf092835863780c411881479d3350586c36a1ba0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 2 Nov 2022 10:37:49 +0000 Subject: [PATCH 021/357] docs: [#56] update README for integration tests --- tests/README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/README.md b/tests/README.md index 81e9d18a..2cad69c7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,10 +1,21 @@ -### Running Tests -Torrust requires Docker to run different database systems for testing. [install docker here](https://docs.docker.com/engine/). +# Running Tests + +Torrust requires Docker to run different database systems for testing. [Install docker here](https://docs.docker.com/engine/). Start the databases with `docker-compose` before running tests: - $ docker-compose up +```s +docker-compose -f tests/docker-compose.yml up +``` Run all tests using: - $ cargo test +```s +cargo test +``` + +Connect to the DB using MySQL client: + +```s +mysql -h127.0.0.1 -uroot -ppassword torrust-index_test +``` From 01921edfb8a10318857f5e7aa7a88a18111cb48e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 2 Nov 2022 10:39:32 +0000 Subject: [PATCH 022/357] fix: [#56] triggering recompilation on migration changes Migrations were not executed while running integration tests. After adding a new migration without changing any Rust code. More info: https://docs.rs/sqlx/latest/sqlx/macro.migrate.html#triggering-recompilation-on-migration-changes --- build.rs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 build.rs diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..76095938 --- /dev/null +++ b/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} \ No newline at end of file From d9b4e871ed4701eadef8f5d386b090e746699e19 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 2 Nov 2022 11:00:39 +0000 Subject: [PATCH 023/357] feat: [#56] transfer user password from v1.0.0 to v2.0.0 We transfer the field `torrust_users.password` to `torrust_user_authentication.password_hash`. The hash value is using the PHC string format since v.1.0.0. In v1.0.0 we were using the hash function "pbkdf2-sha256". In v2.0.0 we are using "argon2id". The packages we use to verify password allow using different hash functions. So we only had to use a different algorithm depending on the hash id in the PHC string. --- Cargo.lock | 22 ++++++++ Cargo.toml | 1 + src/models/user.rs | 2 +- src/routes/user.rs | 37 +++++++++---- .../databases/sqlite_v2_0_0.rs | 20 +++++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 53 +++++++++++++++---- 6 files changed, 113 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7a6994e..e65c2f14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -941,6 +941,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.3", +] + [[package]] name = "home" version = "0.5.3" @@ -1545,6 +1554,18 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1de2e551fb905ac83f73f7aedf2f0cb4a0da7e35efa24a202a936269f1f18e1" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.3", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "pem" version = "1.1.0" @@ -2577,6 +2598,7 @@ dependencies = [ "futures", "jsonwebtoken", "lettre", + "pbkdf2", "rand_core", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index d89251ac..60206325 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,4 @@ tokio = {version = "1.13", features = ["macros", "io-util", "net", "time", "rt-m lettre = { version = "0.10.0-rc.3", features = ["builder", "tokio1", "tokio1-rustls-tls", "smtp-transport"]} sailfish = "0.4.0" regex = "1.6.0" +pbkdf2 = "0.11.0" diff --git a/src/models/user.rs b/src/models/user.rs index f64b88b4..fdf86f76 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -10,7 +10,7 @@ pub struct User { #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserAuthentication { pub user_id: i64, - pub password_hash: String, + pub password_hash: String } #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)] diff --git a/src/routes/user.rs b/src/routes/user.rs index 6b535bc6..9195be7a 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -2,6 +2,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use pbkdf2::Pbkdf2; use rand_core::OsRng; use serde::{Deserialize, Serialize}; @@ -10,6 +11,7 @@ use crate::config::EmailOnSignup; use crate::errors::{ServiceError, ServiceResult}; use crate::mailer::VerifyClaims; use crate::models::response::{OkResponse, TokenResponse}; +use crate::models::user::UserAuthentication; use crate::utils::regex::validate_email_address; use crate::utils::time::current_time; @@ -139,16 +141,7 @@ pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceRe .await .map_err(|_| ServiceError::InternalServerError)?; - // wrap string of the hashed password into a PasswordHash struct for verification - let parsed_hash = PasswordHash::new(&user_authentication.password_hash)?; - - // verify if the user supplied and the database supplied passwords match - if Argon2::default() - .verify_password(payload.password.as_bytes(), &parsed_hash) - .is_err() - { - return Err(ServiceError::WrongPasswordOrUsername); - } + verify_password(payload.password.as_bytes(), &user_authentication)?; let settings = app_data.cfg.settings.read().await; @@ -174,6 +167,30 @@ pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceRe })) } +/// Verify if the user supplied and the database supplied passwords match +pub fn verify_password(password: &[u8], user_authentication: &UserAuthentication) -> Result<(), ServiceError> { + // wrap string of the hashed password into a PasswordHash struct for verification + let parsed_hash = PasswordHash::new(&user_authentication.password_hash)?; + + match parsed_hash.algorithm.as_str() { + "argon2id" => { + if Argon2::default().verify_password(password, &parsed_hash).is_err() { + return Err(ServiceError::WrongPasswordOrUsername); + } + + Ok(()) + } + "pbkdf2-sha256" => { + if Pbkdf2.verify_password(password, &parsed_hash).is_err() { + return Err(ServiceError::WrongPasswordOrUsername); + } + + Ok(()) + } + _ => Err(ServiceError::WrongPasswordOrUsername), + } +} + pub async fn verify_token(payload: web::Json, app_data: WebAppData) -> ServiceResult { // verify if token is valid let _claims = app_data.auth.verify_jwt(&payload.token).await?; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 5aa83fde..b1eb97e3 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -22,6 +22,13 @@ impl SqliteDatabaseV2_0_0 { Self { pool: db } } + pub async fn migrate(&self) { + sqlx::migrate!("migrations/sqlite3") + .run(&self.pool) + .await + .expect("Could not run database migrations.") + } + pub async fn reset_categories_sequence(&self) -> Result { query("DELETE FROM `sqlite_sequence` WHERE `name` = 'torrust_categories'") .execute(&self.pool) @@ -95,6 +102,19 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } + pub async fn insert_user_password_hash( + &self, + user_id: i64, + password_hash: &str, + ) -> Result { + query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") + .bind(user_id) + .bind(password_hash) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 29ad2a85..4d250188 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -7,8 +7,10 @@ //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::{ + upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0, +}; use chrono::prelude::{DateTime, Utc}; use std::{sync::Arc, time::SystemTime}; @@ -38,6 +40,11 @@ async fn new_db(db_filename: String) -> Arc { Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) } +async fn migrate_destiny_database(dest_database: Arc) { + println!("Running migrations ..."); + dest_database.migrate().await; +} + async fn reset_destiny_database(dest_database: Arc) { println!("Truncating all tables in destiny database ..."); dest_database @@ -80,16 +87,10 @@ async fn transfer_categories( println!("[v2] categories: {:?}", &dest_categories); } -pub async fn upgrade() { - // Get connections to source adn destiny databases - let source_database = current_db().await; - let dest_database = new_db("data_v2.db".to_string()).await; - - println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); - - reset_destiny_database(dest_database.clone()).await; - transfer_categories(source_database.clone(), dest_database.clone()).await; - +async fn transfer_user_data( + source_database: Arc, + dest_database: Arc, +) { // Transfer `torrust_users` let users = source_database.get_users().await.unwrap(); @@ -147,7 +148,37 @@ pub async fn upgrade() { "[v2][torrust_user_profiles] user: {:?} {:?} added.", &user.user_id, &user.username ); + + // [v2] table torrust_user_authentication + + println!( + "[v2][torrust_user_authentication] adding password hash ({:?}) for user ({:?}) ...", + &user.password, &user.user_id + ); + + dest_database + .insert_user_password_hash(user.user_id, &user.password) + .await + .unwrap(); + + println!( + "[v2][torrust_user_authentication] password hash ({:?}) added for user ({:?}).", + &user.password, &user.user_id + ); } +} + +pub async fn upgrade() { + // Get connections to source adn destiny databases + let source_database = current_db().await; + let dest_database = new_db("data_v2.db".to_string()).await; + + println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); + + migrate_destiny_database(dest_database.clone()).await; + reset_destiny_database(dest_database.clone()).await; + transfer_categories(source_database.clone(), dest_database.clone()).await; + transfer_user_data(source_database.clone(), dest_database.clone()).await; // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. } From dd949fa91dce3daf1a480ca4fa320821ea40e551 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 3 Nov 2022 13:17:58 +0000 Subject: [PATCH 024/357] feat: [#56] transfer tracker keys from v1.0.0 to v2.0.0 --- .../databases/sqlite_v1_0_0.rs | 22 ++++++-- .../databases/sqlite_v2_0_0.rs | 17 ++++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 55 +++++++++++++++++-- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index b38957fd..584c6776 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -20,6 +20,14 @@ pub struct User { pub administrator: bool, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct TrackerKey { + pub key_id: i64, + pub user_id: i64, + pub key: String, + pub valid_until: i64, +} + pub struct SqliteDatabaseV1_0_0 { pub pool: SqlitePool, } @@ -43,10 +51,14 @@ impl SqliteDatabaseV1_0_0 { } pub async fn get_users(&self) -> Result, sqlx::Error> { - query_as::<_, User>( - "SELECT * FROM torrust_users ORDER BY user_id ASC", - ) - .fetch_all(&self.pool) - .await + query_as::<_, User>("SELECT * FROM torrust_users ORDER BY user_id ASC") + .fetch_all(&self.pool) + .await + } + + pub async fn get_tracker_keys(&self) -> Result, sqlx::Error> { + query_as::<_, TrackerKey>("SELECT * FROM torrust_tracker_keys ORDER BY key_id ASC") + .fetch_all(&self.pool) + .await } } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index b1eb97e3..e16b4571 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -115,6 +115,23 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } + pub async fn insert_tracker_key( + &self, + tracker_key_id: i64, + user_id: i64, + tracker_key: &str, + date_expiry: i64, + ) -> Result { + query("INSERT INTO torrust_tracker_keys (tracker_key_id, user_id, tracker_key, date_expiry) VALUES (?, ?, ?, ?)") + .bind(tracker_key_id) + .bind(user_id) + .bind(tracker_key) + .bind(date_expiry) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 4d250188..04500666 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -7,10 +7,8 @@ //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -use crate::{ - upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0, -}; use chrono::prelude::{DateTime, Utc}; use std::{sync::Arc, time::SystemTime}; @@ -41,7 +39,7 @@ async fn new_db(db_filename: String) -> Arc { } async fn migrate_destiny_database(dest_database: Arc) { - println!("Running migrations ..."); + println!("Running migrations in destiny database..."); dest_database.migrate().await; } @@ -57,6 +55,8 @@ async fn transfer_categories( source_database: Arc, dest_database: Arc, ) { + println!("Transferring categories ..."); + let source_categories = source_database.get_categories_order_by_id().await.unwrap(); println!("[v1] categories: {:?}", &source_categories); @@ -91,7 +91,9 @@ async fn transfer_user_data( source_database: Arc, dest_database: Arc, ) { - // Transfer `torrust_users` + println!("Transferring users ..."); + + // Transfer table `torrust_users` let users = source_database.get_users().await.unwrap(); @@ -168,6 +170,48 @@ async fn transfer_user_data( } } +async fn transfer_tracker_keys( + source_database: Arc, + dest_database: Arc, +) { + println!("Transferring tracker keys ..."); + + // Transfer table `torrust_tracker_keys` + + let tracker_keys = source_database.get_tracker_keys().await.unwrap(); + + for tracker_key in &tracker_keys { + // [v2] table torrust_tracker_keys + + println!( + "[v2][torrust_users] adding the tracker key: {:?} ...", + &tracker_key.key_id + ); + + let id = dest_database + .insert_tracker_key( + tracker_key.key_id, + tracker_key.user_id, + &tracker_key.key, + tracker_key.valid_until, + ) + .await + .unwrap(); + + if id != tracker_key.key_id { + panic!( + "Error copying tracker key {:?} from source DB to destiny DB", + &tracker_key.key_id + ); + } + + println!( + "[v2][torrust_tracker_keys] tracker key: {:?} added.", + &tracker_key.key_id + ); + } +} + pub async fn upgrade() { // Get connections to source adn destiny databases let source_database = current_db().await; @@ -179,6 +223,7 @@ pub async fn upgrade() { reset_destiny_database(dest_database.clone()).await; transfer_categories(source_database.clone(), dest_database.clone()).await; transfer_user_data(source_database.clone(), dest_database.clone()).await; + transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. } From 35f1e371b5d2ceb20fe20d0c7f027e3b6df216d9 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 3 Nov 2022 13:27:52 +0000 Subject: [PATCH 025/357] fix: [#56} default user registration date with time When we import users from previous versions where the app did not store the registration date we assign the current datetime for the registration. --- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 04500666..5d6b733c 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -16,7 +16,7 @@ use crate::config::Configuration; fn today_iso8601() -> String { let dt: DateTime = SystemTime::now().into(); - format!("{}", dt.format("%Y-%m-%d")) + format!("{}", dt.format("%Y-%m-%d %H:%M:%S")) } async fn current_db() -> Arc { From 8d26faa76ab11dbebcd6e25ee5a4b7414592dfb1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 4 Nov 2022 10:27:42 +0000 Subject: [PATCH 026/357] fix: [#78] parsing keys from tracker --- src/models/tracker_key.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/models/tracker_key.rs b/src/models/tracker_key.rs index 71bf51c3..15e23622 100644 --- a/src/models/tracker_key.rs +++ b/src/models/tracker_key.rs @@ -6,3 +6,15 @@ pub struct TrackerKey { pub key: String, pub valid_until: i64, } + +#[derive(Debug, Serialize, Deserialize)] +pub struct NewTrackerKey { + pub key: String, + pub valid_until: Duration, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Duration { + pub secs: i64, + pub nanos: i64, +} \ No newline at end of file From 0b3aefaa63ce14c3614581b527c24a153a7bedf5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 4 Nov 2022 18:11:32 +0000 Subject: [PATCH 027/357] feat: [#56] transfer torrents (1/4 tables) from v1.0.0 to v2.0.0 Transferred data to tables in versio v2.0.0: - [x] Table `torrust_torrents` - [ ] Table `torrust_torrent_files` - [ ] Table `torrust_torrent_announce_urls` - [ ] Table `torrust_torrent_info` --- src/models/torrent_file.rs | 18 ++ .../databases/sqlite_v1_0_0.rs | 42 +++++ .../databases/sqlite_v2_0_0.rs | 46 +++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 167 +++++++++++++++--- 4 files changed, 253 insertions(+), 20 deletions(-) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 581d73a8..c7ab26a7 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -39,6 +39,24 @@ pub struct TorrentInfo { pub root_hash: Option, } +impl TorrentInfo { + /// torrent file can only hold a pieces key or a root hash key: + /// http://www.bittorrent.org/beps/bep_0030.html + pub fn get_pieces_as_string(&self) -> String { + match &self.pieces { + None => "".to_string(), + Some(byte_buf) => bytes_to_hex(byte_buf.as_ref()) + } + } + + pub fn get_root_hash_as_i64(&self) -> i64 { + match &self.root_hash { + None => 0i64, + Some(root_hash) => root_hash.parse::().unwrap() + } + } +} + #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] pub struct Torrent { pub info: TorrentInfo, // diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 584c6776..3f784db1 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -28,6 +28,29 @@ pub struct TrackerKey { pub valid_until: i64, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct Torrent { + pub torrent_id: i64, + pub uploader: String, + pub info_hash: String, + pub title: String, + pub category_id: i64, + pub description: String, + pub upload_date: i64, + pub file_size: i64, + pub seeders: i64, + pub leechers: i64, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct TorrentFile { + pub file_id: i64, + pub torrent_uid: i64, + pub number: i64, + pub path: String, + pub length: i64, +} + pub struct SqliteDatabaseV1_0_0 { pub pool: SqlitePool, } @@ -56,9 +79,28 @@ impl SqliteDatabaseV1_0_0 { .await } + pub async fn get_user_by_username(&self, username: &str) -> Result { + query_as::<_, User>("SELECT * FROM torrust_users WHERE username = ?") + .bind(username) + .fetch_one(&self.pool) + .await + } + pub async fn get_tracker_keys(&self) -> Result, sqlx::Error> { query_as::<_, TrackerKey>("SELECT * FROM torrust_tracker_keys ORDER BY key_id ASC") .fetch_all(&self.pool) .await } + + pub async fn get_torrents(&self) -> Result, sqlx::Error> { + query_as::<_, Torrent>("SELECT * FROM torrust_torrents ORDER BY torrent_id ASC") + .fetch_all(&self.pool) + .await + } + + pub async fn get_torrent_files(&self) -> Result, sqlx::Error> { + query_as::<_, TorrentFile>("SELECT * FROM torrust_torrent_files ORDER BY file_id ASC") + .fetch_all(&self.pool) + .await + } } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index e16b4571..04c04216 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -132,6 +132,52 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } + pub async fn insert_torrent( + &self, + torrent_id: i64, + uploader_id: i64, + category_id: i64, + info_hash: &str, + size: i64, + name: &str, + pieces: &str, + piece_length: i64, + private: bool, + root_hash: i64, + date_uploaded: &str, + ) -> Result { + query( + " + INSERT INTO torrust_torrents ( + torrent_id, + uploader_id, + category_id, + info_hash, + size, + name, + pieces, + piece_length, + private, + root_hash, + date_uploaded + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(torrent_id) + .bind(uploader_id) + .bind(category_id) + .bind(info_hash) + .bind(size) + .bind(name) + .bind(pieces) + .bind(piece_length) + .bind(private) + .bind(root_hash) + .bind(date_uploaded) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 5d6b733c..b6f23e76 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -7,20 +7,25 @@ //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::utils::parse_torrent::decode_torrent; +use crate::{ + models::torrent_file::Torrent, + upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0, +}; use chrono::prelude::{DateTime, Utc}; +use chrono::NaiveDateTime; + +use std::{error, fs}; use std::{sync::Arc, time::SystemTime}; use crate::config::Configuration; -fn today_iso8601() -> String { - let dt: DateTime = SystemTime::now().into(); - format!("{}", dt.format("%Y-%m-%d %H:%M:%S")) -} +pub async fn upgrade() { + // TODO: get from command arguments + let database_file = "data_v2.db".to_string(); // The new database + let upload_path = "./uploads".to_string(); // The relative dir where torrent files are stored -async fn current_db() -> Arc { - // Connect to the old v1.0.0 DB let cfg = match Configuration::load_from_file().await { Ok(config) => Arc::new(config), Err(error) => { @@ -30,10 +35,29 @@ async fn current_db() -> Arc { let settings = cfg.settings.read().await; - Arc::new(SqliteDatabaseV1_0_0::new(&settings.database.connect_url).await) + // Get connection to source database (current DB in settings) + let source_database = current_db(&settings.database.connect_url).await; + + // Get connection to destiny database + let dest_database = new_db(&database_file).await; + + println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); + + migrate_destiny_database(dest_database.clone()).await; + reset_destiny_database(dest_database.clone()).await; + transfer_categories(source_database.clone(), dest_database.clone()).await; + transfer_user_data(source_database.clone(), dest_database.clone()).await; + transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; + transfer_torrents(source_database.clone(), dest_database.clone(), &upload_path).await; + + // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. } -async fn new_db(db_filename: String) -> Arc { +async fn current_db(connect_url: &str) -> Arc { + Arc::new(SqliteDatabaseV1_0_0::new(connect_url).await) +} + +async fn new_db(db_filename: &str) -> Arc { let dest_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) } @@ -170,6 +194,11 @@ async fn transfer_user_data( } } +fn today_iso8601() -> String { + let dt: DateTime = SystemTime::now().into(); + format!("{}", dt.format("%Y-%m-%d %H:%M:%S")) +} + async fn transfer_tracker_keys( source_database: Arc, dest_database: Arc, @@ -212,18 +241,116 @@ async fn transfer_tracker_keys( } } -pub async fn upgrade() { - // Get connections to source adn destiny databases - let source_database = current_db().await; - let dest_database = new_db("data_v2.db".to_string()).await; +async fn transfer_torrents( + source_database: Arc, + dest_database: Arc, + upload_path: &str, +) { + println!("Transferring torrents ..."); - println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); + // Transfer table `torrust_torrents_files` - migrate_destiny_database(dest_database.clone()).await; - reset_destiny_database(dest_database.clone()).await; - transfer_categories(source_database.clone(), dest_database.clone()).await; - transfer_user_data(source_database.clone(), dest_database.clone()).await; - transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; + // Although the The table `torrust_torrents_files` existed in version v1.0.0 + // it was was not used. - // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. + // Transfer table `torrust_torrents` + + let torrents = source_database.get_torrents().await.unwrap(); + + for torrent in &torrents { + // [v2] table torrust_torrents + + println!( + "[v2][torrust_torrents] adding the torrent: {:?} ...", + &torrent.torrent_id + ); + + // TODO: confirm with @WarmBeer that + // - All torrents were public in version v1.0.0 + // - Infohashes were in lowercase en v1.0. and uppercase in version v2.0.0 + let private = false; + + let uploader = source_database + .get_user_by_username(&torrent.uploader) + .await + .unwrap(); + + if uploader.username != torrent.uploader { + panic!( + "Error copying torrent {:?}. Uploader in torrent does username", + &torrent.torrent_id + ); + } + + let filepath = format!("{}/{}.torrent", upload_path, &torrent.torrent_id); + + let torrent_from_file = read_torrent_from_file(&filepath).unwrap(); + + let pieces = torrent_from_file.info.get_pieces_as_string(); + let root_hash = torrent_from_file.info.get_root_hash_as_i64(); + + let id = dest_database + .insert_torrent( + torrent.torrent_id, + uploader.user_id, + torrent.category_id, + &torrent_from_file.info_hash(), + torrent.file_size, + &torrent_from_file.info.name, + &pieces, + torrent_from_file.info.piece_length, + private, + root_hash, + &convert_timestamp_to_datetime(torrent.upload_date), + ) + .await + .unwrap(); + + if id != torrent.torrent_id { + panic!( + "Error copying torrent {:?} from source DB to destiny DB", + &torrent.torrent_id + ); + } + + println!( + "[v2][torrust_torrents] torrent: {:?} added.", + &torrent.torrent_id + ); + + // [v2] table torrust_torrent_files + + // TODO + + // [v2] table torrust_torrent_announce_urls + + // TODO + + // [v2] table torrust_torrent_info + + // TODO + } +} + +fn read_torrent_from_file(path: &str) -> Result> { + let contents = match fs::read(path) { + Ok(contents) => contents, + Err(e) => return Err(e.into()), + }; + + match decode_torrent(&contents) { + Ok(torrent) => Ok(torrent), + Err(e) => Err(e), + } +} + +fn convert_timestamp_to_datetime(timestamp: i64) -> String { + // The expected format in database is: 2022-11-04 09:53:57 + // MySQL uses a DATETIME column and SQLite uses a TEXT column. + + let naive_datetime = NaiveDateTime::from_timestamp(timestamp, 0); + let datetime_again: DateTime = DateTime::from_utc(naive_datetime, Utc); + + // Format without timezone + datetime_again.format("%Y-%m-%d %H:%M:%S").to_string() } From 03e4befa186c6e0cb6b541385f344b0e3eb059e5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 7 Nov 2022 16:35:07 +0000 Subject: [PATCH 028/357] feat: [#56] remove unused scripts and write basic upgrage guide --- upgrades/from_v1_0_0_to_v2_0_0/README.md | 143 +++--------------- .../docker/start_mysql.sh | 10 -- .../docker/start_mysql_client.sh | 3 - .../docker/stop_mysql.sh | 3 - 4 files changed, 22 insertions(+), 137 deletions(-) delete mode 100755 upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh delete mode 100755 upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql_client.sh delete mode 100755 upgrades/from_v1_0_0_to_v2_0_0/docker/stop_mysql.sh diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index af9a9b69..ab04a8b4 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -1,133 +1,34 @@ -# DB migration +# Upgrade from v1.0.0 to v2.0.0 -With the console command `cargo run --bin upgrade` you can migrate data from `v1.0.0` to `v2.0.0`. This migration includes: +## How-to -- Changing the DB schema. -- Transferring the torrent files in the dir `uploads` to the database. +To upgrade from version `v1.0.0` to `v2.0.0` you have to follow these steps: -## SQLite3 +- Back up your current database and the `uploads` folder. You can find which database and upload folder are you using in the `Config.toml` file in the root folder of your installation. +- Set up a local environment exactly as you have it in production with your production data (DB and torrents folder). +- Run the application locally with: `cargo run`. +- Execute the upgrader command: `cargo run --bin upgrade` +- A new SQLite file should have been created in the root folder: `data_v2.db` +- Stop the running application and change the DB configuration to use the newly generated configuration: -TODO - -## MySQL8 - -Please, - -> WARNING: MySQL migration is not implemented yet. We also provide docker infrastructure to run mysql during implementation of a migration tool. - -and also: - -> WARNING: We are not using a persisted volume. If you remove the volume used by the container you lose the database data. - -Run the docker container and connect using the console client: - -```s -./upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh -./upgrades/from_v1_0_0_to_v2_0_0/docker/mysql_client.sh -``` - -Once you are connected to the client you can create databases with: - -```s -create database torrust_v1; -create database torrust_v2; -``` - -After creating databases you should see something like this: - -```s -mysql> show databases; -+--------------------+ -| Database | -+--------------------+ -| information_schema | -| mysql | -| performance_schema | -| sys | -| torrust_v1 | -| torrust_v2 | -+--------------------+ -6 rows in set (0.001 sec) -``` - -How to connect from outside the container: - -```s -mysql -h127.0.0.1 -uroot -pdb-root-password -``` - -## Create DB for backend `v2.0.0` - -You need to create an empty new database for v2.0.0. - -You need to change the configuration in `config.toml` file to use MySQL: - -```yml +```toml [database] -connect_url = "mysql://root:db-root-password@127.0.0.1/torrust_v2" -``` - -After running the backend with `cargo run` you should see the tables created by migrations: - -```s -mysql> show tables; -+-------------------------------+ -| Tables_in_torrust_v2 | -+-------------------------------+ -| _sqlx_migrations | -| torrust_categories | -| torrust_torrent_announce_urls | -| torrust_torrent_files | -| torrust_torrent_info | -| torrust_torrent_tracker_stats | -| torrust_torrents | -| torrust_tracker_keys | -| torrust_user_authentication | -| torrust_user_bans | -| torrust_user_invitation_uses | -| torrust_user_invitations | -| torrust_user_profiles | -| torrust_user_public_keys | -| torrust_users | -+-------------------------------+ -15 rows in set (0.001 sec) -``` - -### Create DB for backend `v1.0.0` - -The `upgrade` command is going to import data from version `v1.0.0` (database and `uploads` folder) into the new empty database for `v2.0.0`. - -You can import data into the source database for testing with the `mysql` DB client or docker. - -Using `mysql` client: - -```s -mysql -h127.0.0.1 -uroot -pdb-root-password torrust_v1 < ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/db_migrations_v1_for_mysql_8.sql +connect_url = "sqlite://data_v2.db?mode=rwc" ``` -Using dockerized `mysql` client: +- Run the application again. +- Perform some tests. +- If all tests pass, stop the production service, replace the DB, and start it again. -```s -docker exec -i torrust-index-backend-mysql mysql torrust_v1 -uroot -pdb-root-password < ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/db_migrations_v1_for_mysql_8.sql -``` +## Tests -### Commands +Before replacing the DB in production you can make some tests like: -Connect to `mysql` client: - -```s -mysql -h127.0.0.1 -uroot -pdb-root-password torrust_v1 -``` +- Try to log in with a preexisting user. If you do not know any you can create a new "test" user in production before starting with the upgrade process. Users had a different hash algorithm for the password in v1. +- Try to create a new user. +- Try to upload and download a new torrent containing a single file. +- Try to upload and download a new torrent containing a folder. -Connect to dockerized `mysql` client: +## Notes -```s -docker exec -it torrust-index-backend-mysql mysql torrust_v1 -uroot -pdb-root-password -``` - -Backup DB: - -```s -mysqldump -h127.0.0.1 torrust_v1 -uroot -pdb-root-password > ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/v1_schema_dump.sql -mysqldump -h127.0.0.1 torrust_v2 -uroot -pdb-root-password > ./upgrades/from_v1_0_0_to_v2_0_0/db_schemas/v2_schema_dump.sql -``` +The `db_schemas` contains the snapshots of the source and destiny databases for this upgrade. diff --git a/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh b/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh deleted file mode 100755 index 5a245d32..00000000 --- a/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -docker run \ - --detach \ - --name torrust-index-backend-mysql \ - --env MYSQL_USER=db-user \ - --env MYSQL_PASSWORD=db-passwrod \ - --env MYSQL_ROOT_PASSWORD=db-root-password \ - -p 3306:3306 \ - mysql:8.0.30 # This version is used in tests diff --git a/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql_client.sh b/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql_client.sh deleted file mode 100755 index fed2a877..00000000 --- a/upgrades/from_v1_0_0_to_v2_0_0/docker/start_mysql_client.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker exec -it torrust-index-backend-mysql mysql -uroot -pdb-root-password diff --git a/upgrades/from_v1_0_0_to_v2_0_0/docker/stop_mysql.sh b/upgrades/from_v1_0_0_to_v2_0_0/docker/stop_mysql.sh deleted file mode 100755 index 19d7a786..00000000 --- a/upgrades/from_v1_0_0_to_v2_0_0/docker/stop_mysql.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker stop torrust-index-backend-mysql From 3fea6ea7819ee0dbed3095dc268b7bd3c09578eb Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Nov 2022 12:40:34 +0000 Subject: [PATCH 029/357] feat: [#56] trasnfer torrents (2/4 tables) from v1.0.0 to v2.0.0 --- .../databases/sqlite_v2_0_0.rs | 40 +++++++++++++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 49 +++++++++++++++++++ upgrades/from_v1_0_0_to_v2_0_0/README.md | 2 +- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 04c04216..8ce447b2 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -3,6 +3,9 @@ use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; use sqlx::{query, query_as, SqlitePool}; use crate::databases::database::DatabaseError; +use crate::models::torrent_file::TorrentFile; + +use super::sqlite_v1_0_0::Torrent; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Category { @@ -178,6 +181,43 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } + pub async fn insert_torrent_file_for_torrent_with_one_file( + &self, + torrent_id: i64, + md5sum: &Option, + length: i64, + ) -> Result { + query( + " + INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH) + VALUES (?, ?, ?)", + ) + .bind(md5sum) + .bind(torrent_id) + .bind(length) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + + pub async fn insert_torrent_file_for_torrent_with_multiple_files( + &self, + torrent: &Torrent, + file: &TorrentFile, + ) -> Result { + query( + "INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH, PATH) + VALUES (?, ?, ?, ?)", + ) + .bind(file.md5sum.clone()) + .bind(torrent.torrent_id) + .bind(file.length) + .bind(file.path.join("/")) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index b6f23e76..71fa762b 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -322,6 +322,55 @@ async fn transfer_torrents( // TODO + println!("[v2][torrust_torrent_files] adding torrent files"); + + let _is_torrent_with_multiple_files = torrent_from_file.info.files.is_some(); + let is_torrent_with_a_single_file = torrent_from_file.info.length.is_some(); + + if is_torrent_with_a_single_file { + // Only one file is being shared: + // - "path" is NULL + // - "md5sum" can be NULL + + println!( + "[v2][torrust_torrent_files][one] adding torrent file {:?} with length {:?} ...", + &torrent_from_file.info.name, &torrent_from_file.info.length, + ); + + let file_id = dest_database + .insert_torrent_file_for_torrent_with_one_file( + torrent.torrent_id, + // TODO: it seems med5sum can be None. Why? When? + &torrent_from_file.info.md5sum.clone(), + torrent_from_file.info.length.unwrap(), + ) + .await; + + println!( + "[v2][torrust_torrent_files][one] torrent file insert result: {:?}", + &file_id + ); + } else { + // Multiple files are being shared + let files = torrent_from_file.info.files.as_ref().unwrap(); + + for file in files.iter() { + println!( + "[v2][torrust_torrent_files][multiple] adding torrent file: {:?} ...", + &file + ); + + let file_id = dest_database + .insert_torrent_file_for_torrent_with_multiple_files(torrent, file) + .await; + + println!( + "[v2][torrust_torrent_files][multiple] torrent file insert result: {:?}", + &file_id + ); + } + } + // [v2] table torrust_torrent_announce_urls // TODO diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index ab04a8b4..e635f8a1 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -26,7 +26,7 @@ Before replacing the DB in production you can make some tests like: - Try to log in with a preexisting user. If you do not know any you can create a new "test" user in production before starting with the upgrade process. Users had a different hash algorithm for the password in v1. - Try to create a new user. -- Try to upload and download a new torrent containing a single file. +- Try to upload and download a new torrent containing a single file (with and without md5sum). - Try to upload and download a new torrent containing a folder. ## Notes From 8bdf32ffb219cb258b3fdae4ed181a9219b9416f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Nov 2022 12:48:13 +0000 Subject: [PATCH 030/357] feat: [#56] trasnfer torrents (3/4 tables) from v1.0.0 to v2.0.0 --- .../databases/sqlite_v2_0_0.rs | 13 +++++++++++++ src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 14 +++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 8ce447b2..3f1b3ade 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -218,6 +218,19 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } + pub async fn insert_torrent_info(&self, torrent: &Torrent) -> Result { + query( + "INSERT INTO torrust_torrent_info (torrent_id, title, description) + VALUES (?, ?, ?)", + ) + .bind(torrent.torrent_id) + .bind(torrent.title.clone()) + .bind(torrent.description.clone()) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 71fa762b..45f681db 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -377,7 +377,19 @@ async fn transfer_torrents( // [v2] table torrust_torrent_info - // TODO + println!( + "[v2][torrust_torrent_info] adding the torrent info for torrent {:?} ...", + &torrent.torrent_id + ); + + let id = dest_database.insert_torrent_info(torrent).await; + + println!( + "[v2][torrust_torrents] torrent info insert result: {:?}.", + &id + ); + + println!("Torrents transferred"); } } From 21174d4c746a1564ab1e3c9cdd59628648706bef Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 8 Nov 2022 13:56:41 +0000 Subject: [PATCH 031/357] feat: [#56] trasnfer torrents (4/4 tables) from v1.0.0 to v2.0.0 --- .../databases/sqlite_v2_0_0.rs | 13 +++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 53 +++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 3f1b3ade..836ed864 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -231,6 +231,19 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } + pub async fn insert_torrent_announce_url( + &self, + torrent_id: i64, + tracker_url: &str, + ) -> Result { + query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") + .bind(torrent_id) + .bind(tracker_url) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { query("DELETE FROM torrust_categories;") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 45f681db..9f783bb7 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -268,6 +268,8 @@ async fn transfer_torrents( // TODO: confirm with @WarmBeer that // - All torrents were public in version v1.0.0 // - Infohashes were in lowercase en v1.0. and uppercase in version v2.0.0 + // - Only one option is used for announce url if we have two the announce and the announce list. + // And announce has priority over announce list. let private = false; let uploader = source_database @@ -371,10 +373,6 @@ async fn transfer_torrents( } } - // [v2] table torrust_torrent_announce_urls - - // TODO - // [v2] table torrust_torrent_info println!( @@ -389,8 +387,53 @@ async fn transfer_torrents( &id ); - println!("Torrents transferred"); + // [v2] table torrust_torrent_announce_urls + + println!( + "[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent {:?} ...", + &torrent.torrent_id + ); + + if torrent_from_file.announce.is_some() { + println!("[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent {:?} ...", &torrent.torrent_id); + + let announce_url_id = dest_database + .insert_torrent_announce_url( + torrent.torrent_id, + &torrent_from_file.announce.unwrap(), + ) + .await; + + println!( + "[v2][torrust_torrent_announce_urls] torrent announce url insert result {:?} ...", + &announce_url_id + ); + } else if torrent_from_file.announce_list.is_some() { + // BEP-0012. Multiple trackers. + + println!("[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent {:?} ...", &torrent.torrent_id); + + // flatten the nested vec (this will however remove the) + let announce_urls = torrent_from_file + .announce_list + .clone() + .unwrap() + .into_iter() + .flatten() + .collect::>(); + + for tracker_url in announce_urls.iter() { + println!("[v2][torrust_torrent_announce_urls] adding the torrent announce url (from announce list) for torrent {:?} ...", &torrent.torrent_id); + + let announce_url_id = dest_database + .insert_torrent_announce_url(torrent.torrent_id, tracker_url) + .await; + + println!("[v2][torrust_torrent_announce_urls] torrent announce url insert result {:?} ...", &announce_url_id); + } + } } + println!("Torrents transferred"); } fn read_torrent_from_file(path: &str) -> Result> { From 99edf5257ee56d4212c4bed7978a3d56dba29af2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 10:17:04 +0000 Subject: [PATCH 032/357] feat: imported users have importation date instead of registrataion date - Now registration date is optional (we allow NULL) for imported users. - And imported users have an importation date, which is NULL for registered users throught the sdantadrd registration process. --- ...092556_torrust_user_date_registered_allow_null.sql | 1 + .../20221109095718_torrust_user_add_date_imported.sql | 1 + ...092556_torrust_user_date_registered_allow_null.sql | 11 +++++++++++ .../20221109095718_torrust_user_add_date_imported.sql | 1 + src/models/user.rs | 6 ++++-- .../from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs | 8 ++++---- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 7 ++++--- 7 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql create mode 100644 migrations/mysql/20221109095718_torrust_user_add_date_imported.sql create mode 100644 migrations/sqlite3/20221109092556_torrust_user_date_registered_allow_null.sql create mode 100644 migrations/sqlite3/20221109095718_torrust_user_add_date_imported.sql diff --git a/migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql b/migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql new file mode 100644 index 00000000..9f936f8a --- /dev/null +++ b/migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_users CHANGE date_registered date_registered DATETIME NOT NULL \ No newline at end of file diff --git a/migrations/mysql/20221109095718_torrust_user_add_date_imported.sql b/migrations/mysql/20221109095718_torrust_user_add_date_imported.sql new file mode 100644 index 00000000..352a5e8f --- /dev/null +++ b/migrations/mysql/20221109095718_torrust_user_add_date_imported.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_users ADD COLUMN date_imported DATETIME DEFAULT NULL \ No newline at end of file diff --git a/migrations/sqlite3/20221109092556_torrust_user_date_registered_allow_null.sql b/migrations/sqlite3/20221109092556_torrust_user_date_registered_allow_null.sql new file mode 100644 index 00000000..5757849c --- /dev/null +++ b/migrations/sqlite3/20221109092556_torrust_user_date_registered_allow_null.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS torrust_users_new ( + user_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + date_registered TEXT DEFAULT NULL, + administrator BOOL NOT NULL DEFAULT FALSE +); + +INSERT INTO torrust_users_new SELECT * FROM torrust_users; + +DROP TABLE torrust_users; + +ALTER TABLE torrust_users_new RENAME TO torrust_users \ No newline at end of file diff --git a/migrations/sqlite3/20221109095718_torrust_user_add_date_imported.sql b/migrations/sqlite3/20221109095718_torrust_user_add_date_imported.sql new file mode 100644 index 00000000..96dddd2f --- /dev/null +++ b/migrations/sqlite3/20221109095718_torrust_user_add_date_imported.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_users ADD COLUMN date_imported TEXT DEFAULT NULL \ No newline at end of file diff --git a/src/models/user.rs b/src/models/user.rs index fdf86f76..f1418f3a 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -3,7 +3,8 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct User { pub user_id: i64, - pub date_registered: String, + pub date_registered: Option, + pub date_imported: Option, pub administrator: bool, } @@ -33,7 +34,8 @@ pub struct UserCompact { #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserFull { pub user_id: i64, - pub date_registered: String, + pub date_registered: Option, + pub date_imported: Option, pub administrator: bool, pub username: String, pub email: String, diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 836ed864..41d9327a 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -67,17 +67,17 @@ impl SqliteDatabaseV2_0_0 { }) } - pub async fn insert_user( + pub async fn insert_imported_user( &self, user_id: i64, - date_registered: &str, + date_imported: &str, administrator: bool, ) -> Result { query( - "INSERT INTO torrust_users (user_id, date_registered, administrator) VALUES (?, ?, ?)", + "INSERT INTO torrust_users (user_id, date_imported, administrator) VALUES (?, ?, ?)", ) .bind(user_id) - .bind(date_registered) + .bind(date_imported) .bind(administrator) .execute(&self.pool) .await diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 9f783bb7..0c3d99fd 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -3,7 +3,8 @@ //! NOTES for `torrust_users` table transfer: //! //! - In v2, the table `torrust_user` contains a field `date_registered` non existing in v1. -//! It's used the day when the upgrade command is executed. +//! We changed that columns to allow NULL. WE also added the new column `date_imported` with +//! the datetime when the upgrader was executed. //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. @@ -129,10 +130,10 @@ async fn transfer_user_data( &user.user_id, &user.username ); - let default_data_registered = today_iso8601(); + let date_imported = today_iso8601(); let id = dest_database - .insert_user(user.user_id, &default_data_registered, user.administrator) + .insert_imported_user(user.user_id, &date_imported, user.administrator) .await .unwrap(); From 715265490dc118c27f8ffeb2c1598efd92de5c98 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 10:46:44 +0000 Subject: [PATCH 033/357] refactor: [#56] rename structs for DB records Add the "Record" suffix to structs representing DB records. In order to avoid mixing them up with models. --- .../databases/sqlite_v1_0_0.rs | 34 +++++++++---------- .../databases/sqlite_v2_0_0.rs | 28 +++++++-------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 3f784db1..d763be6b 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -5,13 +5,13 @@ use sqlx::{query_as, SqlitePool}; use crate::databases::database::DatabaseError; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct Category { +pub struct CategoryRecord { pub category_id: i64, pub name: String, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct User { +pub struct UserRecord { pub user_id: i64, pub username: String, pub email: String, @@ -21,7 +21,7 @@ pub struct User { } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct TrackerKey { +pub struct TrackerKeyRecord { pub key_id: i64, pub user_id: i64, pub key: String, @@ -29,7 +29,7 @@ pub struct TrackerKey { } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct Torrent { +pub struct TorrentRecord { pub torrent_id: i64, pub uploader: String, pub info_hash: String, @@ -43,7 +43,7 @@ pub struct Torrent { } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct TorrentFile { +pub struct TorrentFileRecord { pub file_id: i64, pub torrent_uid: i64, pub number: i64, @@ -64,8 +64,8 @@ impl SqliteDatabaseV1_0_0 { Self { pool: db } } - pub async fn get_categories_order_by_id(&self) -> Result, DatabaseError> { - query_as::<_, Category>( + pub async fn get_categories_order_by_id(&self) -> Result, DatabaseError> { + query_as::<_, CategoryRecord>( "SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC", ) .fetch_all(&self.pool) @@ -73,33 +73,33 @@ impl SqliteDatabaseV1_0_0 { .map_err(|_| DatabaseError::Error) } - pub async fn get_users(&self) -> Result, sqlx::Error> { - query_as::<_, User>("SELECT * FROM torrust_users ORDER BY user_id ASC") + pub async fn get_users(&self) -> Result, sqlx::Error> { + query_as::<_, UserRecord>("SELECT * FROM torrust_users ORDER BY user_id ASC") .fetch_all(&self.pool) .await } - pub async fn get_user_by_username(&self, username: &str) -> Result { - query_as::<_, User>("SELECT * FROM torrust_users WHERE username = ?") + pub async fn get_user_by_username(&self, username: &str) -> Result { + query_as::<_, UserRecord>("SELECT * FROM torrust_users WHERE username = ?") .bind(username) .fetch_one(&self.pool) .await } - pub async fn get_tracker_keys(&self) -> Result, sqlx::Error> { - query_as::<_, TrackerKey>("SELECT * FROM torrust_tracker_keys ORDER BY key_id ASC") + pub async fn get_tracker_keys(&self) -> Result, sqlx::Error> { + query_as::<_, TrackerKeyRecord>("SELECT * FROM torrust_tracker_keys ORDER BY key_id ASC") .fetch_all(&self.pool) .await } - pub async fn get_torrents(&self) -> Result, sqlx::Error> { - query_as::<_, Torrent>("SELECT * FROM torrust_torrents ORDER BY torrent_id ASC") + pub async fn get_torrents(&self) -> Result, sqlx::Error> { + query_as::<_, TorrentRecord>("SELECT * FROM torrust_torrents ORDER BY torrent_id ASC") .fetch_all(&self.pool) .await } - pub async fn get_torrent_files(&self) -> Result, sqlx::Error> { - query_as::<_, TorrentFile>("SELECT * FROM torrust_torrent_files ORDER BY file_id ASC") + pub async fn get_torrent_files(&self) -> Result, sqlx::Error> { + query_as::<_, TorrentFileRecord>("SELECT * FROM torrust_torrent_files ORDER BY file_id ASC") .fetch_all(&self.pool) .await } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 41d9327a..186bb712 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -5,10 +5,10 @@ use sqlx::{query, query_as, SqlitePool}; use crate::databases::database::DatabaseError; use crate::models::torrent_file::TorrentFile; -use super::sqlite_v1_0_0::Torrent; +use super::sqlite_v1_0_0::TorrentRecord; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct Category { +pub struct CategoryRecord { pub category_id: i64, pub name: String, } @@ -39,8 +39,8 @@ impl SqliteDatabaseV2_0_0 { .map_err(|_| DatabaseError::Error) } - pub async fn get_categories(&self) -> Result, DatabaseError> { - query_as::<_, Category>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") + pub async fn get_categories(&self) -> Result, DatabaseError> { + query_as::<_, CategoryRecord>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") .fetch_all(&self.pool) .await .map_err(|_| DatabaseError::Error) @@ -73,15 +73,13 @@ impl SqliteDatabaseV2_0_0 { date_imported: &str, administrator: bool, ) -> Result { - query( - "INSERT INTO torrust_users (user_id, date_imported, administrator) VALUES (?, ?, ?)", - ) - .bind(user_id) - .bind(date_imported) - .bind(administrator) - .execute(&self.pool) - .await - .map(|v| v.last_insert_rowid()) + query("INSERT INTO torrust_users (user_id, date_imported, administrator) VALUES (?, ?, ?)") + .bind(user_id) + .bind(date_imported) + .bind(administrator) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) } pub async fn insert_user_profile( @@ -202,7 +200,7 @@ impl SqliteDatabaseV2_0_0 { pub async fn insert_torrent_file_for_torrent_with_multiple_files( &self, - torrent: &Torrent, + torrent: &TorrentRecord, file: &TorrentFile, ) -> Result { query( @@ -218,7 +216,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } - pub async fn insert_torrent_info(&self, torrent: &Torrent) -> Result { + pub async fn insert_torrent_info(&self, torrent: &TorrentRecord) -> Result { query( "INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, ?)", From b9bf405d9793ed792f9960925e7ead4872858b70 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 11:03:24 +0000 Subject: [PATCH 034/357] feat: [#56] improve command output --- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 0c3d99fd..4f96fb2f 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -5,6 +5,9 @@ //! - In v2, the table `torrust_user` contains a field `date_registered` non existing in v1. //! We changed that columns to allow NULL. WE also added the new column `date_imported` with //! the datetime when the upgrader was executed. +//! +//! NOTES for `torrust_user_profiles` table transfer: +//! //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. @@ -50,8 +53,6 @@ pub async fn upgrade() { transfer_user_data(source_database.clone(), dest_database.clone()).await; transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; transfer_torrents(source_database.clone(), dest_database.clone(), &upload_path).await; - - // TODO: WIP. We have to transfer data from the 5 tables in V1 and the torrent files in folder `uploads`. } async fn current_db(connect_url: &str) -> Arc { @@ -86,12 +87,12 @@ async fn transfer_categories( println!("[v1] categories: {:?}", &source_categories); let result = dest_database.reset_categories_sequence().await.unwrap(); - println!("result {:?}", result); + println!("[v2] reset categories sequence result {:?}", result); for cat in &source_categories { println!( - "[v2] adding category: {:?} {:?} ...", - &cat.category_id, &cat.name + "[v2] adding category {:?} with id {:?} ...", + &cat.name, &cat.category_id ); let id = dest_database .insert_category_and_get_id(&cat.name) @@ -126,8 +127,8 @@ async fn transfer_user_data( // [v2] table torrust_users println!( - "[v2][torrust_users] adding user: {:?} {:?} ...", - &user.user_id, &user.username + "[v2][torrust_users] adding user with username {:?} and id {:?} ...", + &user.username, &user.user_id ); let date_imported = today_iso8601(); @@ -152,8 +153,8 @@ async fn transfer_user_data( // [v2] table torrust_user_profiles println!( - "[v2][torrust_user_profiles] adding user: {:?} {:?} ...", - &user.user_id, &user.username + "[v2][torrust_user_profiles] adding user profile for user with username {:?} and id {:?} ...", + &user.username, &user.user_id ); let default_user_bio = "".to_string(); @@ -172,14 +173,14 @@ async fn transfer_user_data( .unwrap(); println!( - "[v2][torrust_user_profiles] user: {:?} {:?} added.", - &user.user_id, &user.username + "[v2][torrust_user_profiles] user profile added for user with username {:?} and id {:?}.", + &user.username, &user.user_id ); // [v2] table torrust_user_authentication println!( - "[v2][torrust_user_authentication] adding password hash ({:?}) for user ({:?}) ...", + "[v2][torrust_user_authentication] adding password hash ({:?}) for user id ({:?}) ...", &user.password, &user.user_id ); @@ -189,7 +190,7 @@ async fn transfer_user_data( .unwrap(); println!( - "[v2][torrust_user_authentication] password hash ({:?}) added for user ({:?}).", + "[v2][torrust_user_authentication] password hash ({:?}) added for user id ({:?}).", &user.password, &user.user_id ); } @@ -214,7 +215,7 @@ async fn transfer_tracker_keys( // [v2] table torrust_tracker_keys println!( - "[v2][torrust_users] adding the tracker key: {:?} ...", + "[v2][torrust_users] adding the tracker key with id {:?} ...", &tracker_key.key_id ); @@ -236,7 +237,7 @@ async fn transfer_tracker_keys( } println!( - "[v2][torrust_tracker_keys] tracker key: {:?} added.", + "[v2][torrust_tracker_keys] tracker key with id {:?} added.", &tracker_key.key_id ); } @@ -266,11 +267,7 @@ async fn transfer_torrents( &torrent.torrent_id ); - // TODO: confirm with @WarmBeer that - // - All torrents were public in version v1.0.0 - // - Infohashes were in lowercase en v1.0. and uppercase in version v2.0.0 - // - Only one option is used for announce url if we have two the announce and the announce list. - // And announce has priority over announce list. + // All torrents were public in version v1.0.0 let private = false; let uploader = source_database @@ -280,7 +277,8 @@ async fn transfer_torrents( if uploader.username != torrent.uploader { panic!( - "Error copying torrent {:?}. Uploader in torrent does username", + "Error copying torrent with id {:?}. + Username (`uploader`) in `torrust_torrents` table does not match `username` in `torrust_users` table", &torrent.torrent_id ); } @@ -317,26 +315,24 @@ async fn transfer_torrents( } println!( - "[v2][torrust_torrents] torrent: {:?} added.", + "[v2][torrust_torrents] torrent with id {:?} added.", &torrent.torrent_id ); // [v2] table torrust_torrent_files - // TODO - println!("[v2][torrust_torrent_files] adding torrent files"); let _is_torrent_with_multiple_files = torrent_from_file.info.files.is_some(); let is_torrent_with_a_single_file = torrent_from_file.info.length.is_some(); if is_torrent_with_a_single_file { - // Only one file is being shared: + // The torrent contains only one file then: // - "path" is NULL // - "md5sum" can be NULL println!( - "[v2][torrust_torrent_files][one] adding torrent file {:?} with length {:?} ...", + "[v2][torrust_torrent_files][single-file-torrent] adding torrent file {:?} with length {:?} ...", &torrent_from_file.info.name, &torrent_from_file.info.length, ); @@ -350,7 +346,7 @@ async fn transfer_torrents( .await; println!( - "[v2][torrust_torrent_files][one] torrent file insert result: {:?}", + "[v2][torrust_torrent_files][single-file-torrent] torrent file insert result: {:?}", &file_id ); } else { @@ -359,7 +355,7 @@ async fn transfer_torrents( for file in files.iter() { println!( - "[v2][torrust_torrent_files][multiple] adding torrent file: {:?} ...", + "[v2][torrust_torrent_files][multiple-file-torrent] adding torrent file: {:?} ...", &file ); @@ -368,7 +364,7 @@ async fn transfer_torrents( .await; println!( - "[v2][torrust_torrent_files][multiple] torrent file insert result: {:?}", + "[v2][torrust_torrent_files][multiple-file-torrent] torrent file insert result: {:?}", &file_id ); } @@ -377,7 +373,7 @@ async fn transfer_torrents( // [v2] table torrust_torrent_info println!( - "[v2][torrust_torrent_info] adding the torrent info for torrent {:?} ...", + "[v2][torrust_torrent_info] adding the torrent info for torrent id {:?} ...", &torrent.torrent_id ); @@ -391,12 +387,12 @@ async fn transfer_torrents( // [v2] table torrust_torrent_announce_urls println!( - "[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent {:?} ...", + "[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id ); if torrent_from_file.announce.is_some() { - println!("[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent {:?} ...", &torrent.torrent_id); + println!("[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); let announce_url_id = dest_database .insert_torrent_announce_url( @@ -406,13 +402,13 @@ async fn transfer_torrents( .await; println!( - "[v2][torrust_torrent_announce_urls] torrent announce url insert result {:?} ...", + "[v2][torrust_torrent_announce_urls][announce] torrent announce url insert result {:?} ...", &announce_url_id ); } else if torrent_from_file.announce_list.is_some() { // BEP-0012. Multiple trackers. - println!("[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent {:?} ...", &torrent.torrent_id); + println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); // flatten the nested vec (this will however remove the) let announce_urls = torrent_from_file @@ -424,13 +420,13 @@ async fn transfer_torrents( .collect::>(); for tracker_url in announce_urls.iter() { - println!("[v2][torrust_torrent_announce_urls] adding the torrent announce url (from announce list) for torrent {:?} ...", &torrent.torrent_id); + println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); let announce_url_id = dest_database .insert_torrent_announce_url(torrent.torrent_id, tracker_url) .await; - println!("[v2][torrust_torrent_announce_urls] torrent announce url insert result {:?} ...", &announce_url_id); + println!("[v2][torrust_torrent_announce_urls][announce-list] torrent announce url insert result {:?} ...", &announce_url_id); } } } From 6bb4c53c7e84323fdaaca848cb9695ff7914f7ac Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 11:31:49 +0000 Subject: [PATCH 035/357] refactor: extract struct TorrentRecordV2 --- .../databases/sqlite_v1_0_0.rs | 34 +++--- .../databases/sqlite_v2_0_0.rs | 101 ++++++++++++------ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 36 ++----- 3 files changed, 95 insertions(+), 76 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index d763be6b..3328fd43 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -11,7 +11,7 @@ pub struct CategoryRecord { } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct UserRecord { +pub struct UserRecordV1 { pub user_id: i64, pub username: String, pub email: String, @@ -21,7 +21,7 @@ pub struct UserRecord { } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct TrackerKeyRecord { +pub struct TrackerKeyRecordV1 { pub key_id: i64, pub user_id: i64, pub key: String, @@ -29,7 +29,7 @@ pub struct TrackerKeyRecord { } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct TorrentRecord { +pub struct TorrentRecordV1 { pub torrent_id: i64, pub uploader: String, pub info_hash: String, @@ -43,7 +43,7 @@ pub struct TorrentRecord { } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct TorrentFileRecord { +pub struct TorrentFileRecordV1 { pub file_id: i64, pub torrent_uid: i64, pub number: i64, @@ -73,34 +73,36 @@ impl SqliteDatabaseV1_0_0 { .map_err(|_| DatabaseError::Error) } - pub async fn get_users(&self) -> Result, sqlx::Error> { - query_as::<_, UserRecord>("SELECT * FROM torrust_users ORDER BY user_id ASC") + pub async fn get_users(&self) -> Result, sqlx::Error> { + query_as::<_, UserRecordV1>("SELECT * FROM torrust_users ORDER BY user_id ASC") .fetch_all(&self.pool) .await } - pub async fn get_user_by_username(&self, username: &str) -> Result { - query_as::<_, UserRecord>("SELECT * FROM torrust_users WHERE username = ?") + pub async fn get_user_by_username(&self, username: &str) -> Result { + query_as::<_, UserRecordV1>("SELECT * FROM torrust_users WHERE username = ?") .bind(username) .fetch_one(&self.pool) .await } - pub async fn get_tracker_keys(&self) -> Result, sqlx::Error> { - query_as::<_, TrackerKeyRecord>("SELECT * FROM torrust_tracker_keys ORDER BY key_id ASC") + pub async fn get_tracker_keys(&self) -> Result, sqlx::Error> { + query_as::<_, TrackerKeyRecordV1>("SELECT * FROM torrust_tracker_keys ORDER BY key_id ASC") .fetch_all(&self.pool) .await } - pub async fn get_torrents(&self) -> Result, sqlx::Error> { - query_as::<_, TorrentRecord>("SELECT * FROM torrust_torrents ORDER BY torrent_id ASC") + pub async fn get_torrents(&self) -> Result, sqlx::Error> { + query_as::<_, TorrentRecordV1>("SELECT * FROM torrust_torrents ORDER BY torrent_id ASC") .fetch_all(&self.pool) .await } - pub async fn get_torrent_files(&self) -> Result, sqlx::Error> { - query_as::<_, TorrentFileRecord>("SELECT * FROM torrust_torrent_files ORDER BY file_id ASC") - .fetch_all(&self.pool) - .await + pub async fn get_torrent_files(&self) -> Result, sqlx::Error> { + query_as::<_, TorrentFileRecordV1>( + "SELECT * FROM torrust_torrent_files ORDER BY file_id ASC", + ) + .fetch_all(&self.pool) + .await } } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 186bb712..3cbf4020 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -1,17 +1,67 @@ +use chrono::{DateTime, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; use sqlx::{query, query_as, SqlitePool}; use crate::databases::database::DatabaseError; -use crate::models::torrent_file::TorrentFile; +use crate::models::torrent_file::{TorrentFile, TorrentInfo}; -use super::sqlite_v1_0_0::TorrentRecord; +use super::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct CategoryRecord { +pub struct CategoryRecordV2 { pub category_id: i64, pub name: String, } + +pub struct TorrentRecordV2 { + pub torrent_id: i64, + pub uploader_id: i64, + pub category_id: i64, + pub info_hash: String, + pub size: i64, + pub name: String, + pub pieces: String, + pub piece_length: i64, + pub private: bool, + pub root_hash: i64, + pub date_uploaded: String, +} + +impl TorrentRecordV2 { + pub fn from_v1_data( + torrent: &TorrentRecordV1, + torrent_info: &TorrentInfo, + uploader: &UserRecordV1, + private: bool, + ) -> Self { + Self { + torrent_id: torrent.torrent_id, + uploader_id: uploader.user_id, + category_id: torrent.category_id, + info_hash: torrent.info_hash.clone(), + size: torrent.file_size, + name: torrent_info.name.clone(), + pieces: torrent_info.get_pieces_as_string(), + piece_length: torrent_info.piece_length, + private, + root_hash: torrent_info.get_root_hash_as_i64(), + date_uploaded: convert_timestamp_to_datetime(torrent.upload_date), + } + } +} + +fn convert_timestamp_to_datetime(timestamp: i64) -> String { + // The expected format in database is: 2022-11-04 09:53:57 + // MySQL uses a DATETIME column and SQLite uses a TEXT column. + + let naive_datetime = NaiveDateTime::from_timestamp(timestamp, 0); + let datetime_again: DateTime = DateTime::from_utc(naive_datetime, Utc); + + // Format without timezone + datetime_again.format("%Y-%m-%d %H:%M:%S").to_string() +} + pub struct SqliteDatabaseV2_0_0 { pub pool: SqlitePool, } @@ -39,8 +89,8 @@ impl SqliteDatabaseV2_0_0 { .map_err(|_| DatabaseError::Error) } - pub async fn get_categories(&self) -> Result, DatabaseError> { - query_as::<_, CategoryRecord>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") + pub async fn get_categories(&self) -> Result, DatabaseError> { + query_as::<_, CategoryRecordV2>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") .fetch_all(&self.pool) .await .map_err(|_| DatabaseError::Error) @@ -133,20 +183,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } - pub async fn insert_torrent( - &self, - torrent_id: i64, - uploader_id: i64, - category_id: i64, - info_hash: &str, - size: i64, - name: &str, - pieces: &str, - piece_length: i64, - private: bool, - root_hash: i64, - date_uploaded: &str, - ) -> Result { + pub async fn insert_torrent(&self, torrent: &TorrentRecordV2) -> Result { query( " INSERT INTO torrust_torrents ( @@ -163,17 +200,17 @@ impl SqliteDatabaseV2_0_0 { date_uploaded ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) - .bind(torrent_id) - .bind(uploader_id) - .bind(category_id) - .bind(info_hash) - .bind(size) - .bind(name) - .bind(pieces) - .bind(piece_length) - .bind(private) - .bind(root_hash) - .bind(date_uploaded) + .bind(torrent.torrent_id) + .bind(torrent.uploader_id) + .bind(torrent.category_id) + .bind(torrent.info_hash.clone()) + .bind(torrent.size) + .bind(torrent.name.clone()) + .bind(torrent.pieces.clone()) + .bind(torrent.piece_length) + .bind(torrent.private) + .bind(torrent.root_hash) + .bind(torrent.date_uploaded.clone()) .execute(&self.pool) .await .map(|v| v.last_insert_rowid()) @@ -200,7 +237,7 @@ impl SqliteDatabaseV2_0_0 { pub async fn insert_torrent_file_for_torrent_with_multiple_files( &self, - torrent: &TorrentRecord, + torrent: &TorrentRecordV1, file: &TorrentFile, ) -> Result { query( @@ -216,7 +253,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } - pub async fn insert_torrent_info(&self, torrent: &TorrentRecord) -> Result { + pub async fn insert_torrent_info(&self, torrent: &TorrentRecordV1) -> Result { query( "INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, ?)", diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 4f96fb2f..28bdc87e 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -11,14 +11,15 @@ //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{ + SqliteDatabaseV2_0_0, TorrentRecordV2, +}; use crate::utils::parse_torrent::decode_torrent; use crate::{ models::torrent_file::Torrent, upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0, }; use chrono::prelude::{DateTime, Utc}; -use chrono::NaiveDateTime; use std::{error, fs}; use std::{sync::Arc, time::SystemTime}; @@ -287,23 +288,13 @@ async fn transfer_torrents( let torrent_from_file = read_torrent_from_file(&filepath).unwrap(); - let pieces = torrent_from_file.info.get_pieces_as_string(); - let root_hash = torrent_from_file.info.get_root_hash_as_i64(); - let id = dest_database - .insert_torrent( - torrent.torrent_id, - uploader.user_id, - torrent.category_id, - &torrent_from_file.info_hash(), - torrent.file_size, - &torrent_from_file.info.name, - &pieces, - torrent_from_file.info.piece_length, + .insert_torrent(&TorrentRecordV2::from_v1_data( + torrent, + &torrent_from_file.info, + &uploader, private, - root_hash, - &convert_timestamp_to_datetime(torrent.upload_date), - ) + )) .await .unwrap(); @@ -444,14 +435,3 @@ fn read_torrent_from_file(path: &str) -> Result> Err(e) => Err(e), } } - -fn convert_timestamp_to_datetime(timestamp: i64) -> String { - // The expected format in database is: 2022-11-04 09:53:57 - // MySQL uses a DATETIME column and SQLite uses a TEXT column. - - let naive_datetime = NaiveDateTime::from_timestamp(timestamp, 0); - let datetime_again: DateTime = DateTime::from_utc(naive_datetime, Utc); - - // Format without timezone - datetime_again.format("%Y-%m-%d %H:%M:%S").to_string() -} From 72dc1398878c7e175ec460c4d85564427d5518bf Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 11:36:30 +0000 Subject: [PATCH 036/357] refactor: reformat sql queries --- .../databases/sqlite_v2_0_0.rs | 59 ++++++++----------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 3cbf4020..3021b352 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -222,17 +222,13 @@ impl SqliteDatabaseV2_0_0 { md5sum: &Option, length: i64, ) -> Result { - query( - " - INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH) - VALUES (?, ?, ?)", - ) - .bind(md5sum) - .bind(torrent_id) - .bind(length) - .execute(&self.pool) - .await - .map(|v| v.last_insert_rowid()) + query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH) VALUES (?, ?, ?)") + .bind(md5sum) + .bind(torrent_id) + .bind(length) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) } pub async fn insert_torrent_file_for_torrent_with_multiple_files( @@ -240,9 +236,7 @@ impl SqliteDatabaseV2_0_0 { torrent: &TorrentRecordV1, file: &TorrentFile, ) -> Result { - query( - "INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH, PATH) - VALUES (?, ?, ?, ?)", + query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH, PATH) VALUES (?, ?, ?, ?)", ) .bind(file.md5sum.clone()) .bind(torrent.torrent_id) @@ -254,16 +248,13 @@ impl SqliteDatabaseV2_0_0 { } pub async fn insert_torrent_info(&self, torrent: &TorrentRecordV1) -> Result { - query( - "INSERT INTO torrust_torrent_info (torrent_id, title, description) - VALUES (?, ?, ?)", - ) - .bind(torrent.torrent_id) - .bind(torrent.title.clone()) - .bind(torrent.description.clone()) - .execute(&self.pool) - .await - .map(|v| v.last_insert_rowid()) + query("INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, ?)") + .bind(torrent.torrent_id) + .bind(torrent.title.clone()) + .bind(torrent.description.clone()) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) } pub async fn insert_torrent_announce_url( @@ -280,52 +271,52 @@ impl SqliteDatabaseV2_0_0 { } pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { - query("DELETE FROM torrust_categories;") + query("DELETE FROM torrust_categories") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_torrents;") + query("DELETE FROM torrust_torrents") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_tracker_keys;") + query("DELETE FROM torrust_tracker_keys") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_users;") + query("DELETE FROM torrust_users") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_user_authentication;") + query("DELETE FROM torrust_user_authentication") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_user_bans;") + query("DELETE FROM torrust_user_bans") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_user_invitations;") + query("DELETE FROM torrust_user_invitations") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_user_profiles;") + query("DELETE FROM torrust_user_profiles") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_torrents;") + query("DELETE FROM torrust_torrents") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_user_public_keys;") + query("DELETE FROM torrust_user_public_keys") .execute(&self.pool) .await .unwrap(); From 309e141662837af37b6c07a4526319cfd2f70652 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 11:53:16 +0000 Subject: [PATCH 037/357] fix: take torrent private flag from torrent file We were assuming "private" to be always true and we have to use the value inside the torrent file. --- .../from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs | 7 +++---- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 6 +----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 3021b352..21dc28ff 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -23,7 +23,7 @@ pub struct TorrentRecordV2 { pub name: String, pub pieces: String, pub piece_length: i64, - pub private: bool, + pub private: Option, pub root_hash: i64, pub date_uploaded: String, } @@ -33,7 +33,6 @@ impl TorrentRecordV2 { torrent: &TorrentRecordV1, torrent_info: &TorrentInfo, uploader: &UserRecordV1, - private: bool, ) -> Self { Self { torrent_id: torrent.torrent_id, @@ -44,7 +43,7 @@ impl TorrentRecordV2 { name: torrent_info.name.clone(), pieces: torrent_info.get_pieces_as_string(), piece_length: torrent_info.piece_length, - private, + private: torrent_info.private, root_hash: torrent_info.get_root_hash_as_i64(), date_uploaded: convert_timestamp_to_datetime(torrent.upload_date), } @@ -208,7 +207,7 @@ impl SqliteDatabaseV2_0_0 { .bind(torrent.name.clone()) .bind(torrent.pieces.clone()) .bind(torrent.piece_length) - .bind(torrent.private) + .bind(torrent.private.unwrap_or(0)) .bind(torrent.root_hash) .bind(torrent.date_uploaded.clone()) .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 28bdc87e..304a6fcb 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -268,9 +268,6 @@ async fn transfer_torrents( &torrent.torrent_id ); - // All torrents were public in version v1.0.0 - let private = false; - let uploader = source_database .get_user_by_username(&torrent.uploader) .await @@ -292,8 +289,7 @@ async fn transfer_torrents( .insert_torrent(&TorrentRecordV2::from_v1_data( torrent, &torrent_from_file.info, - &uploader, - private, + &uploader )) .await .unwrap(); From f620e05393f0461820b32300a926341c58292779 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 12:26:05 +0000 Subject: [PATCH 038/357] fix: [#56] announce list has precedence over announce From BEP-12: "In addition to the standard "announce" key, in the main area of the metadata file and not part of the "info" section, will be a new key, "announce-list". This key will refer to a list of lists of URLs, and will contain a list of tiers of announces. If the client is compatible with the multitracker specification, and if the "announce-list" key is present, the client will ignore the "announce" key and only use the URLs in "announce-list". --- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 304a6fcb..e9553392 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -289,7 +289,7 @@ async fn transfer_torrents( .insert_torrent(&TorrentRecordV2::from_v1_data( torrent, &torrent_from_file.info, - &uploader + &uploader, )) .await .unwrap(); @@ -378,21 +378,7 @@ async fn transfer_torrents( &torrent.torrent_id ); - if torrent_from_file.announce.is_some() { - println!("[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); - - let announce_url_id = dest_database - .insert_torrent_announce_url( - torrent.torrent_id, - &torrent_from_file.announce.unwrap(), - ) - .await; - - println!( - "[v2][torrust_torrent_announce_urls][announce] torrent announce url insert result {:?} ...", - &announce_url_id - ); - } else if torrent_from_file.announce_list.is_some() { + if torrent_from_file.announce_list.is_some() { // BEP-0012. Multiple trackers. println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); @@ -415,6 +401,20 @@ async fn transfer_torrents( println!("[v2][torrust_torrent_announce_urls][announce-list] torrent announce url insert result {:?} ...", &announce_url_id); } + } else if torrent_from_file.announce.is_some() { + println!("[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); + + let announce_url_id = dest_database + .insert_torrent_announce_url( + torrent.torrent_id, + &torrent_from_file.announce.unwrap(), + ) + .await; + + println!( + "[v2][torrust_torrent_announce_urls][announce] torrent announce url insert result {:?} ...", + &announce_url_id + ); } } println!("Torrents transferred"); From 693994fd084de726f0bcb10ada1db42978ebf99a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 12:49:57 +0000 Subject: [PATCH 039/357] feat: add new dependency text_colorizer It allows adding colors to text output in console commands. --- Cargo.lock | 32 ++++++++++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 33 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e65c2f14..0246f562 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,17 @@ dependencies = [ "num-traits 0.2.14", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.0.1" @@ -444,6 +455,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "colored" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" +dependencies = [ + "atty", + "lazy_static", + "winapi", +] + [[package]] name = "config" version = "0.11.0" @@ -2396,6 +2418,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "text-colorizer" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30f9b94bd367aacc3f62cd28668b10c7ae1784c7d27e223a1c21646221a9166" +dependencies = [ + "colored", +] + [[package]] name = "thiserror" version = "1.0.34" @@ -2610,6 +2641,7 @@ dependencies = [ "serde_json", "sha-1 0.10.0", "sqlx", + "text-colorizer", "tokio", "toml", "urlencoding", diff --git a/Cargo.toml b/Cargo.toml index 60206325..f16bfeeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,4 @@ lettre = { version = "0.10.0-rc.3", features = ["builder", "tokio1", "tokio1-rus sailfish = "0.4.0" regex = "1.6.0" pbkdf2 = "0.11.0" +text-colorizer = "1.0.0" From aabc3ef6398585e92aaeb9fd1d4a3f65e2b285ae Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 12:51:14 +0000 Subject: [PATCH 040/357] feat: the upgrader command takes args Removed hardocoded arguments. Now you can use the "upgrader" with: ``` cargo run --bin upgrade ./data_v2.db ./uploads ``` Where "./data_v2.db" is the newly generated DB and "./uploads" the folder where torrent files weere stored in version v1.0.0. --- src/bin/upgrade.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 56 +++++++++++++++++-- upgrades/from_v1_0_0_to_v2_0_0/README.md | 2 +- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index 15350d1d..1c5a27a3 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -1,6 +1,6 @@ //! Upgrade command. //! It updates the application from version v1.0.0 to v2.0.0. -//! You can execute it with: `cargo run --bin upgrade` +//! You can execute it with: `cargo run --bin upgrade ./data_v2.db ./uploads` use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::upgrade; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index e9553392..d084ede4 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -21,15 +21,54 @@ use crate::{ }; use chrono::prelude::{DateTime, Utc}; -use std::{error, fs}; +use std::{env, error, fs}; use std::{sync::Arc, time::SystemTime}; use crate::config::Configuration; +use text_colorizer::*; + +#[derive(Debug)] +struct Arguments { + database_file: String, // The new database + upload_path: String, // The relative dir where torrent files are stored +} + +fn print_usage() { + eprintln!( + "{} - migrates date from version v1.0.0 to v2.0.0. + + cargo run --bin upgrade TARGET_SLQLITE_FILE_PATH TORRENT_UPLOAD_DIR + + For example: + + cargo run --bin upgrade ./data_v2.db ./uploads + + ", + "Upgrader".green() + ); +} + +fn parse_args() -> Arguments { + let args: Vec = env::args().skip(1).collect(); + + if args.len() != 2 { + eprintln!( + "{} wrong number of arguments: expected 2, got {}", + "Error".red().bold(), + args.len() + ); + print_usage(); + } + + Arguments { + database_file: args[0].clone(), + upload_path: args[1].clone(), + } +} + pub async fn upgrade() { - // TODO: get from command arguments - let database_file = "data_v2.db".to_string(); // The new database - let upload_path = "./uploads".to_string(); // The relative dir where torrent files are stored + let args = parse_args(); let cfg = match Configuration::load_from_file().await { Ok(config) => Arc::new(config), @@ -44,7 +83,7 @@ pub async fn upgrade() { let source_database = current_db(&settings.database.connect_url).await; // Get connection to destiny database - let dest_database = new_db(&database_file).await; + let dest_database = new_db(&args.database_file).await; println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); @@ -53,7 +92,12 @@ pub async fn upgrade() { transfer_categories(source_database.clone(), dest_database.clone()).await; transfer_user_data(source_database.clone(), dest_database.clone()).await; transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; - transfer_torrents(source_database.clone(), dest_database.clone(), &upload_path).await; + transfer_torrents( + source_database.clone(), + dest_database.clone(), + &args.upload_path, + ) + .await; } async fn current_db(connect_url: &str) -> Arc { diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index e635f8a1..c5ca1601 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -7,7 +7,7 @@ To upgrade from version `v1.0.0` to `v2.0.0` you have to follow these steps: - Back up your current database and the `uploads` folder. You can find which database and upload folder are you using in the `Config.toml` file in the root folder of your installation. - Set up a local environment exactly as you have it in production with your production data (DB and torrents folder). - Run the application locally with: `cargo run`. -- Execute the upgrader command: `cargo run --bin upgrade` +- Execute the upgrader command: `cargo run --bin upgrade ./data_v2.db ./uploads` - A new SQLite file should have been created in the root folder: `data_v2.db` - Stop the running application and change the DB configuration to use the newly generated configuration: From 217fae2a6672dfdc8e1b42b6d36bb5778b6e5479 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 13:16:19 +0000 Subject: [PATCH 041/357] feat: [#56] take source DB in upgrader command from args Instead of reading the current configuration. --- src/bin/upgrade.rs | 6 +-- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 44 +++++++++---------- upgrades/from_v1_0_0_to_v2_0_0/README.md | 2 +- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index 1c5a27a3..874f0fad 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -1,10 +1,10 @@ //! Upgrade command. //! It updates the application from version v1.0.0 to v2.0.0. -//! You can execute it with: `cargo run --bin upgrade ./data_v2.db ./uploads` +//! You can execute it with: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads` -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::upgrade; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run_upgrader; #[actix_web::main] async fn main() { - upgrade().await; + run_upgrader().await; } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index d084ede4..6d9d5493 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -24,25 +24,26 @@ use chrono::prelude::{DateTime, Utc}; use std::{env, error, fs}; use std::{sync::Arc, time::SystemTime}; -use crate::config::Configuration; - use text_colorizer::*; +const NUMBER_OF_ARGUMENTS: i64 = 3; + #[derive(Debug)] -struct Arguments { - database_file: String, // The new database - upload_path: String, // The relative dir where torrent files are stored +pub struct Arguments { + source_database_file: String, // The source database in version v1.0.0 we want to migrate + destiny_database_file: String, // The new migrated database in version v2.0.0 + upload_path: String, // The relative dir where torrent files are stored } fn print_usage() { eprintln!( "{} - migrates date from version v1.0.0 to v2.0.0. - cargo run --bin upgrade TARGET_SLQLITE_FILE_PATH TORRENT_UPLOAD_DIR + cargo run --bin upgrade SOURCE_DB_FILE DESTINY_DB_FILE TORRENT_UPLOAD_DIR For example: - cargo run --bin upgrade ./data_v2.db ./uploads + cargo run --bin upgrade ./data.db ./data_v2.db ./uploads ", "Upgrader".green() @@ -52,38 +53,33 @@ fn print_usage() { fn parse_args() -> Arguments { let args: Vec = env::args().skip(1).collect(); - if args.len() != 2 { + if args.len() != 3 { eprintln!( - "{} wrong number of arguments: expected 2, got {}", + "{} wrong number of arguments: expected {}, got {}", "Error".red().bold(), + NUMBER_OF_ARGUMENTS, args.len() ); print_usage(); } Arguments { - database_file: args[0].clone(), - upload_path: args[1].clone(), + source_database_file: args[0].clone(), + destiny_database_file: args[1].clone(), + upload_path: args[2].clone(), } } -pub async fn upgrade() { - let args = parse_args(); - - let cfg = match Configuration::load_from_file().await { - Ok(config) => Arc::new(config), - Err(error) => { - panic!("{}", error) - } - }; - - let settings = cfg.settings.read().await; +pub async fn run_upgrader() { + upgrade(&parse_args()).await +} +pub async fn upgrade(args: &Arguments) { // Get connection to source database (current DB in settings) - let source_database = current_db(&settings.database.connect_url).await; + let source_database = current_db(&args.source_database_file).await; // Get connection to destiny database - let dest_database = new_db(&args.database_file).await; + let dest_database = new_db(&args.destiny_database_file).await; println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index c5ca1601..cd2c1c11 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -7,7 +7,7 @@ To upgrade from version `v1.0.0` to `v2.0.0` you have to follow these steps: - Back up your current database and the `uploads` folder. You can find which database and upload folder are you using in the `Config.toml` file in the root folder of your installation. - Set up a local environment exactly as you have it in production with your production data (DB and torrents folder). - Run the application locally with: `cargo run`. -- Execute the upgrader command: `cargo run --bin upgrade ./data_v2.db ./uploads` +- Execute the upgrader command: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads` - A new SQLite file should have been created in the root folder: `data_v2.db` - Stop the running application and change the DB configuration to use the newly generated configuration: From 7f0a7eaae8fc10cb4179a1c3269065ba693d6fa3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 13:48:22 +0000 Subject: [PATCH 042/357] fix: open source db in read-only mode in upgarder --- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 6d9d5493..55a8821d 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -30,9 +30,9 @@ const NUMBER_OF_ARGUMENTS: i64 = 3; #[derive(Debug)] pub struct Arguments { - source_database_file: String, // The source database in version v1.0.0 we want to migrate - destiny_database_file: String, // The new migrated database in version v2.0.0 - upload_path: String, // The relative dir where torrent files are stored + pub source_database_file: String, // The source database in version v1.0.0 we want to migrate + pub destiny_database_file: String, // The new migrated database in version v2.0.0 + pub upload_path: String, // The relative dir where torrent files are stored } fn print_usage() { @@ -96,8 +96,9 @@ pub async fn upgrade(args: &Arguments) { .await; } -async fn current_db(connect_url: &str) -> Arc { - Arc::new(SqliteDatabaseV1_0_0::new(connect_url).await) +async fn current_db(db_filename: &str) -> Arc { + let source_database_connect_url = format!("sqlite://{}?mode=ro", db_filename); + Arc::new(SqliteDatabaseV1_0_0::new(&source_database_connect_url).await) } async fn new_db(db_filename: &str) -> Arc { From 44927e5ba8b349d6538ae13b806e95002a9f8087 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 17:12:35 +0000 Subject: [PATCH 043/357] test: [#56] WIP. scaffolding to test upgrader command --- tests/mod.rs | 1 + .../20210831113004_torrust_users.sql | 7 ++ .../20210904135524_torrust_tracker_keys.sql | 7 ++ .../20210905160623_torrust_categories.sql | 7 ++ .../20210907083424_torrust_torrent_files.sql | 8 ++ .../20211208143338_torrust_users.sql | 2 + .../20220308083424_torrust_torrents.sql | 14 ++++ .../20220308170028_torrust_categories.sql | 2 + tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 1 + .../from_v1_0_0_to_v2_0_0/output/.gitignore | 1 + tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs | 77 +++++++++++++++++++ tests/upgrades/mod.rs | 1 + 12 files changed, 128 insertions(+) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210831113004_torrust_users.sql create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210904135524_torrust_tracker_keys.sql create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210905160623_torrust_categories.sql create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210907083424_torrust_torrent_files.sql create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20211208143338_torrust_users.sql create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308083424_torrust_torrents.sql create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308170028_torrust_categories.sql create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/output/.gitignore create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs create mode 100644 tests/upgrades/mod.rs diff --git a/tests/mod.rs b/tests/mod.rs index 22adeb6d..27bea3bd 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1 +1,2 @@ mod databases; +pub mod upgrades; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210831113004_torrust_users.sql b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210831113004_torrust_users.sql new file mode 100644 index 00000000..c535dfb9 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210831113004_torrust_users.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS torrust_users ( + user_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + username VARCHAR(32) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + password TEXT NOT NULL +) \ No newline at end of file diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210904135524_torrust_tracker_keys.sql b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210904135524_torrust_tracker_keys.sql new file mode 100644 index 00000000..ef6f6865 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210904135524_torrust_tracker_keys.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS torrust_tracker_keys ( + key_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + key VARCHAR(32) NOT NULL, + valid_until INT(10) NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) +) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210905160623_torrust_categories.sql b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210905160623_torrust_categories.sql new file mode 100644 index 00000000..c88abfe2 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210905160623_torrust_categories.sql @@ -0,0 +1,7 @@ +CREATE TABLE torrust_categories ( + category_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name VARCHAR(64) NOT NULL UNIQUE +); + +INSERT INTO torrust_categories (name) VALUES +('movies'), ('tv shows'), ('games'), ('music'), ('software'); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210907083424_torrust_torrent_files.sql b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210907083424_torrust_torrent_files.sql new file mode 100644 index 00000000..aeb3135a --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20210907083424_torrust_torrent_files.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_files ( + file_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + torrent_id INTEGER NOT NULL, + number INTEGER NOT NULL, + path VARCHAR(255) NOT NULL, + length INTEGER NOT NULL, + FOREIGN KEY(torrent_id) REFERENCES torrust_torrents(torrent_id) +) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20211208143338_torrust_users.sql b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20211208143338_torrust_users.sql new file mode 100644 index 00000000..0b574c69 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20211208143338_torrust_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE torrust_users +ADD COLUMN administrator BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308083424_torrust_torrents.sql b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308083424_torrust_torrents.sql new file mode 100644 index 00000000..413539a4 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308083424_torrust_torrents.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS torrust_torrents ( + torrent_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + uploader VARCHAR(32) NOT NULL, + info_hash VARCHAR(20) UNIQUE NOT NULL, + title VARCHAR(256) UNIQUE NOT NULL, + category_id INTEGER NOT NULL, + description TEXT, + upload_date INT(10) NOT NULL, + file_size BIGINT NOT NULL, + seeders INTEGER NOT NULL, + leechers INTEGER NOT NULL, + FOREIGN KEY(uploader) REFERENCES torrust_users(username) ON DELETE CASCADE, + FOREIGN KEY(category_id) REFERENCES torrust_categories(category_id) ON DELETE CASCADE +) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308170028_torrust_categories.sql b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308170028_torrust_categories.sql new file mode 100644 index 00000000..b786dcd2 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/database/v1.0.0/migrations/20220308170028_torrust_categories.sql @@ -0,0 +1,2 @@ +ALTER TABLE torrust_categories +ADD COLUMN icon VARCHAR(32); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs new file mode 100644 index 00000000..3023529a --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -0,0 +1 @@ +pub mod tests; \ No newline at end of file diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/output/.gitignore b/tests/upgrades/from_v1_0_0_to_v2_0_0/output/.gitignore new file mode 100644 index 00000000..3997bead --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/output/.gitignore @@ -0,0 +1 @@ +*.db \ No newline at end of file diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs new file mode 100644 index 00000000..3ab90e32 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs @@ -0,0 +1,77 @@ +//! You can run this test with: +//! +//! ```text +//! cargo test upgrade_data_from_version_v1_0_0_to_v2_0_0 -- --nocapture +//! ``` +use std::fs; +use std::sync::Arc; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{upgrade, Arguments}; + +#[tokio::test] +async fn upgrade_data_from_version_v1_0_0_to_v2_0_0() { + /* TODO: + * - Insert data: user, tracker key and torrent + * - Assertions + */ + let fixtures_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/".to_string(); + let debug_output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); + + let source_database_file = format!("{}source.db", debug_output_dir); + let destiny_database_file = format!("{}destiny.db", debug_output_dir); + + // TODO: use a unique temporary dir + fs::remove_file(&source_database_file).expect("Can't remove source DB file."); + fs::remove_file(&destiny_database_file).expect("Can't remove destiny DB file."); + + let source_database = source_db_connection(&source_database_file).await; + + migrate(source_database.clone(), &fixtures_dir).await; + + let args = Arguments { + source_database_file, + destiny_database_file, + upload_path: format!("{}uploads/", fixtures_dir), + }; + + upgrade(&args).await; +} + +async fn source_db_connection(source_database_file: &str) -> Arc { + let source_database_connect_url = format!("sqlite://{}?mode=rwc", source_database_file); + Arc::new(SqliteDatabaseV1_0_0::new(&source_database_connect_url).await) +} + +/// Execute migrations for database in version v1.0.0 +async fn migrate(source_database: Arc, fixtures_dir: &str) { + let migrations_dir = format!("{}database/v1.0.0/migrations/", fixtures_dir); + + let migrations = vec![ + "20210831113004_torrust_users.sql", + "20210904135524_torrust_tracker_keys.sql", + "20210905160623_torrust_categories.sql", + "20210907083424_torrust_torrent_files.sql", + "20211208143338_torrust_users.sql", + "20220308083424_torrust_torrents.sql", + "20220308170028_torrust_categories.sql", + ]; + + for migration_file_name in &migrations { + let migration_file_path = format!("{}{}", &migrations_dir, &migration_file_name); + run_migration_from_file(source_database.clone(), &migration_file_path).await; + } +} + +async fn run_migration_from_file( + source_database: Arc, + migration_file_path: &str, +) { + println!("Executing migration: {:?}", migration_file_path); + + let sql = + fs::read_to_string(migration_file_path).expect("Should have been able to read the file"); + + let res = sqlx::query(&sql).execute(&source_database.pool).await; + + println!("Migration result {:?}", res); +} diff --git a/tests/upgrades/mod.rs b/tests/upgrades/mod.rs new file mode 100644 index 00000000..736d54f6 --- /dev/null +++ b/tests/upgrades/mod.rs @@ -0,0 +1 @@ +pub mod from_v1_0_0_to_v2_0_0; \ No newline at end of file From 6188b101d021ded52f9d4c900f6ff62b6cede4db Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 18:33:03 +0000 Subject: [PATCH 044/357] refactor: extract mod sqlite_v1_0_0 in tests --- tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 3 +- .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 53 +++++++++++++++++++ tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs | 41 ++------------ 3 files changed, 58 insertions(+), 39 deletions(-) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs index 3023529a..bb1d6613 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -1 +1,2 @@ -pub mod tests; \ No newline at end of file +pub mod sqlite_v1_0_0; +pub mod tests; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs new file mode 100644 index 00000000..1904df6c --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -0,0 +1,53 @@ +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::SqlitePool; +use std::fs; + +pub struct SqliteDatabaseV1_0_0 { + pub pool: SqlitePool, +} + +impl SqliteDatabaseV1_0_0 { + pub async fn db_connection(source_database_file: &str) -> Self { + let source_database_connect_url = format!("sqlite://{}?mode=rwc", source_database_file); + SqliteDatabaseV1_0_0::new(&source_database_connect_url).await + } + + pub async fn new(database_url: &str) -> Self { + let db = SqlitePoolOptions::new() + .connect(database_url) + .await + .expect("Unable to create database pool."); + Self { pool: db } + } + + /// Execute migrations for database in version v1.0.0 + pub async fn migrate(&self, fixtures_dir: &str) { + let migrations_dir = format!("{}database/v1.0.0/migrations/", fixtures_dir); + + let migrations = vec![ + "20210831113004_torrust_users.sql", + "20210904135524_torrust_tracker_keys.sql", + "20210905160623_torrust_categories.sql", + "20210907083424_torrust_torrent_files.sql", + "20211208143338_torrust_users.sql", + "20220308083424_torrust_torrents.sql", + "20220308170028_torrust_categories.sql", + ]; + + for migration_file_name in &migrations { + let migration_file_path = format!("{}{}", &migrations_dir, &migration_file_name); + self.run_migration_from_file(&migration_file_path).await; + } + } + + async fn run_migration_from_file(&self, migration_file_path: &str) { + println!("Executing migration: {:?}", migration_file_path); + + let sql = fs::read_to_string(migration_file_path) + .expect("Should have been able to read the file"); + + let res = sqlx::query(&sql).execute(&self.pool).await; + + println!("Migration result {:?}", res); + } +} diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs index 3ab90e32..79cfc866 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs @@ -3,9 +3,9 @@ //! ```text //! cargo test upgrade_data_from_version_v1_0_0_to_v2_0_0 -- --nocapture //! ``` +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use std::fs; use std::sync::Arc; -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{upgrade, Arguments}; #[tokio::test] @@ -26,7 +26,7 @@ async fn upgrade_data_from_version_v1_0_0_to_v2_0_0() { let source_database = source_db_connection(&source_database_file).await; - migrate(source_database.clone(), &fixtures_dir).await; + source_database.migrate(&fixtures_dir).await; let args = Arguments { source_database_file, @@ -38,40 +38,5 @@ async fn upgrade_data_from_version_v1_0_0_to_v2_0_0() { } async fn source_db_connection(source_database_file: &str) -> Arc { - let source_database_connect_url = format!("sqlite://{}?mode=rwc", source_database_file); - Arc::new(SqliteDatabaseV1_0_0::new(&source_database_connect_url).await) -} - -/// Execute migrations for database in version v1.0.0 -async fn migrate(source_database: Arc, fixtures_dir: &str) { - let migrations_dir = format!("{}database/v1.0.0/migrations/", fixtures_dir); - - let migrations = vec![ - "20210831113004_torrust_users.sql", - "20210904135524_torrust_tracker_keys.sql", - "20210905160623_torrust_categories.sql", - "20210907083424_torrust_torrent_files.sql", - "20211208143338_torrust_users.sql", - "20220308083424_torrust_torrents.sql", - "20220308170028_torrust_categories.sql", - ]; - - for migration_file_name in &migrations { - let migration_file_path = format!("{}{}", &migrations_dir, &migration_file_name); - run_migration_from_file(source_database.clone(), &migration_file_path).await; - } -} - -async fn run_migration_from_file( - source_database: Arc, - migration_file_path: &str, -) { - println!("Executing migration: {:?}", migration_file_path); - - let sql = - fs::read_to_string(migration_file_path).expect("Should have been able to read the file"); - - let res = sqlx::query(&sql).execute(&source_database.pool).await; - - println!("Migration result {:?}", res); + Arc::new(SqliteDatabaseV1_0_0::db_connection(&source_database_file).await) } From f9931077ce9f6fde38e86af1fc003022113a614a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Nov 2022 19:29:08 +0000 Subject: [PATCH 045/357] tests: [#56] for users table in upgrader --- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 19 ++- tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 1 + .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 23 +++- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 37 +++++ tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs | 130 +++++++++++++++--- 5 files changed, 184 insertions(+), 26 deletions(-) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 55a8821d..bf3754fe 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -71,10 +71,11 @@ fn parse_args() -> Arguments { } pub async fn run_upgrader() { - upgrade(&parse_args()).await + let now = datetime_iso_8601(); + upgrade(&parse_args(), &now).await; } -pub async fn upgrade(args: &Arguments) { +pub async fn upgrade(args: &Arguments, date_imported: &str) { // Get connection to source database (current DB in settings) let source_database = current_db(&args.source_database_file).await; @@ -86,7 +87,12 @@ pub async fn upgrade(args: &Arguments) { migrate_destiny_database(dest_database.clone()).await; reset_destiny_database(dest_database.clone()).await; transfer_categories(source_database.clone(), dest_database.clone()).await; - transfer_user_data(source_database.clone(), dest_database.clone()).await; + transfer_user_data( + source_database.clone(), + dest_database.clone(), + date_imported, + ) + .await; transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; transfer_torrents( source_database.clone(), @@ -158,6 +164,7 @@ async fn transfer_categories( async fn transfer_user_data( source_database: Arc, dest_database: Arc, + date_imported: &str, ) { println!("Transferring users ..."); @@ -173,8 +180,6 @@ async fn transfer_user_data( &user.username, &user.user_id ); - let date_imported = today_iso8601(); - let id = dest_database .insert_imported_user(user.user_id, &date_imported, user.administrator) .await @@ -238,7 +243,9 @@ async fn transfer_user_data( } } -fn today_iso8601() -> String { +/// Current datetime in ISO8601 without time zone. +/// For example: 2022-11-10 10:35:15 +pub fn datetime_iso_8601() -> String { let dt: DateTime = SystemTime::now().into(); format!("{}", dt.format("%Y-%m-%d %H:%M:%S")) } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs index bb1d6613..0a1f301b 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -1,2 +1,3 @@ pub mod sqlite_v1_0_0; +pub mod sqlite_v2_0_0; pub mod tests; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index 1904df6c..6da98170 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -1,15 +1,16 @@ use sqlx::sqlite::SqlitePoolOptions; -use sqlx::SqlitePool; +use sqlx::{query, SqlitePool}; use std::fs; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::UserRecordV1; pub struct SqliteDatabaseV1_0_0 { pub pool: SqlitePool, } impl SqliteDatabaseV1_0_0 { - pub async fn db_connection(source_database_file: &str) -> Self { - let source_database_connect_url = format!("sqlite://{}?mode=rwc", source_database_file); - SqliteDatabaseV1_0_0::new(&source_database_connect_url).await + pub async fn db_connection(database_file: &str) -> Self { + let connect_url = format!("sqlite://{}?mode=rwc", database_file); + Self::new(&connect_url).await } pub async fn new(database_url: &str) -> Self { @@ -24,6 +25,7 @@ impl SqliteDatabaseV1_0_0 { pub async fn migrate(&self, fixtures_dir: &str) { let migrations_dir = format!("{}database/v1.0.0/migrations/", fixtures_dir); + // TODO: read files from dir let migrations = vec![ "20210831113004_torrust_users.sql", "20210904135524_torrust_tracker_keys.sql", @@ -50,4 +52,17 @@ impl SqliteDatabaseV1_0_0 { println!("Migration result {:?}", res); } + + pub async fn insert_user(&self, user: &UserRecordV1) -> Result { + query("INSERT INTO torrust_users (user_id, username, email, email_verified, password, administrator) VALUES (?, ?, ?, ?, ?, ?)") + .bind(user.user_id) + .bind(user.username.clone()) + .bind(user.email.clone()) + .bind(user.email_verified) + .bind(user.password.clone()) + .bind(user.administrator) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs new file mode 100644 index 00000000..ba6f4831 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::{query_as, SqlitePool}; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserRecordV2 { + pub user_id: i64, + pub date_registered: Option, + pub date_imported: Option, + pub administrator: bool, +} + +pub struct SqliteDatabaseV2_0_0 { + pub pool: SqlitePool, +} + +impl SqliteDatabaseV2_0_0 { + pub async fn db_connection(database_file: &str) -> Self { + let connect_url = format!("sqlite://{}?mode=rwc", database_file); + Self::new(&connect_url).await + } + + pub async fn new(database_url: &str) -> Self { + let db = SqlitePoolOptions::new() + .connect(database_url) + .await + .expect("Unable to create database pool."); + Self { pool: db } + } + + pub async fn get_user(&self, user_id: i64) -> Result { + query_as::<_, UserRecordV2>("SELECT * FROM torrust_users WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + } +} diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs index 79cfc866..e0f5f3bc 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs @@ -4,39 +4,137 @@ //! cargo test upgrade_data_from_version_v1_0_0_to_v2_0_0 -- --nocapture //! ``` use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHasher}; +use rand_core::OsRng; use std::fs; use std::sync::Arc; -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{upgrade, Arguments}; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::UserRecordV1; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{ + datetime_iso_8601, upgrade, Arguments, +}; #[tokio::test] async fn upgrade_data_from_version_v1_0_0_to_v2_0_0() { - /* TODO: - * - Insert data: user, tracker key and torrent - * - Assertions - */ + // Directories let fixtures_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/".to_string(); - let debug_output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); + let output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); - let source_database_file = format!("{}source.db", debug_output_dir); - let destiny_database_file = format!("{}destiny.db", debug_output_dir); - - // TODO: use a unique temporary dir - fs::remove_file(&source_database_file).expect("Can't remove source DB file."); - fs::remove_file(&destiny_database_file).expect("Can't remove destiny DB file."); + // Files + let source_database_file = format!("{}source.db", output_dir); + let destiny_database_file = format!("{}destiny.db", output_dir); + // Set up clean database + reset_databases(&source_database_file, &destiny_database_file); let source_database = source_db_connection(&source_database_file).await; - source_database.migrate(&fixtures_dir).await; + // Load data into database v1 + + // `torrust_users` table + + let user = UserRecordV1 { + user_id: 1, + username: "user01".to_string(), + email: "user01@torrust.com".to_string(), + email_verified: true, + password: hashed_valid_password(), + administrator: true, + }; + let user_id = source_database.insert_user(&user).await.unwrap(); + + // `torrust_tracker_keys` table + + // TODO + + // `torrust_torrents` table + + // TODO + + // Run the upgrader let args = Arguments { - source_database_file, - destiny_database_file, + source_database_file: source_database_file.clone(), + destiny_database_file: destiny_database_file.clone(), upload_path: format!("{}uploads/", fixtures_dir), }; + let now = datetime_iso_8601(); + upgrade(&args, &now).await; + + // Assertions in database v2 + + let destiny_database = destiny_db_connection(&destiny_database_file).await; + + // `torrust_users` table + + let imported_user = destiny_database.get_user(user_id).await.unwrap(); + + assert_eq!(imported_user.user_id, user.user_id); + assert!(imported_user.date_registered.is_none()); + assert_eq!(imported_user.date_imported.unwrap(), now); + assert_eq!(imported_user.administrator, user.administrator); + + // `torrust_user_authentication` table + + // TODO + + // `torrust_user_profiles` table + + // TODO + + // `torrust_tracker_keys` table - upgrade(&args).await; + // TODO + + // `torrust_torrents` table + + // TODO + + // `torrust_torrent_files` table + + // TODO + + // `torrust_torrent_info` table + + // TODO + + // `torrust_torrent_announce_urls` table + + // TODO } async fn source_db_connection(source_database_file: &str) -> Arc { Arc::new(SqliteDatabaseV1_0_0::db_connection(&source_database_file).await) } + +async fn destiny_db_connection(destiny_database_file: &str) -> Arc { + Arc::new(SqliteDatabaseV2_0_0::db_connection(&destiny_database_file).await) +} + +/// Reset databases from previous executions +fn reset_databases(source_database_file: &str, destiny_database_file: &str) { + // TODO: use a unique temporary dir + fs::remove_file(&source_database_file).expect("Can't remove source DB file."); + fs::remove_file(&destiny_database_file).expect("Can't remove destiny DB file."); +} + +fn hashed_valid_password() -> String { + hash_password(&valid_password()) +} + +fn valid_password() -> String { + "123456".to_string() +} + +fn hash_password(plain_password: &str) -> String { + let salt = SaltString::generate(&mut OsRng); + + // Argon2 with default params (Argon2id v19) + let argon2 = Argon2::default(); + + // Hash password to PHC string ($argon2id$v=19$...) + argon2 + .hash_password(plain_password.as_bytes(), &salt) + .unwrap() + .to_string() +} From 5d0def2943c13da54b4095d4430ed3f0d0f4f442 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 12:05:55 +0000 Subject: [PATCH 046/357] refactor: [#56] tests for upgrader Extract different testers for every type of data transferred. --- tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 3 +- .../from_v1_0_0_to_v2_0_0/testers/mod.rs | 1 + .../testers/user_data_tester.rs | 91 +++++++++++++++++++ .../{tests.rs => upgrader.rs} | 66 ++++---------- 4 files changed, 113 insertions(+), 48 deletions(-) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs rename tests/upgrades/from_v1_0_0_to_v2_0_0/{tests.rs => upgrader.rs} (62%) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs index 0a1f301b..7a5e3bb7 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -1,3 +1,4 @@ pub mod sqlite_v1_0_0; pub mod sqlite_v2_0_0; -pub mod tests; +pub mod testers; +pub mod upgrader; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs new file mode 100644 index 00000000..85968bfd --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs @@ -0,0 +1 @@ +pub mod user_data_tester; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs new file mode 100644 index 00000000..a83b8077 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs @@ -0,0 +1,91 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHasher}; +use rand_core::OsRng; +use std::sync::Arc; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::UserRecordV1; + +pub struct UserDataTester { + source_database: Arc, + destiny_database: Arc, + execution_time: String, + test_data: TestData, +} + +pub struct TestData { + pub user: UserRecordV1, +} + +impl UserDataTester { + pub fn new( + source_database: Arc, + destiny_database: Arc, + execution_time: &str, + ) -> Self { + let user = UserRecordV1 { + user_id: 1, + username: "user01".to_string(), + email: "user01@torrust.com".to_string(), + email_verified: true, + password: hashed_valid_password(), + administrator: true, + }; + + Self { + source_database, + destiny_database, + execution_time: execution_time.to_owned(), + test_data: TestData { user }, + } + } + + pub async fn load_data_into_source_db(&self) { + self.source_database + .insert_user(&self.test_data.user) + .await + .unwrap(); + } + + pub async fn assert(&self) { + self.assert_user().await; + } + + /// Table `torrust_users` + async fn assert_user(&self) { + let imported_user = self + .destiny_database + .get_user(self.test_data.user.user_id) + .await + .unwrap(); + + assert_eq!(imported_user.user_id, self.test_data.user.user_id); + assert!(imported_user.date_registered.is_none()); + assert_eq!(imported_user.date_imported.unwrap(), self.execution_time); + assert_eq!( + imported_user.administrator, + self.test_data.user.administrator + ); + } +} + +fn hashed_valid_password() -> String { + hash_password(&valid_password()) +} + +fn valid_password() -> String { + "123456".to_string() +} + +fn hash_password(plain_password: &str) -> String { + let salt = SaltString::generate(&mut OsRng); + + // Argon2 with default params (Argon2id v19) + let argon2 = Argon2::default(); + + // Hash password to PHC string ($argon2id$v=19$...) + argon2 + .hash_password(plain_password.as_bytes(), &salt) + .unwrap() + .to_string() +} diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs similarity index 62% rename from tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs rename to tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index e0f5f3bc..4e9d4228 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/tests.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -1,22 +1,19 @@ //! You can run this test with: //! //! ```text -//! cargo test upgrade_data_from_version_v1_0_0_to_v2_0_0 -- --nocapture +//! cargo test upgrades_data_from_version_v1_0_0_to_v2_0_0 -- --nocapture //! ``` use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -use argon2::password_hash::SaltString; -use argon2::{Argon2, PasswordHasher}; -use rand_core::OsRng; +use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_data_tester::UserDataTester; use std::fs; use std::sync::Arc; -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::UserRecordV1; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{ datetime_iso_8601, upgrade, Arguments, }; #[tokio::test] -async fn upgrade_data_from_version_v1_0_0_to_v2_0_0() { +async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // Directories let fixtures_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/".to_string(); let output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); @@ -25,24 +22,28 @@ async fn upgrade_data_from_version_v1_0_0_to_v2_0_0() { let source_database_file = format!("{}source.db", output_dir); let destiny_database_file = format!("{}destiny.db", output_dir); - // Set up clean database + // Set up clean source database reset_databases(&source_database_file, &destiny_database_file); let source_database = source_db_connection(&source_database_file).await; source_database.migrate(&fixtures_dir).await; + // Set up connection for the destiny database + let destiny_database = destiny_db_connection(&destiny_database_file).await; + + // The datetime when the upgrader is executed + let execution_time = datetime_iso_8601(); + // Load data into database v1 // `torrust_users` table - let user = UserRecordV1 { - user_id: 1, - username: "user01".to_string(), - email: "user01@torrust.com".to_string(), - email_verified: true, - password: hashed_valid_password(), - administrator: true, - }; - let user_id = source_database.insert_user(&user).await.unwrap(); + let user_data_tester = UserDataTester::new( + source_database.clone(), + destiny_database.clone(), + &execution_time, + ); + + user_data_tester.load_data_into_source_db().await; // `torrust_tracker_keys` table @@ -58,21 +59,13 @@ async fn upgrade_data_from_version_v1_0_0_to_v2_0_0() { destiny_database_file: destiny_database_file.clone(), upload_path: format!("{}uploads/", fixtures_dir), }; - let now = datetime_iso_8601(); - upgrade(&args, &now).await; + upgrade(&args, &execution_time).await; // Assertions in database v2 - let destiny_database = destiny_db_connection(&destiny_database_file).await; - // `torrust_users` table - let imported_user = destiny_database.get_user(user_id).await.unwrap(); - - assert_eq!(imported_user.user_id, user.user_id); - assert!(imported_user.date_registered.is_none()); - assert_eq!(imported_user.date_imported.unwrap(), now); - assert_eq!(imported_user.administrator, user.administrator); + user_data_tester.assert().await; // `torrust_user_authentication` table @@ -117,24 +110,3 @@ fn reset_databases(source_database_file: &str, destiny_database_file: &str) { fs::remove_file(&source_database_file).expect("Can't remove source DB file."); fs::remove_file(&destiny_database_file).expect("Can't remove destiny DB file."); } - -fn hashed_valid_password() -> String { - hash_password(&valid_password()) -} - -fn valid_password() -> String { - "123456".to_string() -} - -fn hash_password(plain_password: &str) -> String { - let salt = SaltString::generate(&mut OsRng); - - // Argon2 with default params (Argon2id v19) - let argon2 = Argon2::default(); - - // Hash password to PHC string ($argon2id$v=19$...) - argon2 - .hash_password(plain_password.as_bytes(), &salt) - .unwrap() - .to_string() -} From 0a58b6cbe6b1d64ab5615641c5fc853209176a21 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 12:15:00 +0000 Subject: [PATCH 047/357] fix: [#56] bio and avatar is user profile should be NULL for imported users --- .../from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs | 6 +----- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 21dc28ff..b7d1a570 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -136,17 +136,13 @@ impl SqliteDatabaseV2_0_0 { user_id: i64, username: &str, email: &str, - email_verified: bool, - bio: &str, - avatar: &str, + email_verified: bool ) -> Result { query("INSERT INTO torrust_user_profiles (user_id, username, email, email_verified, bio, avatar) VALUES (?, ?, ?, ?, ?, ?)") .bind(user_id) .bind(username) .bind(email) .bind(email_verified) - .bind(bio) - .bind(avatar) .execute(&self.pool) .await .map(|v| v.last_insert_rowid()) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index bf3754fe..48048973 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -181,7 +181,7 @@ async fn transfer_user_data( ); let id = dest_database - .insert_imported_user(user.user_id, &date_imported, user.administrator) + .insert_imported_user(user.user_id, date_imported, user.administrator) .await .unwrap(); @@ -204,17 +204,12 @@ async fn transfer_user_data( &user.username, &user.user_id ); - let default_user_bio = "".to_string(); - let default_user_avatar = "".to_string(); - dest_database .insert_user_profile( user.user_id, &user.username, &user.email, user.email_verified, - &default_user_bio, - &default_user_avatar, ) .await .unwrap(); From 8d74e6683c15b67674a652fc2df05d36a8dfe1fd Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 12:28:56 +0000 Subject: [PATCH 048/357] tests: [#56] for users profile and auth tables in upgrader --- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 35 +++++++++++++++++ .../testers/user_data_tester.rs | 39 +++++++++++++++++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 8 ++++ 3 files changed, 82 insertions(+) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index ba6f4831..87363cea 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -10,6 +10,22 @@ pub struct UserRecordV2 { pub administrator: bool, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserProfileRecordV2 { + pub user_id: i64, + pub username: String, + pub email: String, + pub email_verified: bool, + pub bio: Option, + pub avatar: Option, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserAuthenticationRecordV2 { + pub user_id: i64, + pub password_hash: String, +} + pub struct SqliteDatabaseV2_0_0 { pub pool: SqlitePool, } @@ -34,4 +50,23 @@ impl SqliteDatabaseV2_0_0 { .fetch_one(&self.pool) .await } + + pub async fn get_user_profile(&self, user_id: i64) -> Result { + query_as::<_, UserProfileRecordV2>("SELECT * FROM torrust_user_profiles WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + } + + pub async fn get_user_authentication( + &self, + user_id: i64, + ) -> Result { + query_as::<_, UserAuthenticationRecordV2>( + "SELECT * FROM torrust_user_authentication WHERE user_id = ?", + ) + .bind(user_id) + .fetch_one(&self.pool) + .await + } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs index a83b8077..3f70081c 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs @@ -49,6 +49,8 @@ impl UserDataTester { pub async fn assert(&self) { self.assert_user().await; + self.assert_user_profile().await; + self.assert_user_authentication().await; } /// Table `torrust_users` @@ -67,6 +69,43 @@ impl UserDataTester { self.test_data.user.administrator ); } + + /// Table `torrust_user_profiles` + async fn assert_user_profile(&self) { + let imported_user_profile = self + .destiny_database + .get_user_profile(self.test_data.user.user_id) + .await + .unwrap(); + + assert_eq!(imported_user_profile.user_id, self.test_data.user.user_id); + assert_eq!(imported_user_profile.username, self.test_data.user.username); + assert_eq!(imported_user_profile.email, self.test_data.user.email); + assert_eq!( + imported_user_profile.email_verified, + self.test_data.user.email_verified + ); + assert!(imported_user_profile.bio.is_none()); + assert!(imported_user_profile.avatar.is_none()); + } + + /// Table `torrust_user_profiles` + async fn assert_user_authentication(&self) { + let imported_user_authentication = self + .destiny_database + .get_user_authentication(self.test_data.user.user_id) + .await + .unwrap(); + + assert_eq!( + imported_user_authentication.user_id, + self.test_data.user.user_id + ); + assert_eq!( + imported_user_authentication.password_hash, + self.test_data.user.password + ); + } } fn hashed_valid_password() -> String { diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 4e9d4228..b0976944 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -1,8 +1,16 @@ //! You can run this test with: //! +//! //! ```text +//! cargo test upgrades_data_from_version_v1_0_0_to_v2_0_0 +//! ``` +//! +//! or: +//! //! ```text //! cargo test upgrades_data_from_version_v1_0_0_to_v2_0_0 -- --nocapture //! ``` +//! +//! to see the "upgrader" command output. use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_data_tester::UserDataTester; From eef980c3ce9c529c5c37fe512a8afdc4a24e3a2d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 12:55:27 +0000 Subject: [PATCH 049/357] tests: [#56] for tracker keys table in upgrader --- .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 18 +++++- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 20 ++++++ .../from_v1_0_0_to_v2_0_0/testers/mod.rs | 1 + .../testers/tracker_keys_tester.rs | 62 +++++++++++++++++++ .../testers/user_data_tester.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 23 ++++--- 6 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_keys_tester.rs diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index 6da98170..cc286a20 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -1,7 +1,9 @@ use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query, SqlitePool}; use std::fs; -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::UserRecordV1; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::{ + TrackerKeyRecordV1, UserRecordV1, +}; pub struct SqliteDatabaseV1_0_0 { pub pool: SqlitePool, @@ -65,4 +67,18 @@ impl SqliteDatabaseV1_0_0 { .await .map(|v| v.last_insert_rowid()) } + + pub async fn insert_tracker_key( + &self, + tracker_key: &TrackerKeyRecordV1, + ) -> Result { + query("INSERT INTO torrust_tracker_keys (key_id, user_id, key, valid_until) VALUES (?, ?, ?, ?)") + .bind(tracker_key.key_id) + .bind(tracker_key.user_id) + .bind(tracker_key.key.clone()) + .bind(tracker_key.valid_until) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index 87363cea..1f3c25a7 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -26,6 +26,14 @@ pub struct UserAuthenticationRecordV2 { pub password_hash: String, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct TrackerKeyRecordV2 { + pub tracker_key_id: i64, + pub user_id: i64, + pub tracker_key: String, + pub date_expiry: i64, +} + pub struct SqliteDatabaseV2_0_0 { pub pool: SqlitePool, } @@ -69,4 +77,16 @@ impl SqliteDatabaseV2_0_0 { .fetch_one(&self.pool) .await } + + pub async fn get_tracker_key( + &self, + tracker_key_id: i64, + ) -> Result { + query_as::<_, TrackerKeyRecordV2>( + "SELECT * FROM torrust_tracker_keys WHERE user_id = ?", + ) + .bind(tracker_key_id) + .fetch_one(&self.pool) + .await + } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs index 85968bfd..7285ed3c 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs @@ -1 +1,2 @@ +pub mod tracker_keys_tester; pub mod user_data_tester; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_keys_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_keys_tester.rs new file mode 100644 index 00000000..dd6eefdb --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_keys_tester.rs @@ -0,0 +1,62 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::TrackerKeyRecordV1; + +pub struct TrackerKeysTester { + source_database: Arc, + destiny_database: Arc, + test_data: TestData, +} + +pub struct TestData { + pub tracker_key: TrackerKeyRecordV1, +} + +impl TrackerKeysTester { + pub fn new( + source_database: Arc, + destiny_database: Arc, + user_id: i64, + ) -> Self { + let tracker_key = TrackerKeyRecordV1 { + key_id: 1, + user_id, + key: "rRstSTM5rx0sgxjLkRSJf3rXODcRBI5T".to_string(), + valid_until: 2456956800, // 11-10-2047 00:00:00 UTC + }; + + Self { + source_database, + destiny_database, + test_data: TestData { tracker_key }, + } + } + + pub async fn load_data_into_source_db(&self) { + self.source_database + .insert_tracker_key(&self.test_data.tracker_key) + .await + .unwrap(); + } + + /// Table `torrust_tracker_keys` + pub async fn assert(&self) { + let imported_key = self + .destiny_database + .get_tracker_key(self.test_data.tracker_key.key_id) + .await + .unwrap(); + + assert_eq!( + imported_key.tracker_key_id, + self.test_data.tracker_key.key_id + ); + assert_eq!(imported_key.user_id, self.test_data.tracker_key.user_id); + assert_eq!(imported_key.tracker_key, self.test_data.tracker_key.key); + assert_eq!( + imported_key.date_expiry, + self.test_data.tracker_key.valid_until + ); + } +} diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs index 3f70081c..1f6f7238 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs @@ -10,7 +10,7 @@ pub struct UserDataTester { source_database: Arc, destiny_database: Arc, execution_time: String, - test_data: TestData, + pub test_data: TestData, } pub struct TestData { diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index b0976944..d0314328 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -13,6 +13,7 @@ //! to see the "upgrader" command output. use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::tracker_keys_tester::TrackerKeysTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_data_tester::UserDataTester; use std::fs; use std::sync::Arc; @@ -43,7 +44,7 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // Load data into database v1 - // `torrust_users` table + // `torrust_users`, `torrust_user_profiles` and `torrust_user_authentication` tables let user_data_tester = UserDataTester::new( source_database.clone(), @@ -55,7 +56,13 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // `torrust_tracker_keys` table - // TODO + let tracker_keys_tester = TrackerKeysTester::new( + source_database.clone(), + destiny_database.clone(), + user_data_tester.test_data.user.user_id, + ); + + tracker_keys_tester.load_data_into_source_db().await; // `torrust_torrents` table @@ -71,21 +78,13 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // Assertions in database v2 - // `torrust_users` table + // `torrust_users`, `torrust_user_profiles` and `torrust_user_authentication` tables user_data_tester.assert().await; - // `torrust_user_authentication` table - - // TODO - - // `torrust_user_profiles` table - - // TODO - // `torrust_tracker_keys` table - // TODO + tracker_keys_tester.assert().await; // `torrust_torrents` table From f0f581faebb48823e46edb0408214591152d0225 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 17:46:27 +0000 Subject: [PATCH 050/357] tests: [#56] for torrents table in upgrader --- src/models/torrent_file.rs | 4 +- .../databases/sqlite_v1_0_0.rs | 2 +- .../databases/sqlite_v2_0_0.rs | 5 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 10 +- .../fixtures/uploads/1.torrent | Bin 0 -> 1128 bytes .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 32 ++++- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 18 ++- .../from_v1_0_0_to_v2_0_0/testers/mod.rs | 1 + .../testers/torrent_tester.rs | 115 ++++++++++++++++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 42 ++----- 10 files changed, 183 insertions(+), 46 deletions(-) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/uploads/1.torrent create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index c7ab26a7..62319036 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -45,14 +45,14 @@ impl TorrentInfo { pub fn get_pieces_as_string(&self) -> String { match &self.pieces { None => "".to_string(), - Some(byte_buf) => bytes_to_hex(byte_buf.as_ref()) + Some(byte_buf) => bytes_to_hex(byte_buf.as_ref()), } } pub fn get_root_hash_as_i64(&self) -> i64 { match &self.root_hash { None => 0i64, - Some(root_hash) => root_hash.parse::().unwrap() + Some(root_hash) => root_hash.parse::().unwrap(), } } } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 3328fd43..3d42a4b3 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -10,7 +10,7 @@ pub struct CategoryRecord { pub name: String, } -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct UserRecordV1 { pub user_id: i64, pub username: String, diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index b7d1a570..bee97bc2 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -14,6 +14,7 @@ pub struct CategoryRecordV2 { pub name: String, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct TorrentRecordV2 { pub torrent_id: i64, pub uploader_id: i64, @@ -50,7 +51,7 @@ impl TorrentRecordV2 { } } -fn convert_timestamp_to_datetime(timestamp: i64) -> String { +pub fn convert_timestamp_to_datetime(timestamp: i64) -> String { // The expected format in database is: 2022-11-04 09:53:57 // MySQL uses a DATETIME column and SQLite uses a TEXT column. @@ -136,7 +137,7 @@ impl SqliteDatabaseV2_0_0 { user_id: i64, username: &str, email: &str, - email_verified: bool + email_verified: bool, ) -> Result { query("INSERT INTO torrust_user_profiles (user_id, username, email, email_verified, bio, avatar) VALUES (?, ?, ?, ?, ?, ?)") .bind(user_id) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 48048973..cfb17be9 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -326,7 +326,13 @@ async fn transfer_torrents( let filepath = format!("{}/{}.torrent", upload_path, &torrent.torrent_id); - let torrent_from_file = read_torrent_from_file(&filepath).unwrap(); + let torrent_from_file_result = read_torrent_from_file(&filepath); + + if torrent_from_file_result.is_err() { + panic!("Error torrent file not found: {:?}", &filepath); + } + + let torrent_from_file = torrent_from_file_result.unwrap(); let id = dest_database .insert_torrent(&TorrentRecordV2::from_v1_data( @@ -463,7 +469,7 @@ async fn transfer_torrents( println!("Torrents transferred"); } -fn read_torrent_from_file(path: &str) -> Result> { +pub fn read_torrent_from_file(path: &str) -> Result> { let contents = match fs::read(path) { Ok(contents) => contents, Err(e) => return Err(e.into()), diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/uploads/1.torrent b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/uploads/1.torrent new file mode 100644 index 0000000000000000000000000000000000000000..faa30f4cda2290bada6c2462f8e7782dae6246b3 GIT binary patch literal 1128 zcmYeXuu9C!%P-AKPBk&GDorV{($_C3N=(j9Ez$+@^wN_uOG@-I^V0IIEDZEvN)3(S zx^#0gi%W8HNYHF#3AHCDKRGccBfq%B%FNQt5^lJWIaJ=z$Xw4r&k$8MHPzfIIX^cy zHLt|T%*r=0FC{f6sVKihAvm=}!N|bGq5?z|R2UjqB^RY8mZYXABvl$(SQR<}J(6Ek z1k|TcW};`JX9)BJSVd-jo>eWrI%!68k!pcB~uLztqL+zlT(Woit_VIjE$_$-KiF4 ztKjTSVv9M{p>vJ(hyAt3*B_jVX)O|{y~?ASmztW2VTO@aZfZ%QLRo52G0>HnMnHGv zCFZ8$af+c4#99TIYs`!-On?SM+yZo&Rqez=-nqWJI%bC`gj`iC>Wyl%f2erB{P_3X zIpxz7(^M^v-aNayo8x_t%Ir;FkDS%I&wYUB!`k~r6P|8!^ij@npWQd#Z50o1-^JUU+ob&py3nhBbRX{CuSR^Gw6~5C)c?t(h|Aod?VOuC3aixa?5F%+=fC z6Z9QcsO~dbY&(~or!r&4mAT2D>lGr>)beL!=Ng%ut>qAnle)mb8NS-US|Q=!9ijGX zpP#$>7o>BV8HRpR_?EL&G+(E4YNl9lW3A!GZF|j~&IodE`lIc+v~}B`vJ#icsR-{U zRssVpg@|xBHZ`(Z?%Km>dsE(G;~L+@gzf$}B4VQ@nfr?F{}yw$H~#D>a^kZZyJX*t zi}nv&llRvAX)`ywz3zXq;Kj*`yt|GZ5U(-P^X)%#}n zZEMQkXtY&_&!Rv&L)-JlR|h$}Xx0_Xj-MCLbd>N{W$T{5Vr4x?K=}Xgq?naEzBQKh ztxj>R-kro?<9WNjDM;SoT-94uljNBAWeG>?*ZwcrfAiSI*(bB>?)83XxFly?GTC8H zLTC43DTcQ-jQYpePJ|Yp2v2R6xqs+c_t|eh&GPw6uI@Z;OCv#fOzfC z2FwaYvz<7PD13Z;rTF0I-TX}zq4N`8)r;iv{t- Result { + query( + "INSERT INTO torrust_torrents ( + torrent_id, + uploader, + info_hash, + title, + category_id, + description, + upload_date, + file_size, + seeders, + leechers + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(torrent.torrent_id) + .bind(torrent.uploader.clone()) + .bind(torrent.info_hash.clone()) + .bind(torrent.title.clone()) + .bind(torrent.category_id) + .bind(torrent.description.clone()) + .bind(torrent.upload_date) + .bind(torrent.file_size) + .bind(torrent.seeders) + .bind(torrent.leechers) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index 1f3c25a7..17331572 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query_as, SqlitePool}; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::TorrentRecordV2; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct UserRecordV2 { @@ -82,11 +83,16 @@ impl SqliteDatabaseV2_0_0 { &self, tracker_key_id: i64, ) -> Result { - query_as::<_, TrackerKeyRecordV2>( - "SELECT * FROM torrust_tracker_keys WHERE user_id = ?", - ) - .bind(tracker_key_id) - .fetch_one(&self.pool) - .await + query_as::<_, TrackerKeyRecordV2>("SELECT * FROM torrust_tracker_keys WHERE user_id = ?") + .bind(tracker_key_id) + .fetch_one(&self.pool) + .await + } + + pub async fn get_torrent(&self, torrent_id: i64) -> Result { + query_as::<_, TorrentRecordV2>("SELECT * FROM torrust_torrents WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_one(&self.pool) + .await } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs index 7285ed3c..6445ec5b 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs @@ -1,2 +1,3 @@ +pub mod torrent_tester; pub mod tracker_keys_tester; pub mod user_data_tester; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs new file mode 100644 index 00000000..d6c14045 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -0,0 +1,115 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; +use torrust_index_backend::models::torrent_file::Torrent; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::{ + TorrentRecordV1, UserRecordV1, +}; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::convert_timestamp_to_datetime; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::read_torrent_from_file; + +pub struct TorrentTester { + source_database: Arc, + destiny_database: Arc, + test_data: TestData, +} + +pub struct TestData { + pub torrent: TorrentRecordV1, + pub user: UserRecordV1, +} + +impl TorrentTester { + pub fn new( + source_database: Arc, + destiny_database: Arc, + user: &UserRecordV1, + ) -> Self { + let torrent = TorrentRecordV1 { + torrent_id: 1, + uploader: user.username.clone(), + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + title: "title".to_string(), + category_id: 1, + description: "description".to_string(), + upload_date: 1667546358, // 2022-11-04 07:19:18 + file_size: 9219566, + seeders: 0, + leechers: 0, + }; + + Self { + source_database, + destiny_database, + test_data: TestData { + torrent, + user: user.clone(), + }, + } + } + + pub async fn load_data_into_source_db(&self) { + self.source_database + .insert_torrent(&self.test_data.torrent) + .await + .unwrap(); + } + + pub async fn assert(&self, upload_path: &str) { + let filepath = self.torrent_file_path(upload_path, self.test_data.torrent.torrent_id); + let torrent_file = read_torrent_from_file(&filepath).unwrap(); + + self.assert_torrent(&torrent_file).await; + // TODO + // `torrust_torrent_files`, + // `torrust_torrent_info` + // `torrust_torrent_announce_urls` + } + + pub fn torrent_file_path(&self, upload_path: &str, torrent_id: i64) -> String { + format!("{}/{}.torrent", &upload_path, &torrent_id) + } + + /// Table `torrust_torrents` + async fn assert_torrent(&self, torrent_file: &Torrent) { + let imported_torrent = self + .destiny_database + .get_torrent(self.test_data.torrent.torrent_id) + .await + .unwrap(); + + assert_eq!( + imported_torrent.torrent_id, + self.test_data.torrent.torrent_id + ); + assert_eq!(imported_torrent.uploader_id, self.test_data.user.user_id); + assert_eq!( + imported_torrent.category_id, + self.test_data.torrent.category_id + ); + assert_eq!(imported_torrent.info_hash, self.test_data.torrent.info_hash); + assert_eq!(imported_torrent.size, self.test_data.torrent.file_size); + assert_eq!(imported_torrent.name, torrent_file.info.name); + assert_eq!( + imported_torrent.pieces, + torrent_file.info.get_pieces_as_string() + ); + assert_eq!( + imported_torrent.piece_length, + torrent_file.info.piece_length + ); + if torrent_file.info.private.is_none() { + assert_eq!(imported_torrent.private, Some(0)); + } else { + assert_eq!(imported_torrent.private, torrent_file.info.private); + } + assert_eq!( + imported_torrent.root_hash, + torrent_file.info.get_root_hash_as_i64() + ); + assert_eq!( + imported_torrent.date_uploaded, + convert_timestamp_to_datetime(self.test_data.torrent.upload_date) + ); + } +} diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index d0314328..22093624 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -13,6 +13,7 @@ //! to see the "upgrader" command output. use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::torrent_tester::TorrentTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::tracker_keys_tester::TrackerKeysTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_data_tester::UserDataTester; use std::fs; @@ -26,6 +27,7 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // Directories let fixtures_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/".to_string(); let output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); + let upload_path = format!("{}uploads/", &fixtures_dir); // Files let source_database_file = format!("{}source.db", output_dir); @@ -44,63 +46,40 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // Load data into database v1 - // `torrust_users`, `torrust_user_profiles` and `torrust_user_authentication` tables - let user_data_tester = UserDataTester::new( source_database.clone(), destiny_database.clone(), &execution_time, ); - user_data_tester.load_data_into_source_db().await; - // `torrust_tracker_keys` table - let tracker_keys_tester = TrackerKeysTester::new( source_database.clone(), destiny_database.clone(), user_data_tester.test_data.user.user_id, ); - tracker_keys_tester.load_data_into_source_db().await; - // `torrust_torrents` table - - // TODO + let torrent_tester = TorrentTester::new( + source_database.clone(), + destiny_database.clone(), + &user_data_tester.test_data.user, + ); + torrent_tester.load_data_into_source_db().await; // Run the upgrader let args = Arguments { source_database_file: source_database_file.clone(), destiny_database_file: destiny_database_file.clone(), - upload_path: format!("{}uploads/", fixtures_dir), + upload_path: upload_path.clone(), }; upgrade(&args, &execution_time).await; // Assertions in database v2 - // `torrust_users`, `torrust_user_profiles` and `torrust_user_authentication` tables - user_data_tester.assert().await; - - // `torrust_tracker_keys` table - tracker_keys_tester.assert().await; - - // `torrust_torrents` table - - // TODO - - // `torrust_torrent_files` table - - // TODO - - // `torrust_torrent_info` table - - // TODO - - // `torrust_torrent_announce_urls` table - - // TODO + torrent_tester.assert(&upload_path).await; } async fn source_db_connection(source_database_file: &str) -> Arc { @@ -113,7 +92,6 @@ async fn destiny_db_connection(destiny_database_file: &str) -> Arc Date: Thu, 10 Nov 2022 17:50:03 +0000 Subject: [PATCH 051/357] refactor: [#56] rename mod and variables --- .../from_v1_0_0_to_v2_0_0/testers/mod.rs | 4 ++-- ...r_keys_tester.rs => tracker_key_tester.rs} | 4 ++-- .../{user_data_tester.rs => user_tester.rs} | 4 ++-- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 20 +++++++++---------- 4 files changed, 16 insertions(+), 16 deletions(-) rename tests/upgrades/from_v1_0_0_to_v2_0_0/testers/{tracker_keys_tester.rs => tracker_key_tester.rs} (97%) rename tests/upgrades/from_v1_0_0_to_v2_0_0/testers/{user_data_tester.rs => user_tester.rs} (98%) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs index 6445ec5b..730b5149 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs @@ -1,3 +1,3 @@ pub mod torrent_tester; -pub mod tracker_keys_tester; -pub mod user_data_tester; +pub mod tracker_key_tester; +pub mod user_tester; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_keys_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs similarity index 97% rename from tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_keys_tester.rs rename to tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs index dd6eefdb..68b591a8 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_keys_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs @@ -3,7 +3,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; use std::sync::Arc; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::TrackerKeyRecordV1; -pub struct TrackerKeysTester { +pub struct TrackerKeyTester { source_database: Arc, destiny_database: Arc, test_data: TestData, @@ -13,7 +13,7 @@ pub struct TestData { pub tracker_key: TrackerKeyRecordV1, } -impl TrackerKeysTester { +impl TrackerKeyTester { pub fn new( source_database: Arc, destiny_database: Arc, diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs similarity index 98% rename from tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs rename to tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs index 1f6f7238..e0d001f8 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_data_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs @@ -6,7 +6,7 @@ use rand_core::OsRng; use std::sync::Arc; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::UserRecordV1; -pub struct UserDataTester { +pub struct UserTester { source_database: Arc, destiny_database: Arc, execution_time: String, @@ -17,7 +17,7 @@ pub struct TestData { pub user: UserRecordV1, } -impl UserDataTester { +impl UserTester { pub fn new( source_database: Arc, destiny_database: Arc, diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 22093624..ccda3537 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -14,8 +14,8 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::torrent_tester::TorrentTester; -use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::tracker_keys_tester::TrackerKeysTester; -use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_data_tester::UserDataTester; +use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::tracker_key_tester::TrackerKeyTester; +use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_tester::UserTester; use std::fs; use std::sync::Arc; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{ @@ -46,24 +46,24 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // Load data into database v1 - let user_data_tester = UserDataTester::new( + let user_tester = UserTester::new( source_database.clone(), destiny_database.clone(), &execution_time, ); - user_data_tester.load_data_into_source_db().await; + user_tester.load_data_into_source_db().await; - let tracker_keys_tester = TrackerKeysTester::new( + let tracker_key_tester = TrackerKeyTester::new( source_database.clone(), destiny_database.clone(), - user_data_tester.test_data.user.user_id, + user_tester.test_data.user.user_id, ); - tracker_keys_tester.load_data_into_source_db().await; + tracker_key_tester.load_data_into_source_db().await; let torrent_tester = TorrentTester::new( source_database.clone(), destiny_database.clone(), - &user_data_tester.test_data.user, + &user_tester.test_data.user, ); torrent_tester.load_data_into_source_db().await; @@ -77,8 +77,8 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // Assertions in database v2 - user_data_tester.assert().await; - tracker_keys_tester.assert().await; + user_tester.assert().await; + tracker_key_tester.assert().await; torrent_tester.assert(&upload_path).await; } From 00632890f35f26f8f4547850fa5a42708d5f39e0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 22:19:06 +0000 Subject: [PATCH 052/357] tests: [#56] for torrents info and announce urls tables in upgrader --- .../databases/sqlite_v1_0_0.rs | 2 +- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 38 ++++++++++++++++ .../testers/torrent_tester.rs | 43 +++++++++++++++++-- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 3d42a4b3..a5743000 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -35,7 +35,7 @@ pub struct TorrentRecordV1 { pub info_hash: String, pub title: String, pub category_id: i64, - pub description: String, + pub description: Option, pub upload_date: i64, pub file_size: i64, pub seeders: i64, diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index 17331572..2f0ba395 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -35,6 +35,20 @@ pub struct TrackerKeyRecordV2 { pub date_expiry: i64, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct TorrentInfoRecordV2 { + pub torrent_id: i64, + pub title: String, + pub description: Option, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, PartialEq)] +pub struct TorrentAnnounceUrlV2 { + pub announce_url_id: i64, + pub torrent_id: i64, + pub tracker_url: String, +} + pub struct SqliteDatabaseV2_0_0 { pub pool: SqlitePool, } @@ -95,4 +109,28 @@ impl SqliteDatabaseV2_0_0 { .fetch_one(&self.pool) .await } + + pub async fn get_torrent_info( + &self, + torrent_id: i64, + ) -> Result { + query_as::<_, TorrentInfoRecordV2>( + "SELECT * FROM torrust_torrent_info WHERE torrent_id = ?", + ) + .bind(torrent_id) + .fetch_one(&self.pool) + .await + } + + pub async fn get_torrent_announce_urls( + &self, + torrent_id: i64, + ) -> Result, sqlx::Error> { + query_as::<_, TorrentAnnounceUrlV2>( + "SELECT * FROM torrust_torrent_announce_urls WHERE torrent_id = ?", + ) + .bind(torrent_id) + .fetch_all(&self.pool) + .await + } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index d6c14045..33ea8b1a 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -31,7 +31,7 @@ impl TorrentTester { info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), title: "title".to_string(), category_id: 1, - description: "description".to_string(), + description: Some("description".to_string()), upload_date: 1667546358, // 2022-11-04 07:19:18 file_size: 9219566, seeders: 0, @@ -60,10 +60,10 @@ impl TorrentTester { let torrent_file = read_torrent_from_file(&filepath).unwrap(); self.assert_torrent(&torrent_file).await; + self.assert_torrent_info().await; + self.assert_torrent_announce_urls(&torrent_file).await; // TODO // `torrust_torrent_files`, - // `torrust_torrent_info` - // `torrust_torrent_announce_urls` } pub fn torrent_file_path(&self, upload_path: &str, torrent_id: i64) -> String { @@ -112,4 +112,41 @@ impl TorrentTester { convert_timestamp_to_datetime(self.test_data.torrent.upload_date) ); } + + /// Table `torrust_torrent_info` + async fn assert_torrent_info(&self) { + let torrent_info = self + .destiny_database + .get_torrent_info(self.test_data.torrent.torrent_id) + .await + .unwrap(); + + assert_eq!(torrent_info.torrent_id, self.test_data.torrent.torrent_id); + assert_eq!(torrent_info.title, self.test_data.torrent.title); + assert_eq!(torrent_info.description, self.test_data.torrent.description); + } + + /// Table `torrust_torrent_announce_urls` + async fn assert_torrent_announce_urls(&self, torrent_file: &Torrent) { + let torrent_announce_urls = self + .destiny_database + .get_torrent_announce_urls(self.test_data.torrent.torrent_id) + .await + .unwrap(); + + let urls: Vec = torrent_announce_urls + .iter() + .map(|torrent_announce_url| torrent_announce_url.tracker_url.to_string()) + .collect(); + + let expected_urls = torrent_file + .announce_list + .clone() + .unwrap() + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(urls, expected_urls); + } } From 750969dce57729f13d60e10db8e550c9ef03b627 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 22:25:15 +0000 Subject: [PATCH 053/357] refactor: [#56] rename methods --- .../testers/torrent_tester.rs | 2 +- .../testers/tracker_key_tester.rs | 2 +- .../testers/user_tester.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 25 +++++++++++-------- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index 33ea8b1a..3f636506 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -55,7 +55,7 @@ impl TorrentTester { .unwrap(); } - pub async fn assert(&self, upload_path: &str) { + pub async fn assert_data_in_destiny_db(&self, upload_path: &str) { let filepath = self.torrent_file_path(upload_path, self.test_data.torrent.torrent_id); let torrent_file = read_torrent_from_file(&filepath).unwrap(); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs index 68b591a8..3dfa4904 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs @@ -41,7 +41,7 @@ impl TrackerKeyTester { } /// Table `torrust_tracker_keys` - pub async fn assert(&self) { + pub async fn assert_data_in_destiny_db(&self) { let imported_key = self .destiny_database .get_tracker_key(self.test_data.tracker_key.key_id) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs index e0d001f8..d349a47f 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs @@ -47,7 +47,7 @@ impl UserTester { .unwrap(); } - pub async fn assert(&self) { + pub async fn assert_data_in_destiny_db(&self) { self.assert_user().await; self.assert_user_profile().await; self.assert_user_authentication().await; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index ccda3537..8f3c33ca 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -44,7 +44,7 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // The datetime when the upgrader is executed let execution_time = datetime_iso_8601(); - // Load data into database v1 + // Load data into source database in version v1.0.0 let user_tester = UserTester::new( source_database.clone(), @@ -68,18 +68,21 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { torrent_tester.load_data_into_source_db().await; // Run the upgrader - let args = Arguments { - source_database_file: source_database_file.clone(), - destiny_database_file: destiny_database_file.clone(), - upload_path: upload_path.clone(), - }; - upgrade(&args, &execution_time).await; + upgrade( + &Arguments { + source_database_file: source_database_file.clone(), + destiny_database_file: destiny_database_file.clone(), + upload_path: upload_path.clone(), + }, + &execution_time, + ) + .await; - // Assertions in database v2 + // Assertions for data transferred to the new database in version v2.0.0 - user_tester.assert().await; - tracker_key_tester.assert().await; - torrent_tester.assert(&upload_path).await; + user_tester.assert_data_in_destiny_db().await; + tracker_key_tester.assert_data_in_destiny_db().await; + torrent_tester.assert_data_in_destiny_db(&upload_path).await; } async fn source_db_connection(source_database_file: &str) -> Arc { From 82b84a3633e287829c2b290b7cc1857002a3c6e2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 10 Nov 2022 22:47:25 +0000 Subject: [PATCH 054/357] refactor: [#56] extract test configuration --- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 89 ++++++++++++------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 8f3c33ca..a40f0a37 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -22,67 +22,88 @@ use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{ datetime_iso_8601, upgrade, Arguments, }; -#[tokio::test] -async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { +struct TestConfig { // Directories - let fixtures_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/".to_string(); - let output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); - let upload_path = format!("{}uploads/", &fixtures_dir); - + pub fixtures_dir: String, + pub upload_path: String, // Files - let source_database_file = format!("{}source.db", output_dir); - let destiny_database_file = format!("{}destiny.db", output_dir); + pub source_database_file: String, + pub destiny_database_file: String, +} - // Set up clean source database - reset_databases(&source_database_file, &destiny_database_file); - let source_database = source_db_connection(&source_database_file).await; - source_database.migrate(&fixtures_dir).await; +impl Default for TestConfig { + fn default() -> Self { + let fixtures_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/".to_string(); + let upload_path = format!("{}uploads/", &fixtures_dir); + let output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); + let source_database_file = format!("{}source.db", output_dir); + let destiny_database_file = format!("{}destiny.db", output_dir); + Self { + fixtures_dir, + upload_path, + source_database_file, + destiny_database_file, + } + } +} - // Set up connection for the destiny database - let destiny_database = destiny_db_connection(&destiny_database_file).await; +#[tokio::test] +async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { + let config = TestConfig::default(); + + let (source_db, dest_db) = setup_databases(&config).await; // The datetime when the upgrader is executed let execution_time = datetime_iso_8601(); - // Load data into source database in version v1.0.0 - - let user_tester = UserTester::new( - source_database.clone(), - destiny_database.clone(), - &execution_time, - ); - user_tester.load_data_into_source_db().await; - + let user_tester = UserTester::new(source_db.clone(), dest_db.clone(), &execution_time); let tracker_key_tester = TrackerKeyTester::new( - source_database.clone(), - destiny_database.clone(), + source_db.clone(), + dest_db.clone(), user_tester.test_data.user.user_id, ); - tracker_key_tester.load_data_into_source_db().await; - let torrent_tester = TorrentTester::new( - source_database.clone(), - destiny_database.clone(), + source_db.clone(), + dest_db.clone(), &user_tester.test_data.user, ); + + // Load data into source database in version v1.0.0 + user_tester.load_data_into_source_db().await; + tracker_key_tester.load_data_into_source_db().await; torrent_tester.load_data_into_source_db().await; // Run the upgrader upgrade( &Arguments { - source_database_file: source_database_file.clone(), - destiny_database_file: destiny_database_file.clone(), - upload_path: upload_path.clone(), + source_database_file: config.source_database_file.clone(), + destiny_database_file: config.destiny_database_file.clone(), + upload_path: config.upload_path.clone(), }, &execution_time, ) .await; // Assertions for data transferred to the new database in version v2.0.0 - user_tester.assert_data_in_destiny_db().await; tracker_key_tester.assert_data_in_destiny_db().await; - torrent_tester.assert_data_in_destiny_db(&upload_path).await; + torrent_tester + .assert_data_in_destiny_db(&config.upload_path) + .await; +} + +async fn setup_databases( + config: &TestConfig, +) -> (Arc, Arc) { + // Set up clean source database + reset_databases(&config.source_database_file, &config.destiny_database_file); + let source_database = source_db_connection(&config.source_database_file).await; + source_database.migrate(&config.fixtures_dir).await; + + // Set up connection for the destiny database + let destiny_database = destiny_db_connection(&config.destiny_database_file).await; + + (source_database, destiny_database) } async fn source_db_connection(source_database_file: &str) -> Arc { From afffaefc62f21070ee9a9aa4e3f98367142dad39 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 11 Nov 2022 10:15:16 +0000 Subject: [PATCH 055/357] tests: [#56] for torrents files table in upgrader --- src/models/torrent_file.rs | 25 ++++++++++++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 5 +-- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 19 +++++++++++ .../testers/torrent_tester.rs | 33 ++++++++++++++----- 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 62319036..6e015d1a 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -55,6 +55,14 @@ impl TorrentInfo { Some(root_hash) => root_hash.parse::().unwrap(), } } + + pub fn is_a_single_file_torrent(&self) -> bool { + self.length.is_some() + } + + pub fn is_a_multiple_file_torrent(&self) -> bool { + self.files.is_some() + } } #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] @@ -192,6 +200,23 @@ impl Torrent { } } } + + pub fn announce_urls(&self) -> Vec { + self.announce_list + .clone() + .unwrap() + .into_iter() + .flatten() + .collect::>() + } + + pub fn is_a_single_file_torrent(&self) -> bool { + self.info.is_a_single_file_torrent() + } + + pub fn is_a_multiple_file_torrent(&self) -> bool { + self.info.is_a_multiple_file_torrent() + } } #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index cfb17be9..91e42931 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -359,10 +359,7 @@ async fn transfer_torrents( println!("[v2][torrust_torrent_files] adding torrent files"); - let _is_torrent_with_multiple_files = torrent_from_file.info.files.is_some(); - let is_torrent_with_a_single_file = torrent_from_file.info.length.is_some(); - - if is_torrent_with_a_single_file { + if torrent_from_file.is_a_single_file_torrent() { // The torrent contains only one file then: // - "path" is NULL // - "md5sum" can be NULL diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index 2f0ba395..20a55daa 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -49,6 +49,15 @@ pub struct TorrentAnnounceUrlV2 { pub tracker_url: String, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, PartialEq)] +pub struct TorrentFileV2 { + pub file_id: i64, + pub torrent_id: i64, + pub md5sum: Option, + pub length: i64, + pub path: Option, +} + pub struct SqliteDatabaseV2_0_0 { pub pool: SqlitePool, } @@ -133,4 +142,14 @@ impl SqliteDatabaseV2_0_0 { .fetch_all(&self.pool) .await } + + pub async fn get_torrent_files( + &self, + torrent_id: i64, + ) -> Result, sqlx::Error> { + query_as::<_, TorrentFileV2>("SELECT * FROM torrust_torrent_files WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await + } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index 3f636506..2b6d92b4 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -62,8 +62,7 @@ impl TorrentTester { self.assert_torrent(&torrent_file).await; self.assert_torrent_info().await; self.assert_torrent_announce_urls(&torrent_file).await; - // TODO - // `torrust_torrent_files`, + self.assert_torrent_files(&torrent_file).await; } pub fn torrent_file_path(&self, upload_path: &str, torrent_id: i64) -> String { @@ -139,14 +138,30 @@ impl TorrentTester { .map(|torrent_announce_url| torrent_announce_url.tracker_url.to_string()) .collect(); - let expected_urls = torrent_file - .announce_list - .clone() - .unwrap() - .into_iter() - .flatten() - .collect::>(); + let expected_urls = torrent_file.announce_urls(); assert_eq!(urls, expected_urls); } + + /// Table `torrust_torrent_files` + async fn assert_torrent_files(&self, torrent_file: &Torrent) { + let db_torrent_files = self + .destiny_database + .get_torrent_files(self.test_data.torrent.torrent_id) + .await + .unwrap(); + + if torrent_file.is_a_single_file_torrent() { + let db_torrent_file = &db_torrent_files[0]; + assert_eq!( + db_torrent_file.torrent_id, + self.test_data.torrent.torrent_id + ); + assert!(db_torrent_file.md5sum.is_none()); + assert_eq!(db_torrent_file.length, torrent_file.info.length.unwrap()); + assert!(db_torrent_file.path.is_none()); + } else { + todo!(); + } + } } From ee01e7b475605f767324c94287f91bc647642d5e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 11 Nov 2022 10:44:27 +0000 Subject: [PATCH 056/357] test: [#56] for torrent files table in upgrader (new case) --- src/models/torrent_file.rs | 4 + .../fixtures/uploads/2.torrent | Bin 0 -> 1505 bytes .../testers/torrent_tester.rs | 141 +++++++++++++----- 3 files changed, 105 insertions(+), 40 deletions(-) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/uploads/2.torrent diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 6e015d1a..ff34be5e 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -202,6 +202,10 @@ impl Torrent { } pub fn announce_urls(&self) -> Vec { + if self.announce_list.is_none() { + return vec![self.announce.clone().unwrap()]; + } + self.announce_list .clone() .unwrap() diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/uploads/2.torrent b/tests/upgrades/from_v1_0_0_to_v2_0_0/fixtures/uploads/2.torrent new file mode 100644 index 0000000000000000000000000000000000000000..a62afbff926204bae8fca24d76914ceae1a38b1c GIT binary patch literal 1505 zcmYeXuu9C!%P-AKPBk&GDorV{($_C3N=(j9Ez$+@^wN_uOG@-I^V0IIEDZEvO3kg3 z^K)}k^Gb{@twPH46?_x(Qc`o0itTQrj0{XHDnLX*g+f7Qa!F}XYO$e#RdP{k zVo7R>LQ3asU}t+ zC!|%&NbE__SYU?e{e3QwMd}$DvxSjYAQ}k zjHqZyswvo8#W@%*HnA#5EXl~h=?bt{fni}{l~__z1oR%<%N7Pkpil#AH?h(yNCc_@ zi=~om3D~Qtsi}rWR=KGqi3(+@Ma954$}|F+pO=^m3Zs(pd|gCj=oY7z=)&XO2omTD zu(&WYwlGOGgT{mjFwxacEaaW*yQ^b%h(gF!wW8jrHv5N)_sfre-ucs{JXUo_$AHb)=j9QWCM^W9eQ@b+Dt{{LM@cf85s_Una5 zm;LP1TV`0Z_ruRe%0JIEtPf#e`PrH&Q{H*7%n!S?c=t+=1yk>xi|gM z_FUS!?N3>W%Vcw_f}+eaa7G0u4shO5$VsdOrjHaNa+k5Gk=1h79!A@n@)jG{_$DT7 z_rDPl8!gG)S9Jfkn6tg{XGf6}pVinU`(|9Uf7qJ5x8_fqx!LV?|C0qTPFCdIb>xuf z5z){)FD|*ocmHrw5Asa9mRWu?)aCY;V_!p_>$H7nSLfK?x=h^V@dc>>y@QPw618ke z&ppJ}wgDsU%??e)w|Z5(SIjppk#1VOZ-(Etru>aYTXpy>3Zyf%J#Tzjcy;U_yj)`BE zaI}8y|C0STk6oO7GP~|x?}vsEULyFRQ>)GDz3=CG+*l%36{?xg%dL%S0mWt=}(, user: &UserRecordV1, ) -> Self { - let torrent = TorrentRecordV1 { + let torrent_01 = TorrentRecordV1 { torrent_id: 1, uploader: user.username.clone(), info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), - title: "title".to_string(), + title: "A Mandelbrot Set 2048x2048px picture".to_string(), category_id: 1, - description: Some("description".to_string()), + description: Some( + "A beautiful Mandelbrot Set picture in black and white. \ + - Hybrid torrent V1 and V2. \ + - Single-file torrent. \ + - Public. \ + - More than one tracker URL. \ + " + .to_string(), + ), + upload_date: 1667546358, // 2022-11-04 07:19:18 + file_size: 9219566, + seeders: 0, + leechers: 0, + }; + let torrent_02 = TorrentRecordV1 { + torrent_id: 2, + uploader: user.username.clone(), + info_hash: "0902d375f18ec020f0cc68ed4810023032ba81cb".to_string(), + title: "Two Mandelbrot Set 2048x2048px pictures".to_string(), + category_id: 1, + description: Some( + "Two beautiful Mandelbrot Set pictures in black and white. \ + - Hybrid torrent V1 and V2. \ + - Multiple-files torrent. \ + - Private. + - Only one tracker URL. + " + .to_string(), + ), upload_date: 1667546358, // 2022-11-04 07:19:18 file_size: 9219566, seeders: 0, @@ -42,7 +71,8 @@ impl TorrentTester { source_database, destiny_database, test_data: TestData { - torrent, + torrent_01, + torrent_02, user: user.clone(), }, } @@ -50,19 +80,39 @@ impl TorrentTester { pub async fn load_data_into_source_db(&self) { self.source_database - .insert_torrent(&self.test_data.torrent) + .insert_torrent(&self.test_data.torrent_01) + .await + .unwrap(); + self.source_database + .insert_torrent(&self.test_data.torrent_02) .await .unwrap(); } pub async fn assert_data_in_destiny_db(&self, upload_path: &str) { - let filepath = self.torrent_file_path(upload_path, self.test_data.torrent.torrent_id); - let torrent_file = read_torrent_from_file(&filepath).unwrap(); - - self.assert_torrent(&torrent_file).await; - self.assert_torrent_info().await; - self.assert_torrent_announce_urls(&torrent_file).await; - self.assert_torrent_files(&torrent_file).await; + let filepath_01 = self.torrent_file_path(upload_path, self.test_data.torrent_01.torrent_id); + let filepath_02 = self.torrent_file_path(upload_path, self.test_data.torrent_02.torrent_id); + + let torrent_file_01 = read_torrent_from_file(&filepath_01).unwrap(); + let torrent_file_02 = read_torrent_from_file(&filepath_02).unwrap(); + + // Check torrent 01 + self.assert_torrent(&self.test_data.torrent_01, &torrent_file_01) + .await; + self.assert_torrent_info(&self.test_data.torrent_01).await; + self.assert_torrent_announce_urls(&self.test_data.torrent_01, &torrent_file_01) + .await; + self.assert_torrent_files(&self.test_data.torrent_01, &torrent_file_01) + .await; + + // Check torrent 02 + self.assert_torrent(&self.test_data.torrent_02, &torrent_file_02) + .await; + self.assert_torrent_info(&self.test_data.torrent_02).await; + self.assert_torrent_announce_urls(&self.test_data.torrent_02, &torrent_file_02) + .await; + self.assert_torrent_files(&self.test_data.torrent_02, &torrent_file_02) + .await; } pub fn torrent_file_path(&self, upload_path: &str, torrent_id: i64) -> String { @@ -70,24 +120,18 @@ impl TorrentTester { } /// Table `torrust_torrents` - async fn assert_torrent(&self, torrent_file: &Torrent) { + async fn assert_torrent(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { let imported_torrent = self .destiny_database - .get_torrent(self.test_data.torrent.torrent_id) + .get_torrent(torrent.torrent_id) .await .unwrap(); - assert_eq!( - imported_torrent.torrent_id, - self.test_data.torrent.torrent_id - ); + assert_eq!(imported_torrent.torrent_id, torrent.torrent_id); assert_eq!(imported_torrent.uploader_id, self.test_data.user.user_id); - assert_eq!( - imported_torrent.category_id, - self.test_data.torrent.category_id - ); - assert_eq!(imported_torrent.info_hash, self.test_data.torrent.info_hash); - assert_eq!(imported_torrent.size, self.test_data.torrent.file_size); + assert_eq!(imported_torrent.category_id, torrent.category_id); + assert_eq!(imported_torrent.info_hash, torrent.info_hash); + assert_eq!(imported_torrent.size, torrent.file_size); assert_eq!(imported_torrent.name, torrent_file.info.name); assert_eq!( imported_torrent.pieces, @@ -108,28 +152,32 @@ impl TorrentTester { ); assert_eq!( imported_torrent.date_uploaded, - convert_timestamp_to_datetime(self.test_data.torrent.upload_date) + convert_timestamp_to_datetime(torrent.upload_date) ); } /// Table `torrust_torrent_info` - async fn assert_torrent_info(&self) { + async fn assert_torrent_info(&self, torrent: &TorrentRecordV1) { let torrent_info = self .destiny_database - .get_torrent_info(self.test_data.torrent.torrent_id) + .get_torrent_info(torrent.torrent_id) .await .unwrap(); - assert_eq!(torrent_info.torrent_id, self.test_data.torrent.torrent_id); - assert_eq!(torrent_info.title, self.test_data.torrent.title); - assert_eq!(torrent_info.description, self.test_data.torrent.description); + assert_eq!(torrent_info.torrent_id, torrent.torrent_id); + assert_eq!(torrent_info.title, torrent.title); + assert_eq!(torrent_info.description, torrent.description); } /// Table `torrust_torrent_announce_urls` - async fn assert_torrent_announce_urls(&self, torrent_file: &Torrent) { + async fn assert_torrent_announce_urls( + &self, + torrent: &TorrentRecordV1, + torrent_file: &Torrent, + ) { let torrent_announce_urls = self .destiny_database - .get_torrent_announce_urls(self.test_data.torrent.torrent_id) + .get_torrent_announce_urls(torrent.torrent_id) .await .unwrap(); @@ -144,24 +192,37 @@ impl TorrentTester { } /// Table `torrust_torrent_files` - async fn assert_torrent_files(&self, torrent_file: &Torrent) { + async fn assert_torrent_files(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { let db_torrent_files = self .destiny_database - .get_torrent_files(self.test_data.torrent.torrent_id) + .get_torrent_files(torrent.torrent_id) .await .unwrap(); if torrent_file.is_a_single_file_torrent() { let db_torrent_file = &db_torrent_files[0]; - assert_eq!( - db_torrent_file.torrent_id, - self.test_data.torrent.torrent_id - ); + assert_eq!(db_torrent_file.torrent_id, torrent.torrent_id); assert!(db_torrent_file.md5sum.is_none()); assert_eq!(db_torrent_file.length, torrent_file.info.length.unwrap()); assert!(db_torrent_file.path.is_none()); } else { - todo!(); + let files = torrent_file.info.files.as_ref().unwrap(); + + // Files in torrent file + for file in files.iter() { + let file_path = file.path.join("/"); + + // Find file in database + let db_torrent_file = db_torrent_files + .iter() + .find(|&f| f.path == Some(file_path.clone())) + .unwrap(); + + assert_eq!(db_torrent_file.torrent_id, torrent.torrent_id); + assert!(db_torrent_file.md5sum.is_none()); + assert_eq!(db_torrent_file.length, file.length); + assert_eq!(db_torrent_file.path, Some(file_path)); + } } } } From e23d94885f7fa22197dfa3ad458413572c087e53 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 11 Nov 2022 17:42:19 +0000 Subject: [PATCH 057/357] refactor: remove duplication in tests --- .../testers/torrent_tester.rs | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index 28e20e18..d7ec1e39 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -15,8 +15,7 @@ pub struct TorrentTester { } pub struct TestData { - pub torrent_01: TorrentRecordV1, - pub torrent_02: TorrentRecordV1, + pub torrents: Vec, pub user: UserRecordV1, } @@ -71,48 +70,30 @@ impl TorrentTester { source_database, destiny_database, test_data: TestData { - torrent_01, - torrent_02, + torrents: vec![torrent_01, torrent_02], user: user.clone(), }, } } pub async fn load_data_into_source_db(&self) { - self.source_database - .insert_torrent(&self.test_data.torrent_01) - .await - .unwrap(); - self.source_database - .insert_torrent(&self.test_data.torrent_02) - .await - .unwrap(); + for torrent in &self.test_data.torrents { + self.source_database.insert_torrent(&torrent).await.unwrap(); + } } pub async fn assert_data_in_destiny_db(&self, upload_path: &str) { - let filepath_01 = self.torrent_file_path(upload_path, self.test_data.torrent_01.torrent_id); - let filepath_02 = self.torrent_file_path(upload_path, self.test_data.torrent_02.torrent_id); - - let torrent_file_01 = read_torrent_from_file(&filepath_01).unwrap(); - let torrent_file_02 = read_torrent_from_file(&filepath_02).unwrap(); - - // Check torrent 01 - self.assert_torrent(&self.test_data.torrent_01, &torrent_file_01) - .await; - self.assert_torrent_info(&self.test_data.torrent_01).await; - self.assert_torrent_announce_urls(&self.test_data.torrent_01, &torrent_file_01) - .await; - self.assert_torrent_files(&self.test_data.torrent_01, &torrent_file_01) - .await; - - // Check torrent 02 - self.assert_torrent(&self.test_data.torrent_02, &torrent_file_02) - .await; - self.assert_torrent_info(&self.test_data.torrent_02).await; - self.assert_torrent_announce_urls(&self.test_data.torrent_02, &torrent_file_02) - .await; - self.assert_torrent_files(&self.test_data.torrent_02, &torrent_file_02) - .await; + for torrent in &self.test_data.torrents { + let filepath = self.torrent_file_path(upload_path, torrent.torrent_id); + + let torrent_file = read_torrent_from_file(&filepath).unwrap(); + + self.assert_torrent(&torrent, &torrent_file).await; + self.assert_torrent_info(&torrent).await; + self.assert_torrent_announce_urls(&torrent, &torrent_file) + .await; + self.assert_torrent_files(&torrent, &torrent_file).await; + } } pub fn torrent_file_path(&self, upload_path: &str, torrent_id: i64) -> String { From e1790f6991d693aaf4fe72d1c13cd9c10cd488d3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 11 Nov 2022 18:28:31 +0000 Subject: [PATCH 058/357] refactor: [#56] extract mods in upgrader --- .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 27 ++ src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 3 +- .../transferrers/category_transferrer.rs | 39 ++ .../from_v1_0_0_to_v2_0_0/transferrers/mod.rs | 4 + .../transferrers/torrent_transferrer.rs | 198 +++++++++ .../transferrers/tracker_key_transferrer.rs | 45 ++ .../transferrers/user_transferrer.rs | 80 ++++ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 392 +----------------- .../testers/torrent_tester.rs | 2 +- 9 files changed, 408 insertions(+), 382 deletions(-) create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/mod.rs create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs index fa37d81b..0cc2e300 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs @@ -1,2 +1,29 @@ +use self::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use self::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; + pub mod sqlite_v1_0_0; pub mod sqlite_v2_0_0; + +pub async fn current_db(db_filename: &str) -> Arc { + let source_database_connect_url = format!("sqlite://{}?mode=ro", db_filename); + Arc::new(SqliteDatabaseV1_0_0::new(&source_database_connect_url).await) +} + +pub async fn new_db(db_filename: &str) -> Arc { + let dest_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); + Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) +} + +pub async fn migrate_destiny_database(dest_database: Arc) { + println!("Running migrations in destiny database..."); + dest_database.migrate().await; +} + +pub async fn reset_destiny_database(dest_database: Arc) { + println!("Truncating all tables in destiny database ..."); + dest_database + .delete_all_database_rows() + .await + .expect("Can't reset destiny database."); +} diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs index ef4843d0..afb35f90 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -1,2 +1,3 @@ +pub mod databases; +pub mod transferrers; pub mod upgrader; -pub mod databases; \ No newline at end of file diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs new file mode 100644 index 00000000..b8e20515 --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -0,0 +1,39 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; + +pub async fn transfer_categories( + source_database: Arc, + dest_database: Arc, +) { + println!("Transferring categories ..."); + + let source_categories = source_database.get_categories_order_by_id().await.unwrap(); + println!("[v1] categories: {:?}", &source_categories); + + let result = dest_database.reset_categories_sequence().await.unwrap(); + println!("[v2] reset categories sequence result {:?}", result); + + for cat in &source_categories { + println!( + "[v2] adding category {:?} with id {:?} ...", + &cat.name, &cat.category_id + ); + let id = dest_database + .insert_category_and_get_id(&cat.name) + .await + .unwrap(); + + if id != cat.category_id { + panic!( + "Error copying category {:?} from source DB to destiny DB", + &cat.category_id + ); + } + + println!("[v2] category: {:?} {:?} added.", id, &cat.name); + } + + let dest_categories = dest_database.get_categories().await.unwrap(); + println!("[v2] categories: {:?}", &dest_categories); +} diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/mod.rs new file mode 100644 index 00000000..94eaac75 --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/mod.rs @@ -0,0 +1,4 @@ +pub mod category_transferrer; +pub mod torrent_transferrer; +pub mod tracker_key_transferrer; +pub mod user_transferrer; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs new file mode 100644 index 00000000..bcb096b0 --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs @@ -0,0 +1,198 @@ +use crate::models::torrent_file::Torrent; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::TorrentRecordV2; +use crate::utils::parse_torrent::decode_torrent; +use std::sync::Arc; +use std::{error, fs}; + +pub async fn transfer_torrents( + source_database: Arc, + dest_database: Arc, + upload_path: &str, +) { + println!("Transferring torrents ..."); + + // Transfer table `torrust_torrents_files` + + // Although the The table `torrust_torrents_files` existed in version v1.0.0 + // it was was not used. + + // Transfer table `torrust_torrents` + + let torrents = source_database.get_torrents().await.unwrap(); + + for torrent in &torrents { + // [v2] table torrust_torrents + + println!( + "[v2][torrust_torrents] adding the torrent: {:?} ...", + &torrent.torrent_id + ); + + let uploader = source_database + .get_user_by_username(&torrent.uploader) + .await + .unwrap(); + + if uploader.username != torrent.uploader { + panic!( + "Error copying torrent with id {:?}. + Username (`uploader`) in `torrust_torrents` table does not match `username` in `torrust_users` table", + &torrent.torrent_id + ); + } + + let filepath = format!("{}/{}.torrent", upload_path, &torrent.torrent_id); + + let torrent_from_file_result = read_torrent_from_file(&filepath); + + if torrent_from_file_result.is_err() { + panic!("Error torrent file not found: {:?}", &filepath); + } + + let torrent_from_file = torrent_from_file_result.unwrap(); + + let id = dest_database + .insert_torrent(&TorrentRecordV2::from_v1_data( + torrent, + &torrent_from_file.info, + &uploader, + )) + .await + .unwrap(); + + if id != torrent.torrent_id { + panic!( + "Error copying torrent {:?} from source DB to destiny DB", + &torrent.torrent_id + ); + } + + println!( + "[v2][torrust_torrents] torrent with id {:?} added.", + &torrent.torrent_id + ); + + // [v2] table torrust_torrent_files + + println!("[v2][torrust_torrent_files] adding torrent files"); + + if torrent_from_file.is_a_single_file_torrent() { + // The torrent contains only one file then: + // - "path" is NULL + // - "md5sum" can be NULL + + println!( + "[v2][torrust_torrent_files][single-file-torrent] adding torrent file {:?} with length {:?} ...", + &torrent_from_file.info.name, &torrent_from_file.info.length, + ); + + let file_id = dest_database + .insert_torrent_file_for_torrent_with_one_file( + torrent.torrent_id, + // TODO: it seems med5sum can be None. Why? When? + &torrent_from_file.info.md5sum.clone(), + torrent_from_file.info.length.unwrap(), + ) + .await; + + println!( + "[v2][torrust_torrent_files][single-file-torrent] torrent file insert result: {:?}", + &file_id + ); + } else { + // Multiple files are being shared + let files = torrent_from_file.info.files.as_ref().unwrap(); + + for file in files.iter() { + println!( + "[v2][torrust_torrent_files][multiple-file-torrent] adding torrent file: {:?} ...", + &file + ); + + let file_id = dest_database + .insert_torrent_file_for_torrent_with_multiple_files(torrent, file) + .await; + + println!( + "[v2][torrust_torrent_files][multiple-file-torrent] torrent file insert result: {:?}", + &file_id + ); + } + } + + // [v2] table torrust_torrent_info + + println!( + "[v2][torrust_torrent_info] adding the torrent info for torrent id {:?} ...", + &torrent.torrent_id + ); + + let id = dest_database.insert_torrent_info(torrent).await; + + println!( + "[v2][torrust_torrents] torrent info insert result: {:?}.", + &id + ); + + // [v2] table torrust_torrent_announce_urls + + println!( + "[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent id {:?} ...", + &torrent.torrent_id + ); + + if torrent_from_file.announce_list.is_some() { + // BEP-0012. Multiple trackers. + + println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); + + // flatten the nested vec (this will however remove the) + let announce_urls = torrent_from_file + .announce_list + .clone() + .unwrap() + .into_iter() + .flatten() + .collect::>(); + + for tracker_url in announce_urls.iter() { + println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); + + let announce_url_id = dest_database + .insert_torrent_announce_url(torrent.torrent_id, tracker_url) + .await; + + println!("[v2][torrust_torrent_announce_urls][announce-list] torrent announce url insert result {:?} ...", &announce_url_id); + } + } else if torrent_from_file.announce.is_some() { + println!("[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); + + let announce_url_id = dest_database + .insert_torrent_announce_url( + torrent.torrent_id, + &torrent_from_file.announce.unwrap(), + ) + .await; + + println!( + "[v2][torrust_torrent_announce_urls][announce] torrent announce url insert result {:?} ...", + &announce_url_id + ); + } + } + println!("Torrents transferred"); +} + +pub fn read_torrent_from_file(path: &str) -> Result> { + let contents = match fs::read(path) { + Ok(contents) => contents, + Err(e) => return Err(e.into()), + }; + + match decode_torrent(&contents) { + Ok(torrent) => Ok(torrent), + Err(e) => Err(e), + } +} diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs new file mode 100644 index 00000000..e639739a --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs @@ -0,0 +1,45 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; + +pub async fn transfer_tracker_keys( + source_database: Arc, + dest_database: Arc, +) { + println!("Transferring tracker keys ..."); + + // Transfer table `torrust_tracker_keys` + + let tracker_keys = source_database.get_tracker_keys().await.unwrap(); + + for tracker_key in &tracker_keys { + // [v2] table torrust_tracker_keys + + println!( + "[v2][torrust_users] adding the tracker key with id {:?} ...", + &tracker_key.key_id + ); + + let id = dest_database + .insert_tracker_key( + tracker_key.key_id, + tracker_key.user_id, + &tracker_key.key, + tracker_key.valid_until, + ) + .await + .unwrap(); + + if id != tracker_key.key_id { + panic!( + "Error copying tracker key {:?} from source DB to destiny DB", + &tracker_key.key_id + ); + } + + println!( + "[v2][torrust_tracker_keys] tracker key with id {:?} added.", + &tracker_key.key_id + ); + } +} diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs new file mode 100644 index 00000000..18d8d680 --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs @@ -0,0 +1,80 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; + +pub async fn transfer_users( + source_database: Arc, + dest_database: Arc, + date_imported: &str, +) { + println!("Transferring users ..."); + + // Transfer table `torrust_users` + + let users = source_database.get_users().await.unwrap(); + + for user in &users { + // [v2] table torrust_users + + println!( + "[v2][torrust_users] adding user with username {:?} and id {:?} ...", + &user.username, &user.user_id + ); + + let id = dest_database + .insert_imported_user(user.user_id, date_imported, user.administrator) + .await + .unwrap(); + + if id != user.user_id { + panic!( + "Error copying user {:?} from source DB to destiny DB", + &user.user_id + ); + } + + println!( + "[v2][torrust_users] user: {:?} {:?} added.", + &user.user_id, &user.username + ); + + // [v2] table torrust_user_profiles + + println!( + "[v2][torrust_user_profiles] adding user profile for user with username {:?} and id {:?} ...", + &user.username, &user.user_id + ); + + dest_database + .insert_user_profile( + user.user_id, + &user.username, + &user.email, + user.email_verified, + ) + .await + .unwrap(); + + println!( + "[v2][torrust_user_profiles] user profile added for user with username {:?} and id {:?}.", + &user.username, &user.user_id + ); + + // [v2] table torrust_user_authentication + + println!( + "[v2][torrust_user_authentication] adding password hash ({:?}) for user id ({:?}) ...", + &user.password, &user.user_id + ); + + dest_database + .insert_user_password_hash(user.user_id, &user.password) + .await + .unwrap(); + + println!( + "[v2][torrust_user_authentication] password hash ({:?}) added for user id ({:?}).", + &user.password, &user.user_id + ); + } +} diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 91e42931..e2c32c52 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -3,7 +3,7 @@ //! NOTES for `torrust_users` table transfer: //! //! - In v2, the table `torrust_user` contains a field `date_registered` non existing in v1. -//! We changed that columns to allow NULL. WE also added the new column `date_imported` with +//! We changed that columns to allow NULL. We also added the new column `date_imported` with //! the datetime when the upgrader was executed. //! //! NOTES for `torrust_user_profiles` table transfer: @@ -11,18 +11,18 @@ //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{ - SqliteDatabaseV2_0_0, TorrentRecordV2, -}; -use crate::utils::parse_torrent::decode_torrent; -use crate::{ - models::torrent_file::Torrent, - upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0, +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{ + current_db, migrate_destiny_database, new_db, reset_destiny_database, }; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::category_transferrer::transfer_categories; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::transfer_torrents; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::tracker_key_transferrer::transfer_tracker_keys; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::user_transferrer::transfer_users; + use chrono::prelude::{DateTime, Utc}; -use std::{env, error, fs}; -use std::{sync::Arc, time::SystemTime}; +use std::env; +use std::time::SystemTime; use text_colorizer::*; @@ -86,8 +86,9 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) { migrate_destiny_database(dest_database.clone()).await; reset_destiny_database(dest_database.clone()).await; + transfer_categories(source_database.clone(), dest_database.clone()).await; - transfer_user_data( + transfer_users( source_database.clone(), dest_database.clone(), date_imported, @@ -102,378 +103,9 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) { .await; } -async fn current_db(db_filename: &str) -> Arc { - let source_database_connect_url = format!("sqlite://{}?mode=ro", db_filename); - Arc::new(SqliteDatabaseV1_0_0::new(&source_database_connect_url).await) -} - -async fn new_db(db_filename: &str) -> Arc { - let dest_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); - Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) -} - -async fn migrate_destiny_database(dest_database: Arc) { - println!("Running migrations in destiny database..."); - dest_database.migrate().await; -} - -async fn reset_destiny_database(dest_database: Arc) { - println!("Truncating all tables in destiny database ..."); - dest_database - .delete_all_database_rows() - .await - .expect("Can't reset destiny database."); -} - -async fn transfer_categories( - source_database: Arc, - dest_database: Arc, -) { - println!("Transferring categories ..."); - - let source_categories = source_database.get_categories_order_by_id().await.unwrap(); - println!("[v1] categories: {:?}", &source_categories); - - let result = dest_database.reset_categories_sequence().await.unwrap(); - println!("[v2] reset categories sequence result {:?}", result); - - for cat in &source_categories { - println!( - "[v2] adding category {:?} with id {:?} ...", - &cat.name, &cat.category_id - ); - let id = dest_database - .insert_category_and_get_id(&cat.name) - .await - .unwrap(); - - if id != cat.category_id { - panic!( - "Error copying category {:?} from source DB to destiny DB", - &cat.category_id - ); - } - - println!("[v2] category: {:?} {:?} added.", id, &cat.name); - } - - let dest_categories = dest_database.get_categories().await.unwrap(); - println!("[v2] categories: {:?}", &dest_categories); -} - -async fn transfer_user_data( - source_database: Arc, - dest_database: Arc, - date_imported: &str, -) { - println!("Transferring users ..."); - - // Transfer table `torrust_users` - - let users = source_database.get_users().await.unwrap(); - - for user in &users { - // [v2] table torrust_users - - println!( - "[v2][torrust_users] adding user with username {:?} and id {:?} ...", - &user.username, &user.user_id - ); - - let id = dest_database - .insert_imported_user(user.user_id, date_imported, user.administrator) - .await - .unwrap(); - - if id != user.user_id { - panic!( - "Error copying user {:?} from source DB to destiny DB", - &user.user_id - ); - } - - println!( - "[v2][torrust_users] user: {:?} {:?} added.", - &user.user_id, &user.username - ); - - // [v2] table torrust_user_profiles - - println!( - "[v2][torrust_user_profiles] adding user profile for user with username {:?} and id {:?} ...", - &user.username, &user.user_id - ); - - dest_database - .insert_user_profile( - user.user_id, - &user.username, - &user.email, - user.email_verified, - ) - .await - .unwrap(); - - println!( - "[v2][torrust_user_profiles] user profile added for user with username {:?} and id {:?}.", - &user.username, &user.user_id - ); - - // [v2] table torrust_user_authentication - - println!( - "[v2][torrust_user_authentication] adding password hash ({:?}) for user id ({:?}) ...", - &user.password, &user.user_id - ); - - dest_database - .insert_user_password_hash(user.user_id, &user.password) - .await - .unwrap(); - - println!( - "[v2][torrust_user_authentication] password hash ({:?}) added for user id ({:?}).", - &user.password, &user.user_id - ); - } -} - /// Current datetime in ISO8601 without time zone. /// For example: 2022-11-10 10:35:15 pub fn datetime_iso_8601() -> String { let dt: DateTime = SystemTime::now().into(); format!("{}", dt.format("%Y-%m-%d %H:%M:%S")) } - -async fn transfer_tracker_keys( - source_database: Arc, - dest_database: Arc, -) { - println!("Transferring tracker keys ..."); - - // Transfer table `torrust_tracker_keys` - - let tracker_keys = source_database.get_tracker_keys().await.unwrap(); - - for tracker_key in &tracker_keys { - // [v2] table torrust_tracker_keys - - println!( - "[v2][torrust_users] adding the tracker key with id {:?} ...", - &tracker_key.key_id - ); - - let id = dest_database - .insert_tracker_key( - tracker_key.key_id, - tracker_key.user_id, - &tracker_key.key, - tracker_key.valid_until, - ) - .await - .unwrap(); - - if id != tracker_key.key_id { - panic!( - "Error copying tracker key {:?} from source DB to destiny DB", - &tracker_key.key_id - ); - } - - println!( - "[v2][torrust_tracker_keys] tracker key with id {:?} added.", - &tracker_key.key_id - ); - } -} - -async fn transfer_torrents( - source_database: Arc, - dest_database: Arc, - upload_path: &str, -) { - println!("Transferring torrents ..."); - - // Transfer table `torrust_torrents_files` - - // Although the The table `torrust_torrents_files` existed in version v1.0.0 - // it was was not used. - - // Transfer table `torrust_torrents` - - let torrents = source_database.get_torrents().await.unwrap(); - - for torrent in &torrents { - // [v2] table torrust_torrents - - println!( - "[v2][torrust_torrents] adding the torrent: {:?} ...", - &torrent.torrent_id - ); - - let uploader = source_database - .get_user_by_username(&torrent.uploader) - .await - .unwrap(); - - if uploader.username != torrent.uploader { - panic!( - "Error copying torrent with id {:?}. - Username (`uploader`) in `torrust_torrents` table does not match `username` in `torrust_users` table", - &torrent.torrent_id - ); - } - - let filepath = format!("{}/{}.torrent", upload_path, &torrent.torrent_id); - - let torrent_from_file_result = read_torrent_from_file(&filepath); - - if torrent_from_file_result.is_err() { - panic!("Error torrent file not found: {:?}", &filepath); - } - - let torrent_from_file = torrent_from_file_result.unwrap(); - - let id = dest_database - .insert_torrent(&TorrentRecordV2::from_v1_data( - torrent, - &torrent_from_file.info, - &uploader, - )) - .await - .unwrap(); - - if id != torrent.torrent_id { - panic!( - "Error copying torrent {:?} from source DB to destiny DB", - &torrent.torrent_id - ); - } - - println!( - "[v2][torrust_torrents] torrent with id {:?} added.", - &torrent.torrent_id - ); - - // [v2] table torrust_torrent_files - - println!("[v2][torrust_torrent_files] adding torrent files"); - - if torrent_from_file.is_a_single_file_torrent() { - // The torrent contains only one file then: - // - "path" is NULL - // - "md5sum" can be NULL - - println!( - "[v2][torrust_torrent_files][single-file-torrent] adding torrent file {:?} with length {:?} ...", - &torrent_from_file.info.name, &torrent_from_file.info.length, - ); - - let file_id = dest_database - .insert_torrent_file_for_torrent_with_one_file( - torrent.torrent_id, - // TODO: it seems med5sum can be None. Why? When? - &torrent_from_file.info.md5sum.clone(), - torrent_from_file.info.length.unwrap(), - ) - .await; - - println!( - "[v2][torrust_torrent_files][single-file-torrent] torrent file insert result: {:?}", - &file_id - ); - } else { - // Multiple files are being shared - let files = torrent_from_file.info.files.as_ref().unwrap(); - - for file in files.iter() { - println!( - "[v2][torrust_torrent_files][multiple-file-torrent] adding torrent file: {:?} ...", - &file - ); - - let file_id = dest_database - .insert_torrent_file_for_torrent_with_multiple_files(torrent, file) - .await; - - println!( - "[v2][torrust_torrent_files][multiple-file-torrent] torrent file insert result: {:?}", - &file_id - ); - } - } - - // [v2] table torrust_torrent_info - - println!( - "[v2][torrust_torrent_info] adding the torrent info for torrent id {:?} ...", - &torrent.torrent_id - ); - - let id = dest_database.insert_torrent_info(torrent).await; - - println!( - "[v2][torrust_torrents] torrent info insert result: {:?}.", - &id - ); - - // [v2] table torrust_torrent_announce_urls - - println!( - "[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent id {:?} ...", - &torrent.torrent_id - ); - - if torrent_from_file.announce_list.is_some() { - // BEP-0012. Multiple trackers. - - println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); - - // flatten the nested vec (this will however remove the) - let announce_urls = torrent_from_file - .announce_list - .clone() - .unwrap() - .into_iter() - .flatten() - .collect::>(); - - for tracker_url in announce_urls.iter() { - println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); - - let announce_url_id = dest_database - .insert_torrent_announce_url(torrent.torrent_id, tracker_url) - .await; - - println!("[v2][torrust_torrent_announce_urls][announce-list] torrent announce url insert result {:?} ...", &announce_url_id); - } - } else if torrent_from_file.announce.is_some() { - println!("[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); - - let announce_url_id = dest_database - .insert_torrent_announce_url( - torrent.torrent_id, - &torrent_from_file.announce.unwrap(), - ) - .await; - - println!( - "[v2][torrust_torrent_announce_urls][announce] torrent announce url insert result {:?} ...", - &announce_url_id - ); - } - } - println!("Torrents transferred"); -} - -pub fn read_torrent_from_file(path: &str) -> Result> { - let contents = match fs::read(path) { - Ok(contents) => contents, - Err(e) => return Err(e.into()), - }; - - match decode_torrent(&contents) { - Ok(torrent) => Ok(torrent), - Err(e) => Err(e), - } -} diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index d7ec1e39..79256e86 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -6,7 +6,7 @@ use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1 TorrentRecordV1, UserRecordV1, }; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::convert_timestamp_to_datetime; -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::read_torrent_from_file; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::read_torrent_from_file; pub struct TorrentTester { source_database: Arc, From b9a8bf92008d87e99af9261475b784f29f0df6d1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 11 Nov 2022 18:32:07 +0000 Subject: [PATCH 059/357] fix: [#56] remove comment We do not need to read migrations from dir becuase they are not going to change for verion v1.0.0. --- tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index cbc2a055..078f3a58 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -27,7 +27,6 @@ impl SqliteDatabaseV1_0_0 { pub async fn migrate(&self, fixtures_dir: &str) { let migrations_dir = format!("{}database/v1.0.0/migrations/", fixtures_dir); - // TODO: read files from dir let migrations = vec![ "20210831113004_torrust_users.sql", "20210904135524_torrust_tracker_keys.sql", From 38fee53ed46c9bd200dc31ab7dd5479c916005df Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 14 Nov 2022 14:12:28 +0000 Subject: [PATCH 060/357] test: [#56] new test for password verification The application now supports two hashing methods: - "pbkdf2-sha256": the old one. Only for imported users from DB version v1.0.0. - "argon2": the new one for registered users. --- src/routes/user.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/routes/user.rs b/src/routes/user.rs index 9195be7a..0feef088 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -279,3 +279,39 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult Date: Mon, 14 Nov 2022 16:18:17 +0000 Subject: [PATCH 061/357] fix: [#56] db migration for imported users Imported users from DB version v1.0.0 (only SQLite) do not have a "date_registered" field. WE have to copy that behavior in MySQL even if we do not have users imported from from previous versions in MySQL. Support for MySQL was added after the version v1.0.0. --- .../20221109092556_torrust_user_date_registered_allow_null.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql b/migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql index 9f936f8a..92949e96 100644 --- a/migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql +++ b/migrations/mysql/20221109092556_torrust_user_date_registered_allow_null.sql @@ -1 +1 @@ -ALTER TABLE torrust_users CHANGE date_registered date_registered DATETIME NOT NULL \ No newline at end of file +ALTER TABLE torrust_users CHANGE date_registered date_registered DATETIME DEFAULT NULL \ No newline at end of file From 8b761c8c1d17814c4582df7804465ff125b8ec9b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 28 Nov 2022 13:13:23 +0000 Subject: [PATCH 062/357] feat: [#56] keep category id in DB migration script Instead of regenerating ID sequence we keep the category id. Becuase we were keeping IDs for all tables except for this one. @ldpr helped testing the migration script and found the issue with the categories IDs. Co-authored-by: ldpr <103618016+ldpr@users.noreply.github.com> --- .../databases/sqlite_v1_0_0.rs | 6 +- .../databases/sqlite_v2_0_0.rs | 9 +++ .../transferrers/category_transferrer.rs | 11 ++- .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 19 ++++- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 13 ++++ .../testers/category_tester.rs | 70 +++++++++++++++++++ .../from_v1_0_0_to_v2_0_0/testers/mod.rs | 1 + .../testers/torrent_tester.rs | 25 +++---- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 5 ++ 9 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index a5743000..bec424ae 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -5,7 +5,7 @@ use sqlx::{query_as, SqlitePool}; use crate::databases::database::DatabaseError; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct CategoryRecord { +pub struct CategoryRecordV1 { pub category_id: i64, pub name: String, } @@ -64,8 +64,8 @@ impl SqliteDatabaseV1_0_0 { Self { pool: db } } - pub async fn get_categories_order_by_id(&self) -> Result, DatabaseError> { - query_as::<_, CategoryRecord>( + pub async fn get_categories_order_by_id(&self) -> Result, DatabaseError> { + query_as::<_, CategoryRecordV1>( "SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC", ) .fetch_all(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index bee97bc2..828a63b9 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -117,6 +117,15 @@ impl SqliteDatabaseV2_0_0 { }) } + pub async fn insert_category(&self, category: &CategoryRecordV2) -> Result { + query("INSERT INTO torrust_categories (category_id, name) VALUES (?, ?)") + .bind(category.category_id) + .bind(category.name.clone()) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + pub async fn insert_imported_user( &self, user_id: i64, diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs index b8e20515..c48c27bf 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -1,5 +1,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{ + CategoryRecordV2, SqliteDatabaseV2_0_0, +}; use std::sync::Arc; pub async fn transfer_categories( @@ -12,7 +14,7 @@ pub async fn transfer_categories( println!("[v1] categories: {:?}", &source_categories); let result = dest_database.reset_categories_sequence().await.unwrap(); - println!("[v2] reset categories sequence result {:?}", result); + println!("[v2] reset categories sequence result: {:?}", result); for cat in &source_categories { println!( @@ -20,7 +22,10 @@ pub async fn transfer_categories( &cat.name, &cat.category_id ); let id = dest_database - .insert_category_and_get_id(&cat.name) + .insert_category(&CategoryRecordV2 { + category_id: cat.category_id, + name: cat.name.clone(), + }) .await .unwrap(); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index 078f3a58..73f7d556 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -2,7 +2,7 @@ use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query, SqlitePool}; use std::fs; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::{ - TorrentRecordV1, TrackerKeyRecordV1, UserRecordV1, + CategoryRecordV1, TorrentRecordV1, TrackerKeyRecordV1, UserRecordV1, }; pub struct SqliteDatabaseV1_0_0 { @@ -54,6 +54,23 @@ impl SqliteDatabaseV1_0_0 { println!("Migration result {:?}", res); } + pub async fn insert_category(&self, category: &CategoryRecordV1) -> Result { + query("INSERT INTO torrust_categories (category_id, name) VALUES (?, ?)") + .bind(category.category_id) + .bind(category.name.clone()) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) + } + + pub async fn delete_all_categories(&self) -> Result<(), sqlx::Error> { + query("DELETE FROM torrust_categories") + .execute(&self.pool) + .await + .unwrap(); + Ok(()) + } + pub async fn insert_user(&self, user: &UserRecordV1) -> Result { query("INSERT INTO torrust_users (user_id, username, email, email_verified, password, administrator) VALUES (?, ?, ?, ?, ?, ?)") .bind(user.user_id) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index 20a55daa..eea5f354 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -3,6 +3,12 @@ use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query_as, SqlitePool}; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::TorrentRecordV2; +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct CategoryRecordV2 { + pub category_id: i64, + pub name: String, +} + #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct UserRecordV2 { pub user_id: i64, @@ -76,6 +82,13 @@ impl SqliteDatabaseV2_0_0 { Self { pool: db } } + pub async fn get_category(&self, category_id: i64) -> Result { + query_as::<_, CategoryRecordV2>("SELECT * FROM torrust_categories WHERE category_id = ?") + .bind(category_id) + .fetch_one(&self.pool) + .await + } + pub async fn get_user(&self, user_id: i64) -> Result { query_as::<_, UserRecordV2>("SELECT * FROM torrust_users WHERE user_id = ?") .bind(user_id) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs new file mode 100644 index 00000000..e8e79d54 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs @@ -0,0 +1,70 @@ +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::CategoryRecordV1; + +pub struct CategoryTester { + source_database: Arc, + destiny_database: Arc, + test_data: TestData, +} + +pub struct TestData { + pub categories: Vec, +} + +impl CategoryTester { + pub fn new( + source_database: Arc, + destiny_database: Arc, + ) -> Self { + let category_01 = CategoryRecordV1 { + category_id: 10, + name: "category name 10".to_string(), + }; + let category_02 = CategoryRecordV1 { + category_id: 11, + name: "category name 11".to_string(), + }; + + Self { + source_database, + destiny_database, + test_data: TestData { + categories: vec![category_01, category_02], + }, + } + } + + pub fn get_valid_category_id(&self) -> i64 { + self.test_data.categories[0].category_id + } + + /// Table `torrust_categories` + pub async fn load_data_into_source_db(&self) { + // Delete categories added by migrations + self.source_database.delete_all_categories().await.unwrap(); + + // Add test categories + for categories in &self.test_data.categories { + self.source_database + .insert_category(&categories) + .await + .unwrap(); + } + } + + /// Table `torrust_categories` + pub async fn assert_data_in_destiny_db(&self) { + for categories in &self.test_data.categories { + let imported_category = self + .destiny_database + .get_category(categories.category_id) + .await + .unwrap(); + + assert_eq!(imported_category.category_id, categories.category_id); + assert_eq!(imported_category.name, categories.name); + } + } +} diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs index 730b5149..36629cc3 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs @@ -1,3 +1,4 @@ +pub mod category_tester; pub mod torrent_tester; pub mod tracker_key_tester; pub mod user_tester; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index 79256e86..9b4c8c2a 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -24,19 +24,20 @@ impl TorrentTester { source_database: Arc, destiny_database: Arc, user: &UserRecordV1, + category_id: i64, ) -> Self { let torrent_01 = TorrentRecordV1 { torrent_id: 1, uploader: user.username.clone(), info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), title: "A Mandelbrot Set 2048x2048px picture".to_string(), - category_id: 1, + category_id, description: Some( - "A beautiful Mandelbrot Set picture in black and white. \ - - Hybrid torrent V1 and V2. \ - - Single-file torrent. \ - - Public. \ - - More than one tracker URL. \ + "A beautiful Mandelbrot Set picture in black and white. \n \ + - Hybrid torrent V1 and V2. \n \ + - Single-file torrent. \n \ + - Public. \n \ + - More than one tracker URL. \n \ " .to_string(), ), @@ -50,13 +51,13 @@ impl TorrentTester { uploader: user.username.clone(), info_hash: "0902d375f18ec020f0cc68ed4810023032ba81cb".to_string(), title: "Two Mandelbrot Set 2048x2048px pictures".to_string(), - category_id: 1, + category_id, description: Some( - "Two beautiful Mandelbrot Set pictures in black and white. \ - - Hybrid torrent V1 and V2. \ - - Multiple-files torrent. \ - - Private. - - Only one tracker URL. + "Two beautiful Mandelbrot Set pictures in black and white. \n \ + - Hybrid torrent V1 and V2. \n \ + - Multiple-files torrent. \n \ + - Private. \n \ + - Only one tracker URL. \n \ " .to_string(), ), diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index a40f0a37..ee7ddc8f 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -13,6 +13,7 @@ //! to see the "upgrader" command output. use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::category_tester::CategoryTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::torrent_tester::TorrentTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::tracker_key_tester::TrackerKeyTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_tester::UserTester; @@ -56,6 +57,7 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { // The datetime when the upgrader is executed let execution_time = datetime_iso_8601(); + let category_tester = CategoryTester::new(source_db.clone(), dest_db.clone()); let user_tester = UserTester::new(source_db.clone(), dest_db.clone(), &execution_time); let tracker_key_tester = TrackerKeyTester::new( source_db.clone(), @@ -66,9 +68,11 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { source_db.clone(), dest_db.clone(), &user_tester.test_data.user, + category_tester.get_valid_category_id(), ); // Load data into source database in version v1.0.0 + category_tester.load_data_into_source_db().await; user_tester.load_data_into_source_db().await; tracker_key_tester.load_data_into_source_db().await; torrent_tester.load_data_into_source_db().await; @@ -85,6 +89,7 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { .await; // Assertions for data transferred to the new database in version v2.0.0 + category_tester.assert_data_in_destiny_db().await; user_tester.assert_data_in_destiny_db().await; tracker_key_tester.assert_data_in_destiny_db().await; torrent_tester From b400962657e893810592733d120c6d5bd9701c4a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 29 Nov 2022 17:17:32 +0000 Subject: [PATCH 063/357] fix: format --- build.rs | 2 +- src/databases/sqlite.rs | 81 ++++---------- src/models/tracker_key.rs | 2 +- src/models/user.rs | 2 +- src/routes/user.rs | 16 ++- .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 3 +- .../databases/sqlite_v1_0_0.rs | 18 ++- .../databases/sqlite_v2_0_0.rs | 103 ++++++------------ .../transferrers/category_transferrer.rs | 22 +--- .../transferrers/torrent_transferrer.rs | 64 +++++------ .../transferrers/tracker_key_transferrer.rs | 8 +- .../transferrers/user_transferrer.rs | 20 +--- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 35 ++---- src/upgrades/mod.rs | 2 +- .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 16 +-- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 55 +++------- .../testers/category_tester.rs | 22 ++-- .../testers/torrent_tester.rs | 57 +++------- .../testers/tracker_key_tester.rs | 22 ++-- .../testers/user_tester.rs | 44 ++------ .../from_v1_0_0_to_v2_0_0/upgrader.rs | 24 ++-- tests/upgrades/mod.rs | 2 +- 22 files changed, 195 insertions(+), 425 deletions(-) diff --git a/build.rs b/build.rs index 76095938..d5068697 100644 --- a/build.rs +++ b/build.rs @@ -2,4 +2,4 @@ fn main() { // trigger recompilation when a new migration is added println!("cargo:rerun-if-changed=migrations"); -} \ No newline at end of file +} diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 62b197d1..835979fe 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -54,13 +54,12 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error)?; // add password hash for account - let insert_user_auth_result = - query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") - .bind(user_id) - .bind(password_hash) - .execute(&mut tx) - .await - .map_err(|_| DatabaseError::Error); + let insert_user_auth_result = query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") + .bind(user_id) + .bind(password_hash) + .execute(&mut tx) + .await + .map_err(|_| DatabaseError::Error); // rollback transaction on error if let Err(e) = insert_user_auth_result { @@ -109,23 +108,15 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::UserNotFound) } - async fn get_user_authentication_from_id( - &self, - user_id: i64, - ) -> Result { - query_as::<_, UserAuthentication>( - "SELECT * FROM torrust_user_authentication WHERE user_id = ?", - ) - .bind(user_id) - .fetch_one(&self.pool) - .await - .map_err(|_| DatabaseError::UserNotFound) + async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { + query_as::<_, UserAuthentication>("SELECT * FROM torrust_user_authentication WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::UserNotFound) } - async fn get_user_profile_from_username( - &self, - username: &str, - ) -> Result { + async fn get_user_profile_from_username(&self, username: &str) -> Result { query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE username = ?") .bind(username) .fetch_one(&self.pool) @@ -164,12 +155,7 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error) } - async fn ban_user( - &self, - user_id: i64, - reason: &str, - date_expiry: NaiveDateTime, - ) -> Result<(), DatabaseError> { + async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), DatabaseError> { // date needs to be in ISO 8601 format let date_expiry_string = date_expiry.format("%Y-%m-%d %H:%M:%S").to_string(); @@ -207,11 +193,7 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error) } - async fn add_tracker_key( - &self, - user_id: i64, - tracker_key: &TrackerKey, - ) -> Result<(), DatabaseError> { + async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), DatabaseError> { let key = tracker_key.key.clone(); query("INSERT INTO torrust_tracker_keys (user_id, tracker_key, date_expiry) VALUES ($1, $2, $3)") @@ -361,10 +343,7 @@ impl Database for SqliteDatabase { category_filter_query ); - let count_query = format!( - "SELECT COUNT(*) as count FROM ({}) AS count_table", - query_string - ); + let count_query = format!("SELECT COUNT(*) as count FROM ({}) AS count_table", query_string); let count_result: Result = query_as(&count_query) .bind(title.clone()) @@ -411,11 +390,7 @@ impl Database for SqliteDatabase { let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { (bytes_to_hex(pieces.as_ref()), false) } else { - let root_hash = torrent - .info - .root_hash - .as_ref() - .ok_or(DatabaseError::Error)?; + let root_hash = torrent.info.root_hash.as_ref().ok_or(DatabaseError::Error)?; (root_hash.to_string(), true) }; @@ -562,10 +537,7 @@ impl Database for SqliteDatabase { )) } - async fn get_torrent_info_from_id( - &self, - torrent_id: i64, - ) -> Result { + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) @@ -604,10 +576,7 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } - async fn get_torrent_listing_from_id( - &self, - torrent_id: i64, - ) -> Result { + async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -632,11 +601,7 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::Error) } - async fn update_torrent_title( - &self, - torrent_id: i64, - title: &str, - ) -> Result<(), DatabaseError> { + async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), DatabaseError> { query("UPDATE torrust_torrent_info SET title = $1 WHERE torrent_id = $2") .bind(title) .bind(torrent_id) @@ -661,11 +626,7 @@ impl Database for SqliteDatabase { }) } - async fn update_torrent_description( - &self, - torrent_id: i64, - description: &str, - ) -> Result<(), DatabaseError> { + async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), DatabaseError> { query("UPDATE torrust_torrent_info SET description = $1 WHERE torrent_id = $2") .bind(description) .bind(torrent_id) diff --git a/src/models/tracker_key.rs b/src/models/tracker_key.rs index 15e23622..b1baea72 100644 --- a/src/models/tracker_key.rs +++ b/src/models/tracker_key.rs @@ -17,4 +17,4 @@ pub struct NewTrackerKey { pub struct Duration { pub secs: i64, pub nanos: i64, -} \ No newline at end of file +} diff --git a/src/models/user.rs b/src/models/user.rs index f1418f3a..9a500d4d 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -11,7 +11,7 @@ pub struct User { #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserAuthentication { pub user_id: i64, - pub password_hash: String + pub password_hash: String, } #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)] diff --git a/src/routes/user.rs b/src/routes/user.rs index 0feef088..df9a385a 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -282,17 +282,17 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult Result, DatabaseError> { - query_as::<_, CategoryRecordV1>( - "SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC", - ) - .fetch_all(&self.pool) - .await - .map_err(|_| DatabaseError::Error) + query_as::<_, CategoryRecordV1>("SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC") + .fetch_all(&self.pool) + .await + .map_err(|_| DatabaseError::Error) } pub async fn get_users(&self) -> Result, sqlx::Error> { @@ -99,10 +97,8 @@ impl SqliteDatabaseV1_0_0 { } pub async fn get_torrent_files(&self) -> Result, sqlx::Error> { - query_as::<_, TorrentFileRecordV1>( - "SELECT * FROM torrust_torrent_files ORDER BY file_id ASC", - ) - .fetch_all(&self.pool) - .await + query_as::<_, TorrentFileRecordV1>("SELECT * FROM torrust_torrent_files ORDER BY file_id ASC") + .fetch_all(&self.pool) + .await } } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 828a63b9..35207ad4 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -3,11 +3,10 @@ use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; use sqlx::{query, query_as, SqlitePool}; +use super::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; use crate::databases::database::DatabaseError; use crate::models::torrent_file::{TorrentFile, TorrentInfo}; -use super::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; - #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct CategoryRecordV2 { pub category_id: i64, @@ -30,11 +29,7 @@ pub struct TorrentRecordV2 { } impl TorrentRecordV2 { - pub fn from_v1_data( - torrent: &TorrentRecordV1, - torrent_info: &TorrentInfo, - uploader: &UserRecordV1, - ) -> Self { + pub fn from_v1_data(torrent: &TorrentRecordV1, torrent_info: &TorrentInfo, uploader: &UserRecordV1) -> Self { Self { torrent_id: torrent.torrent_id, uploader_id: uploader.user_id, @@ -96,10 +91,7 @@ impl SqliteDatabaseV2_0_0 { .map_err(|_| DatabaseError::Error) } - pub async fn insert_category_and_get_id( - &self, - category_name: &str, - ) -> Result { + pub async fn insert_category_and_get_id(&self, category_name: &str) -> Result { query("INSERT INTO torrust_categories (name) VALUES (?)") .bind(category_name) .execute(&self.pool) @@ -126,12 +118,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } - pub async fn insert_imported_user( - &self, - user_id: i64, - date_imported: &str, - administrator: bool, - ) -> Result { + pub async fn insert_imported_user(&self, user_id: i64, date_imported: &str, administrator: bool) -> Result { query("INSERT INTO torrust_users (user_id, date_imported, administrator) VALUES (?, ?, ?)") .bind(user_id) .bind(date_imported) @@ -148,21 +135,19 @@ impl SqliteDatabaseV2_0_0 { email: &str, email_verified: bool, ) -> Result { - query("INSERT INTO torrust_user_profiles (user_id, username, email, email_verified, bio, avatar) VALUES (?, ?, ?, ?, ?, ?)") - .bind(user_id) - .bind(username) - .bind(email) - .bind(email_verified) - .execute(&self.pool) - .await - .map(|v| v.last_insert_rowid()) + query( + "INSERT INTO torrust_user_profiles (user_id, username, email, email_verified, bio, avatar) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(user_id) + .bind(username) + .bind(email) + .bind(email_verified) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) } - pub async fn insert_user_password_hash( - &self, - user_id: i64, - password_hash: &str, - ) -> Result { + pub async fn insert_user_password_hash(&self, user_id: i64, password_hash: &str) -> Result { query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") .bind(user_id) .bind(password_hash) @@ -241,15 +226,14 @@ impl SqliteDatabaseV2_0_0 { torrent: &TorrentRecordV1, file: &TorrentFile, ) -> Result { - query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH, PATH) VALUES (?, ?, ?, ?)", - ) - .bind(file.md5sum.clone()) - .bind(torrent.torrent_id) - .bind(file.length) - .bind(file.path.join("/")) - .execute(&self.pool) - .await - .map(|v| v.last_insert_rowid()) + query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, LENGTH, PATH) VALUES (?, ?, ?, ?)") + .bind(file.md5sum.clone()) + .bind(torrent.torrent_id) + .bind(file.length) + .bind(file.path.join("/")) + .execute(&self.pool) + .await + .map(|v| v.last_insert_rowid()) } pub async fn insert_torrent_info(&self, torrent: &TorrentRecordV1) -> Result { @@ -262,11 +246,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } - pub async fn insert_torrent_announce_url( - &self, - torrent_id: i64, - tracker_url: &str, - ) -> Result { + pub async fn insert_torrent_announce_url(&self, torrent_id: i64, tracker_url: &str) -> Result { query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") .bind(torrent_id) .bind(tracker_url) @@ -276,50 +256,29 @@ impl SqliteDatabaseV2_0_0 { } pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { - query("DELETE FROM torrust_categories") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_categories").execute(&self.pool).await.unwrap(); - query("DELETE FROM torrust_torrents") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_torrents").execute(&self.pool).await.unwrap(); - query("DELETE FROM torrust_tracker_keys") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_tracker_keys").execute(&self.pool).await.unwrap(); - query("DELETE FROM torrust_users") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_users").execute(&self.pool).await.unwrap(); query("DELETE FROM torrust_user_authentication") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_user_bans") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_user_bans").execute(&self.pool).await.unwrap(); query("DELETE FROM torrust_user_invitations") .execute(&self.pool) .await .unwrap(); - query("DELETE FROM torrust_user_profiles") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_user_profiles").execute(&self.pool).await.unwrap(); - query("DELETE FROM torrust_torrents") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_torrents").execute(&self.pool).await.unwrap(); query("DELETE FROM torrust_user_public_keys") .execute(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs index c48c27bf..e95cfeda 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -1,13 +1,9 @@ -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{ - CategoryRecordV2, SqliteDatabaseV2_0_0, -}; use std::sync::Arc; -pub async fn transfer_categories( - source_database: Arc, - dest_database: Arc, -) { +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{CategoryRecordV2, SqliteDatabaseV2_0_0}; + +pub async fn transfer_categories(source_database: Arc, dest_database: Arc) { println!("Transferring categories ..."); let source_categories = source_database.get_categories_order_by_id().await.unwrap(); @@ -17,10 +13,7 @@ pub async fn transfer_categories( println!("[v2] reset categories sequence result: {:?}", result); for cat in &source_categories { - println!( - "[v2] adding category {:?} with id {:?} ...", - &cat.name, &cat.category_id - ); + println!("[v2] adding category {:?} with id {:?} ...", &cat.name, &cat.category_id); let id = dest_database .insert_category(&CategoryRecordV2 { category_id: cat.category_id, @@ -30,10 +23,7 @@ pub async fn transfer_categories( .unwrap(); if id != cat.category_id { - panic!( - "Error copying category {:?} from source DB to destiny DB", - &cat.category_id - ); + panic!("Error copying category {:?} from source DB to destiny DB", &cat.category_id); } println!("[v2] category: {:?} {:?} added.", id, &cat.name); diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs index bcb096b0..dcaa867a 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs @@ -1,10 +1,10 @@ +use std::sync::Arc; +use std::{error, fs}; + use crate::models::torrent_file::Torrent; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::TorrentRecordV2; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{SqliteDatabaseV2_0_0, TorrentRecordV2}; use crate::utils::parse_torrent::decode_torrent; -use std::sync::Arc; -use std::{error, fs}; pub async fn transfer_torrents( source_database: Arc, @@ -25,15 +25,9 @@ pub async fn transfer_torrents( for torrent in &torrents { // [v2] table torrust_torrents - println!( - "[v2][torrust_torrents] adding the torrent: {:?} ...", - &torrent.torrent_id - ); + println!("[v2][torrust_torrents] adding the torrent: {:?} ...", &torrent.torrent_id); - let uploader = source_database - .get_user_by_username(&torrent.uploader) - .await - .unwrap(); + let uploader = source_database.get_user_by_username(&torrent.uploader).await.unwrap(); if uploader.username != torrent.uploader { panic!( @@ -54,25 +48,15 @@ pub async fn transfer_torrents( let torrent_from_file = torrent_from_file_result.unwrap(); let id = dest_database - .insert_torrent(&TorrentRecordV2::from_v1_data( - torrent, - &torrent_from_file.info, - &uploader, - )) + .insert_torrent(&TorrentRecordV2::from_v1_data(torrent, &torrent_from_file.info, &uploader)) .await .unwrap(); if id != torrent.torrent_id { - panic!( - "Error copying torrent {:?} from source DB to destiny DB", - &torrent.torrent_id - ); + panic!("Error copying torrent {:?} from source DB to destiny DB", &torrent.torrent_id); } - println!( - "[v2][torrust_torrents] torrent with id {:?} added.", - &torrent.torrent_id - ); + println!("[v2][torrust_torrents] torrent with id {:?} added.", &torrent.torrent_id); // [v2] table torrust_torrent_files @@ -131,10 +115,7 @@ pub async fn transfer_torrents( let id = dest_database.insert_torrent_info(torrent).await; - println!( - "[v2][torrust_torrents] torrent info insert result: {:?}.", - &id - ); + println!("[v2][torrust_torrents] torrent info insert result: {:?}.", &id); // [v2] table torrust_torrent_announce_urls @@ -146,7 +127,10 @@ pub async fn transfer_torrents( if torrent_from_file.announce_list.is_some() { // BEP-0012. Multiple trackers. - println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); + println!( + "[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", + &torrent.torrent_id + ); // flatten the nested vec (this will however remove the) let announce_urls = torrent_from_file @@ -158,22 +142,28 @@ pub async fn transfer_torrents( .collect::>(); for tracker_url in announce_urls.iter() { - println!("[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); + println!( + "[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", + &torrent.torrent_id + ); let announce_url_id = dest_database .insert_torrent_announce_url(torrent.torrent_id, tracker_url) .await; - println!("[v2][torrust_torrent_announce_urls][announce-list] torrent announce url insert result {:?} ...", &announce_url_id); + println!( + "[v2][torrust_torrent_announce_urls][announce-list] torrent announce url insert result {:?} ...", + &announce_url_id + ); } } else if torrent_from_file.announce.is_some() { - println!("[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id); + println!( + "[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", + &torrent.torrent_id + ); let announce_url_id = dest_database - .insert_torrent_announce_url( - torrent.torrent_id, - &torrent_from_file.announce.unwrap(), - ) + .insert_torrent_announce_url(torrent.torrent_id, &torrent_from_file.announce.unwrap()) .await; println!( diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs index e639739a..a2f3e753 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs @@ -1,11 +1,9 @@ +use std::sync::Arc; + use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -use std::sync::Arc; -pub async fn transfer_tracker_keys( - source_database: Arc, - dest_database: Arc, -) { +pub async fn transfer_tracker_keys(source_database: Arc, dest_database: Arc) { println!("Transferring tracker keys ..."); // Transfer table `torrust_tracker_keys` diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs index 18d8d680..51d81727 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs @@ -1,6 +1,7 @@ +use std::sync::Arc; + use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -use std::sync::Arc; pub async fn transfer_users( source_database: Arc, @@ -27,16 +28,10 @@ pub async fn transfer_users( .unwrap(); if id != user.user_id { - panic!( - "Error copying user {:?} from source DB to destiny DB", - &user.user_id - ); + panic!("Error copying user {:?} from source DB to destiny DB", &user.user_id); } - println!( - "[v2][torrust_users] user: {:?} {:?} added.", - &user.user_id, &user.username - ); + println!("[v2][torrust_users] user: {:?} {:?} added.", &user.user_id, &user.username); // [v2] table torrust_user_profiles @@ -46,12 +41,7 @@ pub async fn transfer_users( ); dest_database - .insert_user_profile( - user.user_id, - &user.username, - &user.email, - user.email_verified, - ) + .insert_user_profile(user.user_id, &user.username, &user.email, user.email_verified) .await .unwrap(); diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index e2c32c52..53b17cb4 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -11,28 +11,25 @@ //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{ - current_db, migrate_destiny_database, new_db, reset_destiny_database, -}; -use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::category_transferrer::transfer_categories; -use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::transfer_torrents; -use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::tracker_key_transferrer::transfer_tracker_keys; -use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::user_transferrer::transfer_users; - -use chrono::prelude::{DateTime, Utc}; - use std::env; use std::time::SystemTime; +use chrono::prelude::{DateTime, Utc}; use text_colorizer::*; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{current_db, migrate_destiny_database, new_db, reset_destiny_database}; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::category_transferrer::transfer_categories; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::transfer_torrents; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::tracker_key_transferrer::transfer_tracker_keys; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::user_transferrer::transfer_users; + const NUMBER_OF_ARGUMENTS: i64 = 3; #[derive(Debug)] pub struct Arguments { - pub source_database_file: String, // The source database in version v1.0.0 we want to migrate + pub source_database_file: String, // The source database in version v1.0.0 we want to migrate pub destiny_database_file: String, // The new migrated database in version v2.0.0 - pub upload_path: String, // The relative dir where torrent files are stored + pub upload_path: String, // The relative dir where torrent files are stored } fn print_usage() { @@ -88,19 +85,9 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) { reset_destiny_database(dest_database.clone()).await; transfer_categories(source_database.clone(), dest_database.clone()).await; - transfer_users( - source_database.clone(), - dest_database.clone(), - date_imported, - ) - .await; + transfer_users(source_database.clone(), dest_database.clone(), date_imported).await; transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; - transfer_torrents( - source_database.clone(), - dest_database.clone(), - &args.upload_path, - ) - .await; + transfer_torrents(source_database.clone(), dest_database.clone(), &args.upload_path).await; } /// Current datetime in ISO8601 without time zone. diff --git a/src/upgrades/mod.rs b/src/upgrades/mod.rs index 736d54f6..e22b19a7 100644 --- a/src/upgrades/mod.rs +++ b/src/upgrades/mod.rs @@ -1 +1 @@ -pub mod from_v1_0_0_to_v2_0_0; \ No newline at end of file +pub mod from_v1_0_0_to_v2_0_0; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index 73f7d556..fa1adc92 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -1,6 +1,7 @@ +use std::fs; + use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query, SqlitePool}; -use std::fs; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::{ CategoryRecordV1, TorrentRecordV1, TrackerKeyRecordV1, UserRecordV1, }; @@ -46,8 +47,7 @@ impl SqliteDatabaseV1_0_0 { async fn run_migration_from_file(&self, migration_file_path: &str) { println!("Executing migration: {:?}", migration_file_path); - let sql = fs::read_to_string(migration_file_path) - .expect("Should have been able to read the file"); + let sql = fs::read_to_string(migration_file_path).expect("Should have been able to read the file"); let res = sqlx::query(&sql).execute(&self.pool).await; @@ -64,10 +64,7 @@ impl SqliteDatabaseV1_0_0 { } pub async fn delete_all_categories(&self) -> Result<(), sqlx::Error> { - query("DELETE FROM torrust_categories") - .execute(&self.pool) - .await - .unwrap(); + query("DELETE FROM torrust_categories").execute(&self.pool).await.unwrap(); Ok(()) } @@ -84,10 +81,7 @@ impl SqliteDatabaseV1_0_0 { .map(|v| v.last_insert_rowid()) } - pub async fn insert_tracker_key( - &self, - tracker_key: &TrackerKeyRecordV1, - ) -> Result { + pub async fn insert_tracker_key(&self, tracker_key: &TrackerKeyRecordV1) -> Result { query("INSERT INTO torrust_tracker_keys (key_id, user_id, key, valid_until) VALUES (?, ?, ?, ?)") .bind(tracker_key.key_id) .bind(tracker_key.user_id) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index eea5f354..8d863c10 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -103,22 +103,14 @@ impl SqliteDatabaseV2_0_0 { .await } - pub async fn get_user_authentication( - &self, - user_id: i64, - ) -> Result { - query_as::<_, UserAuthenticationRecordV2>( - "SELECT * FROM torrust_user_authentication WHERE user_id = ?", - ) - .bind(user_id) - .fetch_one(&self.pool) - .await + pub async fn get_user_authentication(&self, user_id: i64) -> Result { + query_as::<_, UserAuthenticationRecordV2>("SELECT * FROM torrust_user_authentication WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await } - pub async fn get_tracker_key( - &self, - tracker_key_id: i64, - ) -> Result { + pub async fn get_tracker_key(&self, tracker_key_id: i64) -> Result { query_as::<_, TrackerKeyRecordV2>("SELECT * FROM torrust_tracker_keys WHERE user_id = ?") .bind(tracker_key_id) .fetch_one(&self.pool) @@ -132,34 +124,21 @@ impl SqliteDatabaseV2_0_0 { .await } - pub async fn get_torrent_info( - &self, - torrent_id: i64, - ) -> Result { - query_as::<_, TorrentInfoRecordV2>( - "SELECT * FROM torrust_torrent_info WHERE torrent_id = ?", - ) - .bind(torrent_id) - .fetch_one(&self.pool) - .await + pub async fn get_torrent_info(&self, torrent_id: i64) -> Result { + query_as::<_, TorrentInfoRecordV2>("SELECT * FROM torrust_torrent_info WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_one(&self.pool) + .await } - pub async fn get_torrent_announce_urls( - &self, - torrent_id: i64, - ) -> Result, sqlx::Error> { - query_as::<_, TorrentAnnounceUrlV2>( - "SELECT * FROM torrust_torrent_announce_urls WHERE torrent_id = ?", - ) - .bind(torrent_id) - .fetch_all(&self.pool) - .await + pub async fn get_torrent_announce_urls(&self, torrent_id: i64) -> Result, sqlx::Error> { + query_as::<_, TorrentAnnounceUrlV2>("SELECT * FROM torrust_torrent_announce_urls WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_all(&self.pool) + .await } - pub async fn get_torrent_files( - &self, - torrent_id: i64, - ) -> Result, sqlx::Error> { + pub async fn get_torrent_files(&self, torrent_id: i64) -> Result, sqlx::Error> { query_as::<_, TorrentFileV2>("SELECT * FROM torrust_torrent_files WHERE torrent_id = ?") .bind(torrent_id) .fetch_all(&self.pool) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs index e8e79d54..897e7ccb 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs @@ -1,8 +1,10 @@ -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; use std::sync::Arc; + use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::CategoryRecordV1; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; + pub struct CategoryTester { source_database: Arc, destiny_database: Arc, @@ -14,10 +16,7 @@ pub struct TestData { } impl CategoryTester { - pub fn new( - source_database: Arc, - destiny_database: Arc, - ) -> Self { + pub fn new(source_database: Arc, destiny_database: Arc) -> Self { let category_01 = CategoryRecordV1 { category_id: 10, name: "category name 10".to_string(), @@ -47,21 +46,14 @@ impl CategoryTester { // Add test categories for categories in &self.test_data.categories { - self.source_database - .insert_category(&categories) - .await - .unwrap(); + self.source_database.insert_category(&categories).await.unwrap(); } } /// Table `torrust_categories` pub async fn assert_data_in_destiny_db(&self) { for categories in &self.test_data.categories { - let imported_category = self - .destiny_database - .get_category(categories.category_id) - .await - .unwrap(); + let imported_category = self.destiny_database.get_category(categories.category_id).await.unwrap(); assert_eq!(imported_category.category_id, categories.category_id); assert_eq!(imported_category.name, categories.name); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index 9b4c8c2a..47be2c67 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -1,13 +1,13 @@ -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; use std::sync::Arc; + use torrust_index_backend::models::torrent_file::Torrent; -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::{ - TorrentRecordV1, UserRecordV1, -}; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::convert_timestamp_to_datetime; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::read_torrent_from_file; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; + pub struct TorrentTester { source_database: Arc, destiny_database: Arc, @@ -91,8 +91,7 @@ impl TorrentTester { self.assert_torrent(&torrent, &torrent_file).await; self.assert_torrent_info(&torrent).await; - self.assert_torrent_announce_urls(&torrent, &torrent_file) - .await; + self.assert_torrent_announce_urls(&torrent, &torrent_file).await; self.assert_torrent_files(&torrent, &torrent_file).await; } } @@ -103,11 +102,7 @@ impl TorrentTester { /// Table `torrust_torrents` async fn assert_torrent(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { - let imported_torrent = self - .destiny_database - .get_torrent(torrent.torrent_id) - .await - .unwrap(); + let imported_torrent = self.destiny_database.get_torrent(torrent.torrent_id).await.unwrap(); assert_eq!(imported_torrent.torrent_id, torrent.torrent_id); assert_eq!(imported_torrent.uploader_id, self.test_data.user.user_id); @@ -115,23 +110,14 @@ impl TorrentTester { assert_eq!(imported_torrent.info_hash, torrent.info_hash); assert_eq!(imported_torrent.size, torrent.file_size); assert_eq!(imported_torrent.name, torrent_file.info.name); - assert_eq!( - imported_torrent.pieces, - torrent_file.info.get_pieces_as_string() - ); - assert_eq!( - imported_torrent.piece_length, - torrent_file.info.piece_length - ); + assert_eq!(imported_torrent.pieces, torrent_file.info.get_pieces_as_string()); + assert_eq!(imported_torrent.piece_length, torrent_file.info.piece_length); if torrent_file.info.private.is_none() { assert_eq!(imported_torrent.private, Some(0)); } else { assert_eq!(imported_torrent.private, torrent_file.info.private); } - assert_eq!( - imported_torrent.root_hash, - torrent_file.info.get_root_hash_as_i64() - ); + assert_eq!(imported_torrent.root_hash, torrent_file.info.get_root_hash_as_i64()); assert_eq!( imported_torrent.date_uploaded, convert_timestamp_to_datetime(torrent.upload_date) @@ -140,11 +126,7 @@ impl TorrentTester { /// Table `torrust_torrent_info` async fn assert_torrent_info(&self, torrent: &TorrentRecordV1) { - let torrent_info = self - .destiny_database - .get_torrent_info(torrent.torrent_id) - .await - .unwrap(); + let torrent_info = self.destiny_database.get_torrent_info(torrent.torrent_id).await.unwrap(); assert_eq!(torrent_info.torrent_id, torrent.torrent_id); assert_eq!(torrent_info.title, torrent.title); @@ -152,11 +134,7 @@ impl TorrentTester { } /// Table `torrust_torrent_announce_urls` - async fn assert_torrent_announce_urls( - &self, - torrent: &TorrentRecordV1, - torrent_file: &Torrent, - ) { + async fn assert_torrent_announce_urls(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { let torrent_announce_urls = self .destiny_database .get_torrent_announce_urls(torrent.torrent_id) @@ -175,11 +153,7 @@ impl TorrentTester { /// Table `torrust_torrent_files` async fn assert_torrent_files(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { - let db_torrent_files = self - .destiny_database - .get_torrent_files(torrent.torrent_id) - .await - .unwrap(); + let db_torrent_files = self.destiny_database.get_torrent_files(torrent.torrent_id).await.unwrap(); if torrent_file.is_a_single_file_torrent() { let db_torrent_file = &db_torrent_files[0]; @@ -195,10 +169,7 @@ impl TorrentTester { let file_path = file.path.join("/"); // Find file in database - let db_torrent_file = db_torrent_files - .iter() - .find(|&f| f.path == Some(file_path.clone())) - .unwrap(); + let db_torrent_file = db_torrent_files.iter().find(|&f| f.path == Some(file_path.clone())).unwrap(); assert_eq!(db_torrent_file.torrent_id, torrent.torrent_id); assert!(db_torrent_file.md5sum.is_none()); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs index 3dfa4904..6ba44f5b 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs @@ -1,8 +1,10 @@ -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; use std::sync::Arc; + use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::TrackerKeyRecordV1; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; + pub struct TrackerKeyTester { source_database: Arc, destiny_database: Arc, @@ -14,11 +16,7 @@ pub struct TestData { } impl TrackerKeyTester { - pub fn new( - source_database: Arc, - destiny_database: Arc, - user_id: i64, - ) -> Self { + pub fn new(source_database: Arc, destiny_database: Arc, user_id: i64) -> Self { let tracker_key = TrackerKeyRecordV1 { key_id: 1, user_id, @@ -48,15 +46,9 @@ impl TrackerKeyTester { .await .unwrap(); - assert_eq!( - imported_key.tracker_key_id, - self.test_data.tracker_key.key_id - ); + assert_eq!(imported_key.tracker_key_id, self.test_data.tracker_key.key_id); assert_eq!(imported_key.user_id, self.test_data.tracker_key.user_id); assert_eq!(imported_key.tracker_key, self.test_data.tracker_key.key); - assert_eq!( - imported_key.date_expiry, - self.test_data.tracker_key.valid_until - ); + assert_eq!(imported_key.date_expiry, self.test_data.tracker_key.valid_until); } } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs index d349a47f..870d7fa0 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs @@ -1,11 +1,13 @@ -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use std::sync::Arc; + use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; use rand_core::OsRng; -use std::sync::Arc; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::UserRecordV1; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; + pub struct UserTester { source_database: Arc, destiny_database: Arc, @@ -41,10 +43,7 @@ impl UserTester { } pub async fn load_data_into_source_db(&self) { - self.source_database - .insert_user(&self.test_data.user) - .await - .unwrap(); + self.source_database.insert_user(&self.test_data.user).await.unwrap(); } pub async fn assert_data_in_destiny_db(&self) { @@ -55,19 +54,12 @@ impl UserTester { /// Table `torrust_users` async fn assert_user(&self) { - let imported_user = self - .destiny_database - .get_user(self.test_data.user.user_id) - .await - .unwrap(); + let imported_user = self.destiny_database.get_user(self.test_data.user.user_id).await.unwrap(); assert_eq!(imported_user.user_id, self.test_data.user.user_id); assert!(imported_user.date_registered.is_none()); assert_eq!(imported_user.date_imported.unwrap(), self.execution_time); - assert_eq!( - imported_user.administrator, - self.test_data.user.administrator - ); + assert_eq!(imported_user.administrator, self.test_data.user.administrator); } /// Table `torrust_user_profiles` @@ -81,10 +73,7 @@ impl UserTester { assert_eq!(imported_user_profile.user_id, self.test_data.user.user_id); assert_eq!(imported_user_profile.username, self.test_data.user.username); assert_eq!(imported_user_profile.email, self.test_data.user.email); - assert_eq!( - imported_user_profile.email_verified, - self.test_data.user.email_verified - ); + assert_eq!(imported_user_profile.email_verified, self.test_data.user.email_verified); assert!(imported_user_profile.bio.is_none()); assert!(imported_user_profile.avatar.is_none()); } @@ -97,14 +86,8 @@ impl UserTester { .await .unwrap(); - assert_eq!( - imported_user_authentication.user_id, - self.test_data.user.user_id - ); - assert_eq!( - imported_user_authentication.password_hash, - self.test_data.user.password - ); + assert_eq!(imported_user_authentication.user_id, self.test_data.user.user_id); + assert_eq!(imported_user_authentication.password_hash, self.test_data.user.password); } } @@ -123,8 +106,5 @@ fn hash_password(plain_password: &str) -> String { let argon2 = Argon2::default(); // Hash password to PHC string ($argon2id$v=19$...) - argon2 - .hash_password(plain_password.as_bytes(), &salt) - .unwrap() - .to_string() + argon2.hash_password(plain_password.as_bytes(), &salt).unwrap().to_string() } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index ee7ddc8f..63daee3a 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -11,17 +11,17 @@ //! ``` //! //! to see the "upgrader" command output. +use std::fs; +use std::sync::Arc; + +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{datetime_iso_8601, upgrade, Arguments}; + use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::category_tester::CategoryTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::torrent_tester::TorrentTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::tracker_key_tester::TrackerKeyTester; use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_tester::UserTester; -use std::fs; -use std::sync::Arc; -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{ - datetime_iso_8601, upgrade, Arguments, -}; struct TestConfig { // Directories @@ -59,11 +59,7 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { let category_tester = CategoryTester::new(source_db.clone(), dest_db.clone()); let user_tester = UserTester::new(source_db.clone(), dest_db.clone(), &execution_time); - let tracker_key_tester = TrackerKeyTester::new( - source_db.clone(), - dest_db.clone(), - user_tester.test_data.user.user_id, - ); + let tracker_key_tester = TrackerKeyTester::new(source_db.clone(), dest_db.clone(), user_tester.test_data.user.user_id); let torrent_tester = TorrentTester::new( source_db.clone(), dest_db.clone(), @@ -92,14 +88,10 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { category_tester.assert_data_in_destiny_db().await; user_tester.assert_data_in_destiny_db().await; tracker_key_tester.assert_data_in_destiny_db().await; - torrent_tester - .assert_data_in_destiny_db(&config.upload_path) - .await; + torrent_tester.assert_data_in_destiny_db(&config.upload_path).await; } -async fn setup_databases( - config: &TestConfig, -) -> (Arc, Arc) { +async fn setup_databases(config: &TestConfig) -> (Arc, Arc) { // Set up clean source database reset_databases(&config.source_database_file, &config.destiny_database_file); let source_database = source_db_connection(&config.source_database_file).await; diff --git a/tests/upgrades/mod.rs b/tests/upgrades/mod.rs index 736d54f6..e22b19a7 100644 --- a/tests/upgrades/mod.rs +++ b/tests/upgrades/mod.rs @@ -1 +1 @@ -pub mod from_v1_0_0_to_v2_0_0; \ No newline at end of file +pub mod from_v1_0_0_to_v2_0_0; From e8d984d790e4bc1f1c95498b10ad050bbed076be Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 30 Nov 2022 12:34:16 +0000 Subject: [PATCH 064/357] refactor: [#56] rename destiny DB to target DB --- .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 18 +++---- .../transferrers/category_transferrer.rs | 15 +++--- .../transferrers/torrent_transferrer.rs | 19 ++++--- .../transferrers/tracker_key_transferrer.rs | 6 +-- .../transferrers/user_transferrer.rs | 10 ++-- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 28 +++++------ .../testers/category_tester.rs | 10 ++-- .../testers/torrent_tester.rs | 16 +++--- .../testers/tracker_key_tester.rs | 10 ++-- .../testers/user_tester.rs | 14 +++--- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 50 +++++++++++-------- upgrades/from_v1_0_0_to_v2_0_0/README.md | 2 +- 12 files changed, 105 insertions(+), 93 deletions(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs index 5b3be9b1..936527ab 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs @@ -12,19 +12,19 @@ pub async fn current_db(db_filename: &str) -> Arc { } pub async fn new_db(db_filename: &str) -> Arc { - let dest_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); - Arc::new(SqliteDatabaseV2_0_0::new(&dest_database_connect_url).await) + let target_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); + Arc::new(SqliteDatabaseV2_0_0::new(&target_database_connect_url).await) } -pub async fn migrate_destiny_database(dest_database: Arc) { - println!("Running migrations in destiny database..."); - dest_database.migrate().await; +pub async fn migrate_target_database(target_database: Arc) { + println!("Running migrations in the target database..."); + target_database.migrate().await; } -pub async fn reset_destiny_database(dest_database: Arc) { - println!("Truncating all tables in destiny database ..."); - dest_database +pub async fn reset_target_database(target_database: Arc) { + println!("Truncating all tables in target database ..."); + target_database .delete_all_database_rows() .await - .expect("Can't reset destiny database."); + .expect("Can't reset the target database."); } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs index e95cfeda..f3d83d9b 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -3,18 +3,18 @@ use std::sync::Arc; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{CategoryRecordV2, SqliteDatabaseV2_0_0}; -pub async fn transfer_categories(source_database: Arc, dest_database: Arc) { +pub async fn transfer_categories(source_database: Arc, target_database: Arc) { println!("Transferring categories ..."); let source_categories = source_database.get_categories_order_by_id().await.unwrap(); println!("[v1] categories: {:?}", &source_categories); - let result = dest_database.reset_categories_sequence().await.unwrap(); + let result = target_database.reset_categories_sequence().await.unwrap(); println!("[v2] reset categories sequence result: {:?}", result); for cat in &source_categories { println!("[v2] adding category {:?} with id {:?} ...", &cat.name, &cat.category_id); - let id = dest_database + let id = target_database .insert_category(&CategoryRecordV2 { category_id: cat.category_id, name: cat.name.clone(), @@ -23,12 +23,15 @@ pub async fn transfer_categories(source_database: Arc, des .unwrap(); if id != cat.category_id { - panic!("Error copying category {:?} from source DB to destiny DB", &cat.category_id); + panic!( + "Error copying category {:?} from source DB to the target DB", + &cat.category_id + ); } println!("[v2] category: {:?} {:?} added.", id, &cat.name); } - let dest_categories = dest_database.get_categories().await.unwrap(); - println!("[v2] categories: {:?}", &dest_categories); + let target_categories = target_database.get_categories().await.unwrap(); + println!("[v2] categories: {:?}", &target_categories); } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs index dcaa867a..88a681f0 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs @@ -8,7 +8,7 @@ use crate::utils::parse_torrent::decode_torrent; pub async fn transfer_torrents( source_database: Arc, - dest_database: Arc, + target_database: Arc, upload_path: &str, ) { println!("Transferring torrents ..."); @@ -47,13 +47,16 @@ pub async fn transfer_torrents( let torrent_from_file = torrent_from_file_result.unwrap(); - let id = dest_database + let id = target_database .insert_torrent(&TorrentRecordV2::from_v1_data(torrent, &torrent_from_file.info, &uploader)) .await .unwrap(); if id != torrent.torrent_id { - panic!("Error copying torrent {:?} from source DB to destiny DB", &torrent.torrent_id); + panic!( + "Error copying torrent {:?} from source DB to the target DB", + &torrent.torrent_id + ); } println!("[v2][torrust_torrents] torrent with id {:?} added.", &torrent.torrent_id); @@ -72,7 +75,7 @@ pub async fn transfer_torrents( &torrent_from_file.info.name, &torrent_from_file.info.length, ); - let file_id = dest_database + let file_id = target_database .insert_torrent_file_for_torrent_with_one_file( torrent.torrent_id, // TODO: it seems med5sum can be None. Why? When? @@ -95,7 +98,7 @@ pub async fn transfer_torrents( &file ); - let file_id = dest_database + let file_id = target_database .insert_torrent_file_for_torrent_with_multiple_files(torrent, file) .await; @@ -113,7 +116,7 @@ pub async fn transfer_torrents( &torrent.torrent_id ); - let id = dest_database.insert_torrent_info(torrent).await; + let id = target_database.insert_torrent_info(torrent).await; println!("[v2][torrust_torrents] torrent info insert result: {:?}.", &id); @@ -147,7 +150,7 @@ pub async fn transfer_torrents( &torrent.torrent_id ); - let announce_url_id = dest_database + let announce_url_id = target_database .insert_torrent_announce_url(torrent.torrent_id, tracker_url) .await; @@ -162,7 +165,7 @@ pub async fn transfer_torrents( &torrent.torrent_id ); - let announce_url_id = dest_database + let announce_url_id = target_database .insert_torrent_announce_url(torrent.torrent_id, &torrent_from_file.announce.unwrap()) .await; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs index a2f3e753..51c451b0 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -pub async fn transfer_tracker_keys(source_database: Arc, dest_database: Arc) { +pub async fn transfer_tracker_keys(source_database: Arc, target_database: Arc) { println!("Transferring tracker keys ..."); // Transfer table `torrust_tracker_keys` @@ -18,7 +18,7 @@ pub async fn transfer_tracker_keys(source_database: Arc, d &tracker_key.key_id ); - let id = dest_database + let id = target_database .insert_tracker_key( tracker_key.key_id, tracker_key.user_id, @@ -30,7 +30,7 @@ pub async fn transfer_tracker_keys(source_database: Arc, d if id != tracker_key.key_id { panic!( - "Error copying tracker key {:?} from source DB to destiny DB", + "Error copying tracker key {:?} from source DB to the target DB", &tracker_key.key_id ); } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs index 51d81727..76f5ff44 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs @@ -5,7 +5,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteData pub async fn transfer_users( source_database: Arc, - dest_database: Arc, + target_database: Arc, date_imported: &str, ) { println!("Transferring users ..."); @@ -22,13 +22,13 @@ pub async fn transfer_users( &user.username, &user.user_id ); - let id = dest_database + let id = target_database .insert_imported_user(user.user_id, date_imported, user.administrator) .await .unwrap(); if id != user.user_id { - panic!("Error copying user {:?} from source DB to destiny DB", &user.user_id); + panic!("Error copying user {:?} from source DB to the target DB", &user.user_id); } println!("[v2][torrust_users] user: {:?} {:?} added.", &user.user_id, &user.username); @@ -40,7 +40,7 @@ pub async fn transfer_users( &user.username, &user.user_id ); - dest_database + target_database .insert_user_profile(user.user_id, &user.username, &user.email, user.email_verified) .await .unwrap(); @@ -57,7 +57,7 @@ pub async fn transfer_users( &user.password, &user.user_id ); - dest_database + target_database .insert_user_password_hash(user.user_id, &user.password) .await .unwrap(); diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 53b17cb4..07accb78 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -17,7 +17,7 @@ use std::time::SystemTime; use chrono::prelude::{DateTime, Utc}; use text_colorizer::*; -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{current_db, migrate_destiny_database, new_db, reset_destiny_database}; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{current_db, migrate_target_database, new_db, reset_target_database}; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::category_transferrer::transfer_categories; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::transfer_torrents; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::tracker_key_transferrer::transfer_tracker_keys; @@ -27,9 +27,9 @@ const NUMBER_OF_ARGUMENTS: i64 = 3; #[derive(Debug)] pub struct Arguments { - pub source_database_file: String, // The source database in version v1.0.0 we want to migrate - pub destiny_database_file: String, // The new migrated database in version v2.0.0 - pub upload_path: String, // The relative dir where torrent files are stored + pub source_database_file: String, // The source database in version v1.0.0 we want to migrate + pub target_database_file: String, // The new migrated database in version v2.0.0 + pub upload_path: String, // The relative dir where torrent files are stored } fn print_usage() { @@ -62,7 +62,7 @@ fn parse_args() -> Arguments { Arguments { source_database_file: args[0].clone(), - destiny_database_file: args[1].clone(), + target_database_file: args[1].clone(), upload_path: args[2].clone(), } } @@ -73,21 +73,21 @@ pub async fn run_upgrader() { } pub async fn upgrade(args: &Arguments, date_imported: &str) { - // Get connection to source database (current DB in settings) + // Get connection to the source database (current DB in settings) let source_database = current_db(&args.source_database_file).await; - // Get connection to destiny database - let dest_database = new_db(&args.destiny_database_file).await; + // Get connection to the target database (new DB we want to migrate the data) + let target_database = new_db(&args.target_database_file).await; println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); - migrate_destiny_database(dest_database.clone()).await; - reset_destiny_database(dest_database.clone()).await; + migrate_target_database(target_database.clone()).await; + reset_target_database(target_database.clone()).await; - transfer_categories(source_database.clone(), dest_database.clone()).await; - transfer_users(source_database.clone(), dest_database.clone(), date_imported).await; - transfer_tracker_keys(source_database.clone(), dest_database.clone()).await; - transfer_torrents(source_database.clone(), dest_database.clone(), &args.upload_path).await; + transfer_categories(source_database.clone(), target_database.clone()).await; + transfer_users(source_database.clone(), target_database.clone(), date_imported).await; + transfer_tracker_keys(source_database.clone(), target_database.clone()).await; + transfer_torrents(source_database.clone(), target_database.clone(), &args.upload_path).await; } /// Current datetime in ISO8601 without time zone. diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs index 897e7ccb..c10f93b8 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs @@ -7,7 +7,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; pub struct CategoryTester { source_database: Arc, - destiny_database: Arc, + target_database: Arc, test_data: TestData, } @@ -16,7 +16,7 @@ pub struct TestData { } impl CategoryTester { - pub fn new(source_database: Arc, destiny_database: Arc) -> Self { + pub fn new(source_database: Arc, target_database: Arc) -> Self { let category_01 = CategoryRecordV1 { category_id: 10, name: "category name 10".to_string(), @@ -28,7 +28,7 @@ impl CategoryTester { Self { source_database, - destiny_database, + target_database, test_data: TestData { categories: vec![category_01, category_02], }, @@ -51,9 +51,9 @@ impl CategoryTester { } /// Table `torrust_categories` - pub async fn assert_data_in_destiny_db(&self) { + pub async fn assert_data_in_target_db(&self) { for categories in &self.test_data.categories { - let imported_category = self.destiny_database.get_category(categories.category_id).await.unwrap(); + let imported_category = self.target_database.get_category(categories.category_id).await.unwrap(); assert_eq!(imported_category.category_id, categories.category_id); assert_eq!(imported_category.name, categories.name); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs index 47be2c67..86bd1e52 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs @@ -10,7 +10,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; pub struct TorrentTester { source_database: Arc, - destiny_database: Arc, + target_database: Arc, test_data: TestData, } @@ -22,7 +22,7 @@ pub struct TestData { impl TorrentTester { pub fn new( source_database: Arc, - destiny_database: Arc, + target_database: Arc, user: &UserRecordV1, category_id: i64, ) -> Self { @@ -69,7 +69,7 @@ impl TorrentTester { Self { source_database, - destiny_database, + target_database, test_data: TestData { torrents: vec![torrent_01, torrent_02], user: user.clone(), @@ -83,7 +83,7 @@ impl TorrentTester { } } - pub async fn assert_data_in_destiny_db(&self, upload_path: &str) { + pub async fn assert_data_in_target_db(&self, upload_path: &str) { for torrent in &self.test_data.torrents { let filepath = self.torrent_file_path(upload_path, torrent.torrent_id); @@ -102,7 +102,7 @@ impl TorrentTester { /// Table `torrust_torrents` async fn assert_torrent(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { - let imported_torrent = self.destiny_database.get_torrent(torrent.torrent_id).await.unwrap(); + let imported_torrent = self.target_database.get_torrent(torrent.torrent_id).await.unwrap(); assert_eq!(imported_torrent.torrent_id, torrent.torrent_id); assert_eq!(imported_torrent.uploader_id, self.test_data.user.user_id); @@ -126,7 +126,7 @@ impl TorrentTester { /// Table `torrust_torrent_info` async fn assert_torrent_info(&self, torrent: &TorrentRecordV1) { - let torrent_info = self.destiny_database.get_torrent_info(torrent.torrent_id).await.unwrap(); + let torrent_info = self.target_database.get_torrent_info(torrent.torrent_id).await.unwrap(); assert_eq!(torrent_info.torrent_id, torrent.torrent_id); assert_eq!(torrent_info.title, torrent.title); @@ -136,7 +136,7 @@ impl TorrentTester { /// Table `torrust_torrent_announce_urls` async fn assert_torrent_announce_urls(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { let torrent_announce_urls = self - .destiny_database + .target_database .get_torrent_announce_urls(torrent.torrent_id) .await .unwrap(); @@ -153,7 +153,7 @@ impl TorrentTester { /// Table `torrust_torrent_files` async fn assert_torrent_files(&self, torrent: &TorrentRecordV1, torrent_file: &Torrent) { - let db_torrent_files = self.destiny_database.get_torrent_files(torrent.torrent_id).await.unwrap(); + let db_torrent_files = self.target_database.get_torrent_files(torrent.torrent_id).await.unwrap(); if torrent_file.is_a_single_file_torrent() { let db_torrent_file = &db_torrent_files[0]; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs index 6ba44f5b..e50ac861 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs @@ -7,7 +7,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; pub struct TrackerKeyTester { source_database: Arc, - destiny_database: Arc, + target_database: Arc, test_data: TestData, } @@ -16,7 +16,7 @@ pub struct TestData { } impl TrackerKeyTester { - pub fn new(source_database: Arc, destiny_database: Arc, user_id: i64) -> Self { + pub fn new(source_database: Arc, target_database: Arc, user_id: i64) -> Self { let tracker_key = TrackerKeyRecordV1 { key_id: 1, user_id, @@ -26,7 +26,7 @@ impl TrackerKeyTester { Self { source_database, - destiny_database, + target_database, test_data: TestData { tracker_key }, } } @@ -39,9 +39,9 @@ impl TrackerKeyTester { } /// Table `torrust_tracker_keys` - pub async fn assert_data_in_destiny_db(&self) { + pub async fn assert_data_in_target_db(&self) { let imported_key = self - .destiny_database + .target_database .get_tracker_key(self.test_data.tracker_key.key_id) .await .unwrap(); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs index 870d7fa0..2d52a683 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs @@ -10,7 +10,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; pub struct UserTester { source_database: Arc, - destiny_database: Arc, + target_database: Arc, execution_time: String, pub test_data: TestData, } @@ -22,7 +22,7 @@ pub struct TestData { impl UserTester { pub fn new( source_database: Arc, - destiny_database: Arc, + target_database: Arc, execution_time: &str, ) -> Self { let user = UserRecordV1 { @@ -36,7 +36,7 @@ impl UserTester { Self { source_database, - destiny_database, + target_database, execution_time: execution_time.to_owned(), test_data: TestData { user }, } @@ -46,7 +46,7 @@ impl UserTester { self.source_database.insert_user(&self.test_data.user).await.unwrap(); } - pub async fn assert_data_in_destiny_db(&self) { + pub async fn assert_data_in_target_db(&self) { self.assert_user().await; self.assert_user_profile().await; self.assert_user_authentication().await; @@ -54,7 +54,7 @@ impl UserTester { /// Table `torrust_users` async fn assert_user(&self) { - let imported_user = self.destiny_database.get_user(self.test_data.user.user_id).await.unwrap(); + let imported_user = self.target_database.get_user(self.test_data.user.user_id).await.unwrap(); assert_eq!(imported_user.user_id, self.test_data.user.user_id); assert!(imported_user.date_registered.is_none()); @@ -65,7 +65,7 @@ impl UserTester { /// Table `torrust_user_profiles` async fn assert_user_profile(&self) { let imported_user_profile = self - .destiny_database + .target_database .get_user_profile(self.test_data.user.user_id) .await .unwrap(); @@ -81,7 +81,7 @@ impl UserTester { /// Table `torrust_user_profiles` async fn assert_user_authentication(&self) { let imported_user_authentication = self - .destiny_database + .target_database .get_user_authentication(self.test_data.user.user_id) .await .unwrap(); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 63daee3a..aa0e2a75 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -12,6 +12,7 @@ //! //! to see the "upgrader" command output. use std::fs; +use std::path::Path; use std::sync::Arc; use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{datetime_iso_8601, upgrade, Arguments}; @@ -29,7 +30,7 @@ struct TestConfig { pub upload_path: String, // Files pub source_database_file: String, - pub destiny_database_file: String, + pub target_database_file: String, } impl Default for TestConfig { @@ -38,12 +39,12 @@ impl Default for TestConfig { let upload_path = format!("{}uploads/", &fixtures_dir); let output_dir = "./tests/upgrades/from_v1_0_0_to_v2_0_0/output/".to_string(); let source_database_file = format!("{}source.db", output_dir); - let destiny_database_file = format!("{}destiny.db", output_dir); + let target_database_file = format!("{}target.db", output_dir); Self { fixtures_dir, upload_path, source_database_file, - destiny_database_file, + target_database_file, } } } @@ -52,17 +53,17 @@ impl Default for TestConfig { async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { let config = TestConfig::default(); - let (source_db, dest_db) = setup_databases(&config).await; + let (source_db, target_db) = setup_databases(&config).await; // The datetime when the upgrader is executed let execution_time = datetime_iso_8601(); - let category_tester = CategoryTester::new(source_db.clone(), dest_db.clone()); - let user_tester = UserTester::new(source_db.clone(), dest_db.clone(), &execution_time); - let tracker_key_tester = TrackerKeyTester::new(source_db.clone(), dest_db.clone(), user_tester.test_data.user.user_id); + let category_tester = CategoryTester::new(source_db.clone(), target_db.clone()); + let user_tester = UserTester::new(source_db.clone(), target_db.clone(), &execution_time); + let tracker_key_tester = TrackerKeyTester::new(source_db.clone(), target_db.clone(), user_tester.test_data.user.user_id); let torrent_tester = TorrentTester::new( source_db.clone(), - dest_db.clone(), + target_db.clone(), &user_tester.test_data.user, category_tester.get_valid_category_id(), ); @@ -77,7 +78,7 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { upgrade( &Arguments { source_database_file: config.source_database_file.clone(), - destiny_database_file: config.destiny_database_file.clone(), + target_database_file: config.target_database_file.clone(), upload_path: config.upload_path.clone(), }, &execution_time, @@ -85,34 +86,39 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { .await; // Assertions for data transferred to the new database in version v2.0.0 - category_tester.assert_data_in_destiny_db().await; - user_tester.assert_data_in_destiny_db().await; - tracker_key_tester.assert_data_in_destiny_db().await; - torrent_tester.assert_data_in_destiny_db(&config.upload_path).await; + category_tester.assert_data_in_target_db().await; + user_tester.assert_data_in_target_db().await; + tracker_key_tester.assert_data_in_target_db().await; + torrent_tester.assert_data_in_target_db(&config.upload_path).await; } async fn setup_databases(config: &TestConfig) -> (Arc, Arc) { // Set up clean source database - reset_databases(&config.source_database_file, &config.destiny_database_file); + reset_databases(&config.source_database_file, &config.target_database_file); let source_database = source_db_connection(&config.source_database_file).await; source_database.migrate(&config.fixtures_dir).await; - // Set up connection for the destiny database - let destiny_database = destiny_db_connection(&config.destiny_database_file).await; + // Set up connection for the target database + let target_database = target_db_connection(&config.target_database_file).await; - (source_database, destiny_database) + (source_database, target_database) } async fn source_db_connection(source_database_file: &str) -> Arc { Arc::new(SqliteDatabaseV1_0_0::db_connection(&source_database_file).await) } -async fn destiny_db_connection(destiny_database_file: &str) -> Arc { - Arc::new(SqliteDatabaseV2_0_0::db_connection(&destiny_database_file).await) +async fn target_db_connection(target_database_file: &str) -> Arc { + Arc::new(SqliteDatabaseV2_0_0::db_connection(&target_database_file).await) } /// Reset databases from previous executions -fn reset_databases(source_database_file: &str, destiny_database_file: &str) { - fs::remove_file(&source_database_file).expect("Can't remove source DB file."); - fs::remove_file(&destiny_database_file).expect("Can't remove destiny DB file."); +fn reset_databases(source_database_file: &str, target_database_file: &str) { + if Path::new(source_database_file).exists() { + fs::remove_file(&source_database_file).expect("Can't remove the source DB file."); + } + + if Path::new(target_database_file).exists() { + fs::remove_file(&target_database_file).expect("Can't remove the target DB file."); + } } diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index cd2c1c11..37609149 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -31,4 +31,4 @@ Before replacing the DB in production you can make some tests like: ## Notes -The `db_schemas` contains the snapshots of the source and destiny databases for this upgrade. +The `db_schemas` contains the snapshots of the source and target databases for this upgrade. From 19d054e5f8b72c3218e9b16b77fa215c110b9c13 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 30 Nov 2022 12:48:30 +0000 Subject: [PATCH 065/357] refactor: [#56] rename test mods to follow prod mods --- tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 2 +- tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs | 4 ---- .../category_transferrer_tester.rs} | 0 .../from_v1_0_0_to_v2_0_0/transferrer_testers/mod.rs | 4 ++++ .../torrent_transferrer_tester.rs} | 0 .../tracker_key_transferrer_tester.rs} | 0 .../user_transferrer_tester.rs} | 0 tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 8 ++++---- 8 files changed, 9 insertions(+), 9 deletions(-) delete mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs rename tests/upgrades/from_v1_0_0_to_v2_0_0/{testers/category_tester.rs => transferrer_testers/category_transferrer_tester.rs} (100%) create mode 100644 tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/mod.rs rename tests/upgrades/from_v1_0_0_to_v2_0_0/{testers/torrent_tester.rs => transferrer_testers/torrent_transferrer_tester.rs} (100%) rename tests/upgrades/from_v1_0_0_to_v2_0_0/{testers/tracker_key_tester.rs => transferrer_testers/tracker_key_transferrer_tester.rs} (100%) rename tests/upgrades/from_v1_0_0_to_v2_0_0/{testers/user_tester.rs => transferrer_testers/user_transferrer_tester.rs} (100%) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs index 7a5e3bb7..29897ff7 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -1,4 +1,4 @@ pub mod sqlite_v1_0_0; pub mod sqlite_v2_0_0; -pub mod testers; +pub mod transferrer_testers; pub mod upgrader; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs deleted file mode 100644 index 36629cc3..00000000 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod category_tester; -pub mod torrent_tester; -pub mod tracker_key_tester; -pub mod user_tester; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/category_transferrer_tester.rs similarity index 100% rename from tests/upgrades/from_v1_0_0_to_v2_0_0/testers/category_tester.rs rename to tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/category_transferrer_tester.rs diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/mod.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/mod.rs new file mode 100644 index 00000000..459bcac8 --- /dev/null +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/mod.rs @@ -0,0 +1,4 @@ +pub mod category_transferrer_tester; +pub mod torrent_transferrer_tester; +pub mod tracker_key_transferrer_tester; +pub mod user_transferrer_tester; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs similarity index 100% rename from tests/upgrades/from_v1_0_0_to_v2_0_0/testers/torrent_tester.rs rename to tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/tracker_key_transferrer_tester.rs similarity index 100% rename from tests/upgrades/from_v1_0_0_to_v2_0_0/testers/tracker_key_tester.rs rename to tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/tracker_key_transferrer_tester.rs diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/user_transferrer_tester.rs similarity index 100% rename from tests/upgrades/from_v1_0_0_to_v2_0_0/testers/user_tester.rs rename to tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/user_transferrer_tester.rs diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index aa0e2a75..9e207b22 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -19,10 +19,10 @@ use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{datetime_ use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::sqlite_v2_0_0::SqliteDatabaseV2_0_0; -use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::category_tester::CategoryTester; -use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::torrent_tester::TorrentTester; -use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::tracker_key_tester::TrackerKeyTester; -use crate::upgrades::from_v1_0_0_to_v2_0_0::testers::user_tester::UserTester; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrer_testers::category_transferrer_tester::CategoryTester; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrer_testers::torrent_transferrer_tester::TorrentTester; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrer_testers::tracker_key_transferrer_tester::TrackerKeyTester; +use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrer_testers::user_transferrer_tester::UserTester; struct TestConfig { // Directories From 5a7d87517cbaa8867a0a7f35a0a8a288205c361a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 30 Nov 2022 13:54:14 +0000 Subject: [PATCH 066/357] feat: [#56] console command to import tracker stats for all torrents --- src/bin/import_tracker_statistics.rs | 10 +++ .../commands/import_tracker_statistics.rs | 86 +++++++++++++++++++ src/console/commands/mod.rs | 1 + src/console/mod.rs | 1 + src/lib.rs | 1 + .../from_v1_0_0_to_v2_0_0/upgrader.rs | 14 ++- 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/bin/import_tracker_statistics.rs create mode 100644 src/console/commands/import_tracker_statistics.rs create mode 100644 src/console/commands/mod.rs create mode 100644 src/console/mod.rs diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs new file mode 100644 index 00000000..3f8456c4 --- /dev/null +++ b/src/bin/import_tracker_statistics.rs @@ -0,0 +1,10 @@ +//! Import Tracker Statistics command. +//! It imports the number of seeders and leechers for all torrent from the linked tracker. +//! You can execute it with: `cargo run --bin import_tracker_statistics` + +use torrust_index_backend::console::commands::import_tracker_statistics::run_importer; + +#[actix_web::main] +async fn main() { + run_importer().await; +} diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs new file mode 100644 index 00000000..f5dba839 --- /dev/null +++ b/src/console/commands/import_tracker_statistics.rs @@ -0,0 +1,86 @@ +//! It imports statistics for all torrents from the linked tracker. + +use std::env; +use std::sync::Arc; + +use derive_more::{Display, Error}; +use text_colorizer::*; + +use crate::config::Configuration; +use crate::databases::database::connect_database; +use crate::tracker::TrackerService; + +const NUMBER_OF_ARGUMENTS: usize = 0; + +#[derive(Debug)] +pub struct Arguments {} + +#[derive(Debug, Display, PartialEq, Error)] +#[allow(dead_code)] +pub enum ImportError { + #[display(fmt = "internal server error")] + WrongNumberOfArgumentsError, +} + +fn parse_args() -> Result { + let args: Vec = env::args().skip(1).collect(); + + if args.len() != NUMBER_OF_ARGUMENTS { + eprintln!( + "{} wrong number of arguments: expected {}, got {}", + "Error".red().bold(), + NUMBER_OF_ARGUMENTS, + args.len() + ); + print_usage(); + return Err(ImportError::WrongNumberOfArgumentsError); + } + + Ok(Arguments {}) +} + +fn print_usage() { + eprintln!( + "{} - imports torrents statistics from linked tracker. + + cargo run --bin upgrade SOURCE_DB_FILE DESTINY_DB_FILE TORRENT_UPLOAD_DIR + + For example: + + cargo run --bin import_tracker_statistics + + ", + "Upgrader".green() + ); +} + +pub async fn run_importer() { + import(&parse_args().unwrap()).await; +} + +pub async fn import(_args: &Arguments) { + println!("Importing statistics from linked tracker ..."); + + let cfg = match Configuration::load_from_file().await { + Ok(config) => Arc::new(config), + Err(error) => { + panic!("{}", error) + } + }; + + let settings = cfg.settings.read().await; + + let tracker_url = settings.tracker.url.clone(); + + eprintln!("Tracker url: {}", tracker_url.green()); + + let database = Arc::new( + connect_database(&settings.database.connect_url) + .await + .expect("Database error."), + ); + + let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); + + tracker_service.update_torrents().await.unwrap(); +} diff --git a/src/console/commands/mod.rs b/src/console/commands/mod.rs new file mode 100644 index 00000000..6dad4966 --- /dev/null +++ b/src/console/commands/mod.rs @@ -0,0 +1 @@ +pub mod import_tracker_statistics; diff --git a/src/console/mod.rs b/src/console/mod.rs new file mode 100644 index 00000000..82b6da3c --- /dev/null +++ b/src/console/mod.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/src/lib.rs b/src/lib.rs index d7ef0d09..0d2cc49e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod common; pub mod config; +pub mod console; pub mod databases; pub mod errors; pub mod mailer; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 07accb78..0e18d417 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -23,7 +23,7 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::t use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::tracker_key_transferrer::transfer_tracker_keys; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::user_transferrer::transfer_users; -const NUMBER_OF_ARGUMENTS: i64 = 3; +const NUMBER_OF_ARGUMENTS: usize = 3; #[derive(Debug)] pub struct Arguments { @@ -50,7 +50,7 @@ fn print_usage() { fn parse_args() -> Arguments { let args: Vec = env::args().skip(1).collect(); - if args.len() != 3 { + if args.len() != NUMBER_OF_ARGUMENTS { eprintln!( "{} wrong number of arguments: expected {}, got {}", "Error".red().bold(), @@ -88,6 +88,16 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) { transfer_users(source_database.clone(), target_database.clone(), date_imported).await; transfer_tracker_keys(source_database.clone(), target_database.clone()).await; transfer_torrents(source_database.clone(), target_database.clone(), &args.upload_path).await; + + println!("Upgrade data from version v1.0.0 to v2.0.0 finished!\n"); + + eprintln!( + "{}\nWe recommend you to run the command to import torrent statistics for all torrents manually. \ + If you do not do it the statistics will be imported anyway during the normal execution of the program. \ + You can import statistics manually with:\n {}", + "SUGGESTION: \n".yellow(), + "cargo run --bin import_tracker_statistics".yellow() + ); } /// Current datetime in ISO8601 without time zone. From 83bafb4d1056411b0337b56a9449970d534c3178 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 30 Nov 2022 15:43:53 +0000 Subject: [PATCH 067/357] fix: console command help messages --- src/console/commands/import_tracker_statistics.rs | 6 +----- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index f5dba839..9d60fcf2 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -43,14 +43,10 @@ fn print_usage() { eprintln!( "{} - imports torrents statistics from linked tracker. - cargo run --bin upgrade SOURCE_DB_FILE DESTINY_DB_FILE TORRENT_UPLOAD_DIR - - For example: - cargo run --bin import_tracker_statistics ", - "Upgrader".green() + "Tracker Statistics Importer".green() ); } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 0e18d417..0cc0ea53 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -36,7 +36,7 @@ fn print_usage() { eprintln!( "{} - migrates date from version v1.0.0 to v2.0.0. - cargo run --bin upgrade SOURCE_DB_FILE DESTINY_DB_FILE TORRENT_UPLOAD_DIR + cargo run --bin upgrade SOURCE_DB_FILE TARGET_DB_FILE TORRENT_UPLOAD_DIR For example: From f68d625856d57b5d83f19ab7807ad4191383d501 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 30 Nov 2022 16:02:21 +0000 Subject: [PATCH 068/357] feat: [#85] add cspell configuration --- cspell.json | 13 ++++++++++++ project-words.txt | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 cspell.json create mode 100644 project-words.txt diff --git a/cspell.json b/cspell.json new file mode 100644 index 00000000..8b597b71 --- /dev/null +++ b/cspell.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "version": "0.2", + "dictionaryDefinitions": [ + { + "name": "project-words", + "path": "./project-words.txt", + "addWords": true + } + ], + "dictionaries": ["project-words"], + "ignorePaths": ["target", "/project-words.txt"] +} diff --git a/project-words.txt b/project-words.txt new file mode 100644 index 00000000..e6f61e3a --- /dev/null +++ b/project-words.txt @@ -0,0 +1,51 @@ +actix +AUTOINCREMENT +bencode +bencoded +btih +chrono +compatiblelicenses +creativecommons +creds +Culqt +Cyberneering +datetime +DATETIME +Dont +Grünwald +hasher +Hasher +httpseeds +imagoodboy +jsonwebtoken +leechers +Leechers +LEECHERS +lettre +luckythelab +nanos +NCCA +nilm +nocapture +Oberhachingerstr +ppassword +reqwest +Roadmap +ROADMAP +rowid +sgxj +singlepart +sqlx +strftime +sublicensable +sublist +subpoints +torrust +Torrust +upgrader +Uragqm +urlencoding +uroot +Verstappen +waivable +Xoauth From 728fe8a17e090b56dbde3284c22f3956b641edbd Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 30 Nov 2022 16:40:23 +0000 Subject: [PATCH 069/357] fix: [#78] remove unused struct NewTrackerKey It was added because the tracker API was changed, but that change was reverted here: https://github.com/torrust/torrust-tracker/pull/109 and it is not used anymore. --- src/models/tracker_key.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/models/tracker_key.rs b/src/models/tracker_key.rs index b1baea72..60993c61 100644 --- a/src/models/tracker_key.rs +++ b/src/models/tracker_key.rs @@ -7,12 +7,6 @@ pub struct TrackerKey { pub valid_until: i64, } -#[derive(Debug, Serialize, Deserialize)] -pub struct NewTrackerKey { - pub key: String, - pub valid_until: Duration, -} - #[derive(Debug, Serialize, Deserialize)] pub struct Duration { pub secs: i64, From 0c4eb02c17f77e6269612b14a422ffea8a0db3dc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 13 Mar 2023 14:22:52 +0000 Subject: [PATCH 070/357] feat: [#94] add prefix v1 to tracker API The tracker API uses now a prefix with the version. It has changed from: http://0.0.0.0:1212/api/stats?token=MyAccessToken to http://0.0.0.0:1212/api/v1/stats?token=MyAccessToken --- src/tracker.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tracker.rs b/src/tracker.rs index 4ceae007..984ee451 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -47,7 +47,7 @@ impl TrackerService { let settings = self.cfg.settings.read().await; let request_url = format!( - "{}/api/whitelist/{}?token={}", + "{}/api/v1/whitelist/{}?token={}", settings.tracker.api_url, info_hash, settings.tracker.token ); @@ -72,7 +72,7 @@ impl TrackerService { let settings = self.cfg.settings.read().await; let request_url = format!( - "{}/api/whitelist/{}?token={}", + "{}/api/v1/whitelist/{}?token={}", settings.tracker.api_url, info_hash, settings.tracker.token ); @@ -114,7 +114,7 @@ impl TrackerService { let settings = self.cfg.settings.read().await; let request_url = format!( - "{}/api/key/{}?token={}", + "{}/api/v1/key/{}?token={}", settings.tracker.api_url, settings.tracker.token_valid_seconds, settings.tracker.token ); @@ -149,7 +149,7 @@ impl TrackerService { let tracker_url = settings.tracker.url.clone(); let request_url = format!( - "{}/api/torrent/{}?token={}", + "{}/api/v1/torrent/{}?token={}", settings.tracker.api_url, info_hash, settings.tracker.token ); From 247a0430902c70e5b9c3673a6794a529a0f98b80 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Mon, 20 Mar 2023 08:13:59 +0100 Subject: [PATCH 071/357] feat: update licence files In this comment we update the licence files to make the format more simple. --- COPYRIGHT | 12 + LICENSE | 13 - LICENSE-AGPL_3_0 | 662 +++++++++++++++++++++++++++++++++++++++++++++++ LICENSE-MIT_0 | 14 + 4 files changed, 688 insertions(+), 13 deletions(-) create mode 100644 COPYRIGHT delete mode 100644 LICENSE create mode 100644 LICENSE-AGPL_3_0 create mode 100644 LICENSE-MIT_0 diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 00000000..4c96c089 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,12 @@ +Copyright 2023 in the Torrust-Index-Backend project are retained by their contributors. No +copyright assignment is required to contribute to the Torrust-Index-Backend project. + +Some files include explicit copyright notices and/or license notices. + +Except as otherwise noted (below and/or in individual files), Torrust-Index-Backend is +licensed under the GNU Affero General Public License, Version 3.0 . This license applies to all files in the Torrust-Index-Backend project, except as noted below. + +Except as otherwise noted (below and/or in individual files), Torrust-Index-Backend is licensed under the MIT-0 license for all commits made after 5 years of merging. This license applies to the version of the files merged into the Torrust-Index-Backend project at the time of merging, and does not apply to subsequent updates or revisions to those files. + +The contributors to the Torrust-Index-Backend project disclaim all liability for any damages or losses that may arise from the use of the project. + diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 6618a8f4..00000000 --- a/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -# Multiple Licenses - -This repository has multiple licenses depending on the content type, the date of contributions or stemming from external component licenses that were not developed by any of Torrust team members or Torrust repository contributors. - -The two main applicable license to most of its content are: - -- For Code -- [agpl-3.0](https://github.com/torrust/torrust-index/blob/main/licensing/agpl-3.0.md) - -- For Media (Images, etc.) -- [cc-by-sa](https://github.com/torrust/torrust-index/blob/main/licensing/cc-by-sa.md) - -We make the exception that projects that distribute this work only need to include the name and version of the license, instead of needing to include them verbatim in the package. - -If you want to read more about all the licenses and how they apply please refer to the [contributor agreement](https://github.com/torrust/torrust-index/blob/main/licensing/contributor_agreement_v01.md). diff --git a/LICENSE-AGPL_3_0 b/LICENSE-AGPL_3_0 new file mode 100644 index 00000000..2beb9e16 --- /dev/null +++ b/LICENSE-AGPL_3_0 @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + diff --git a/LICENSE-MIT_0 b/LICENSE-MIT_0 new file mode 100644 index 00000000..fc06cc4f --- /dev/null +++ b/LICENSE-MIT_0 @@ -0,0 +1,14 @@ +MIT No Attribution + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From ec527133b1b3da97fd6f3681b0373d04ada0e6c8 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 21 Mar 2023 16:47:35 +0100 Subject: [PATCH 072/357] docs: minimise and cleanup readme file --- README.md | 67 +++++++++++-------------------------------------------- 1 file changed, 13 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 130113c3..3d62c011 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,24 @@ # Torrust Index Backend -![README HEADER](./img/Torrust_Repo_BackEnd_Readme_Header-20220615.jpg) +This repository serves as the backend for the [Torrust Index](https://github.com/torrust/torrust-index) project, that implements the [Torrust Index Application Interface](https://github.com/torrust/torrust-index-api-lib). -![Open Source](https://badgen.net/badge/Open%20Source/100%25/DA2CE7) -![Cool](https://badgen.net/badge/Cool/100%25/FF7F50) - -![Nautilus Sponsored](https://badgen.net/badge/Sponsor/Nautilus%20Cyberneering/red) - ---- - -## 📢Important Updates 📢 - -- None at the moment [ACCESS ALL UPDATES](https://github.com/torrust/torrust-index-backend/wiki/Project-Updates) - ---- - -## Index - -- [PROJECT DESCRIPTION](#project-description) -- [PROJECT ROADMAP](#project_roadmap) -- [DOCUMENTATION](#documentation) -- [INSTALLATION](#installation) -- [CONTACT & CONTRIBUTING](#contact_and_contributing) -- [CREDITS](#credits) - -## Project Description - -This repository serves as the backend for the [Torrust Index](https://github.com/torrust/torrust-index) project. - -### Roadmap - -*Coming soon.* +We also provide the [Torrust Index Frontend](https://github.com/torrust/torrust-index-frontend) project, that is our reference web-application that consumes the API provided here. ## Documentation -You can read the documentation [here](https://torrust.com/torrust-index/install/#installing-the-backend). +You can read the Torrust Index documentation [here](https://torrust.com/torrust-index/install/#installing-the-backend). ## Installation -1. Install prerequisites: - - - [Rust/Cargo](https://www.rust-lang.org/) - Compiler toolchain & Package Manager (cargo). +1. Setup [Rust / Cargo](https://www.rust-lang.org/) in your Environment. -2. Clone the repository: - - ```bash - git clone https://github.com/torrust/torrust-index-backend.git - ``` +2. Clone this repo. -3. Open the project directory and create a file called: `.env`: +3. Set the database connection URI in the projects `/.env` file: ```bash cd torrust-index-backend - echo "DATABASE_URL=sqlite://data.db?mode=rwc" > .env + echo "DATABASE_URL=sqlite://data.db?mode=rwc" >> .env ``` 4. Install sqlx-cli and build the sqlite database: @@ -74,11 +40,9 @@ You can read the documentation [here](https://torrust.com/torrust-index/install/ ./target/release/torrust-index-backend ``` -7. Edit the newly generated `config.toml` file ([config.toml documentation](https://torrust.github.io/torrust-tracker/CONFIG.html)): +7. Review and edit the default `/config.toml` file. - ```bash - nano config.toml - ``` +> _Please view the [configuration documentation](https://torrust.github.io/torrust-tracker/CONFIG.html)._ 8. Run the backend again: @@ -88,17 +52,12 @@ You can read the documentation [here](https://torrust.com/torrust-index/install/ ## Contact and Contributing -Feel free to contact us via: - -Message `Warm Beer#3352` on Discord or email `mick@dutchbits.nl`. - -or - -Please make suggestions and report any **Torrust Index Back End** specific bugs you find to the issue tracker of this repository [here](https://github.com/torrust/torrust-index-backend/issues) +Please consider the [Torrust Contribution Guide](https://github.com/torrust/.github/blob/main/info/contributing.md). -**Torrust Index Front End** specific issues can be submitted [here](https://github.com/torrust/torrust-index-frontend/issues). +Please report issues: -Universal issues with the **Torrust Index** can be submitted [here](https://github.com/torrust/torrust-index/issues). Ideas and feature requests are welcome as well! +* Torrust Index Backend specifically: [here](https://github.com/torrust/torrust-index-backend/issues). +* Torrust Index in general: [here](https://github.com/torrust/torrust-index/issues). --- From 005eae586b3c4730e271147f9be33b9e5099f620 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 18 Apr 2023 11:26:47 +0100 Subject: [PATCH 073/357] feat: optionally load config with env var You can load configuration with an env var instead of the `config.toml` file: ``` TORRUST_IDX_BACK_CONFIG=$(cat config.toml) cargo run ``` This will make easier to add Docker support. --- Cargo.lock | 252 ++++++++++++++++++++++++--------------- Cargo.toml | 4 +- src/bin/main.rs | 34 ++++-- src/bootstrap/logging.rs | 56 +++++++++ src/bootstrap/mod.rs | 1 + src/config.rs | 51 ++++++-- src/lib.rs | 1 + 7 files changed, 285 insertions(+), 114 deletions(-) create mode 100644 src/bootstrap/logging.rs create mode 100644 src/bootstrap/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 0246f562..4fe190ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,7 +69,7 @@ dependencies = [ "pin-project-lite", "rand", "regex", - "serde 1.0.144", + "serde", "sha-1 0.9.8", "smallvec", "time 0.2.27", @@ -116,7 +116,7 @@ dependencies = [ "http", "log", "regex", - "serde 1.0.144", + "serde", ] [[package]] @@ -217,7 +217,7 @@ dependencies = [ "paste", "pin-project", "regex", - "serde 1.0.144", + "serde", "serde_json", "serde_urlencoded", "smallvec", @@ -274,12 +274,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "arrayvec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" - [[package]] name = "async-trait" version = "0.1.52" @@ -297,7 +291,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" dependencies = [ - "num-traits 0.2.14", + "num-traits", ] [[package]] @@ -450,7 +444,7 @@ checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" dependencies = [ "libc", "num-integer", - "num-traits 0.2.14", + "num-traits", "time 0.1.43", "winapi", ] @@ -468,15 +462,18 @@ dependencies = [ [[package]] name = "config" -version = "0.11.0" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1b9d958c2b1368a663f05538fc1b5975adce1e19f435acceae987aceeeb369" +checksum = "d379af7f68bfc21714c6c7dea883544201741d2ce8274bb12fa54f89507f52a7" dependencies = [ + "async-trait", + "json5", "lazy_static", - "nom 5.1.2", + "nom 7.0.0", + "pathdiff", + "ron", "rust-ini", - "serde 1.0.144", - "serde-hjson", + "serde", "serde_json", "toml", "yaml-rust", @@ -669,6 +666,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + [[package]] name = "dotenvy" version = "0.15.3" @@ -708,6 +711,15 @@ dependencies = [ "instant", ] +[[package]] +name = "fern" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" +dependencies = [ + "log", +] + [[package]] name = "filetime" version = "0.2.15" @@ -1144,6 +1156,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonwebtoken" version = "8.1.1" @@ -1153,7 +1176,7 @@ dependencies = [ "base64", "pem", "ring", - "serde 1.0.144", + "serde", "serde_json", "simple_asn1", ] @@ -1201,19 +1224,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "lexical-core" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" -dependencies = [ - "arrayvec", - "bitflags", - "cfg-if", - "ryu", - "static_assertions", -] - [[package]] name = "libc" version = "0.2.132" @@ -1272,9 +1282,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.14" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ "cfg-if", ] @@ -1359,17 +1369,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nom" -version = "5.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" -dependencies = [ - "lexical-core", - "memchr", - "version_check", -] - [[package]] name = "nom" version = "6.1.2" @@ -1410,7 +1409,7 @@ checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" dependencies = [ "autocfg", "num-integer", - "num-traits 0.2.14", + "num-traits", ] [[package]] @@ -1424,7 +1423,7 @@ dependencies = [ "libm", "num-integer", "num-iter", - "num-traits 0.2.14", + "num-traits", "rand", "smallvec", "zeroize", @@ -1437,7 +1436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" dependencies = [ "autocfg", - "num-traits 0.2.14", + "num-traits", ] [[package]] @@ -1448,16 +1447,7 @@ checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" dependencies = [ "autocfg", "num-integer", - "num-traits 0.2.14", -] - -[[package]] -name = "num-traits" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" -dependencies = [ - "num-traits 0.2.14", + "num-traits", ] [[package]] @@ -1534,6 +1524,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown", +] + [[package]] name = "parking_lot" version = "0.11.1" @@ -1576,6 +1576,12 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1de2e551fb905ac83f73f7aedf2f0cb4a0da7e35efa24a202a936269f1f18e1" +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "pbkdf2" version = "0.11.0" @@ -1612,6 +1618,50 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pest" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc7bc69c062e492337d74d59b120c274fd3d261b6bf6d3207d499b4b379c41a" +dependencies = [ + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b75706b9642ebcb34dab3bc7750f811609a0eb1dd8b88c2d15bf628c1c65b2" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f9272122f5979a6511a749af9db9bfc810393f63119970d7085fed1c4ea0db" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8717927f9b79515e565a64fe46c38b8cd0427e64c40680b14a7365ab09ac8d" +dependencies = [ + "once_cell", + "pest", + "sha1 0.10.4", +] + [[package]] name = "pin-project" version = "1.0.7" @@ -1824,7 +1874,7 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "serde 1.0.144", + "serde", "serde_json", "serde_urlencoded", "tokio", @@ -1851,6 +1901,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64", + "bitflags", + "serde", +] + [[package]] name = "rsa" version = "0.6.1" @@ -1862,7 +1923,7 @@ dependencies = [ "num-bigint-dig", "num-integer", "num-iter", - "num-traits 0.2.14", + "num-traits", "pkcs1", "pkcs8", "rand_core", @@ -1873,9 +1934,13 @@ dependencies = [ [[package]] name = "rust-ini" -version = "0.13.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e52c148ef37f8c375d49d5a73aa70713125b7f19095948a923f80afdeb22ec2" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] [[package]] name = "rustc_version" @@ -1928,7 +1993,7 @@ dependencies = [ "memchr", "proc-macro2", "quote", - "serde 1.0.144", + "serde", "syn", "toml", ] @@ -2016,12 +2081,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" -[[package]] -name = "serde" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dad3f759919b92c3068c696c15c3d17238234498bbdcc80f2c469606f948ac8" - [[package]] name = "serde" version = "1.0.144" @@ -2031,25 +2090,13 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-hjson" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a4e0ea8a88553209f6cc6cfe8724ecad22e1acf372793c27d995290fe74f8" -dependencies = [ - "lazy_static", - "num-traits 0.1.43", - "regex", - "serde 0.8.23", -] - [[package]] name = "serde_bencode" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "934d8bdbaa0126dafaea9a8833424a211d9661897717846c6bb782349ca1c30d" dependencies = [ - "serde 1.0.144", + "serde", "serde_bytes", ] @@ -2059,7 +2106,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16ae07dd2f88a366f15bd0632ba725227018c69a1c8550a927324f8eb8368bb9" dependencies = [ - "serde 1.0.144", + "serde", ] [[package]] @@ -2081,7 +2128,7 @@ checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" dependencies = [ "itoa 0.4.7", "ryu", - "serde 1.0.144", + "serde", ] [[package]] @@ -2093,7 +2140,7 @@ dependencies = [ "form_urlencoded", "itoa 0.4.7", "ryu", - "serde 1.0.144", + "serde", ] [[package]] @@ -2126,6 +2173,17 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d" +[[package]] +name = "sha1" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "006769ba83e921b3085caa8334186b00cf92b4cb1a6cf4632fbccc8eff5c7549" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.3", +] + [[package]] name = "sha2" version = "0.10.5" @@ -2153,7 +2211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", - "num-traits 0.2.14", + "num-traits", "thiserror", "time 0.3.14", ] @@ -2316,12 +2374,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "stdweb" version = "0.4.20" @@ -2344,7 +2396,7 @@ checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" dependencies = [ "proc-macro2", "quote", - "serde 1.0.144", + "serde", "serde_derive", "syn", ] @@ -2358,10 +2410,10 @@ dependencies = [ "base-x", "proc-macro2", "quote", - "serde 1.0.144", + "serde", "serde_derive", "serde_json", - "sha1", + "sha1 0.6.0", "syn", ] @@ -2611,7 +2663,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" dependencies = [ - "serde 1.0.144", + "serde", ] [[package]] @@ -2626,15 +2678,17 @@ dependencies = [ "chrono", "config", "derive_more", + "fern", "futures", "jsonwebtoken", "lettre", + "log", "pbkdf2", "rand_core", "regex", "reqwest", "sailfish", - "serde 1.0.144", + "serde", "serde_bencode", "serde_bytes", "serde_derive", @@ -2695,6 +2749,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "ucd-trie" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" + [[package]] name = "unchecked-index" version = "0.2.2" @@ -2796,7 +2856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce9b1b516211d33767048e5d47fa2a381ed8b76fc48d2ce4aa39877f9f183e0" dependencies = [ "cfg-if", - "serde 1.0.144", + "serde", "serde_json", "wasm-bindgen-macro", ] diff --git a/Cargo.toml b/Cargo.toml index f16bfeeb..1eb837e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ actix-cors = "0.6.0-beta.2" async-trait = "0.1.52" futures = "0.3.5" sqlx = { version = "0.6.1", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } -config = "0.11" +config = "0.13" toml = "0.5" derive_more = "0.99" serde = { version = "1.0", features = ["rc"] } @@ -36,3 +36,5 @@ sailfish = "0.4.0" regex = "1.6.0" pbkdf2 = "0.11.0" text-colorizer = "1.0.0" +log = "0.4.17" +fern = "0.6.2" diff --git a/src/bin/main.rs b/src/bin/main.rs index ce7bf581..f95ee91d 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,10 +1,12 @@ +use std::env; use std::sync::Arc; use actix_cors::Cors; use actix_web::{middleware, web, App, HttpServer}; use torrust_index_backend::auth::AuthorizationService; +use torrust_index_backend::bootstrap::logging; use torrust_index_backend::common::AppData; -use torrust_index_backend::config::Configuration; +use torrust_index_backend::config::{Configuration, CONFIG_ENV_VAR_NAME, CONFIG_PATH}; use torrust_index_backend::databases::database::connect_database; use torrust_index_backend::mailer::MailerService; use torrust_index_backend::routes; @@ -12,12 +14,11 @@ use torrust_index_backend::tracker::TrackerService; #[actix_web::main] async fn main() -> std::io::Result<()> { - let cfg = match Configuration::load_from_file().await { - Ok(config) => Arc::new(config), - Err(error) => { - panic!("{}", error) - } - }; + let configuration = init_configuration().await; + + logging::setup(); + + let cfg = Arc::new(configuration); let settings = cfg.settings.read().await; @@ -60,7 +61,7 @@ async fn main() -> std::io::Result<()> { drop(settings); - println!("Listening on 0.0.0.0:{}", port); + println!("Listening on http://0.0.0.0:{}", port); HttpServer::new(move || { App::new() @@ -73,3 +74,20 @@ async fn main() -> std::io::Result<()> { .run() .await } + +async fn init_configuration() -> Configuration { + if env::var(CONFIG_ENV_VAR_NAME).is_ok() { + println!("Loading configuration from env var `{}`", CONFIG_ENV_VAR_NAME); + + Configuration::load_from_env_var(CONFIG_ENV_VAR_NAME).unwrap() + } else { + println!("Loading configuration from config file `{}`", CONFIG_PATH); + + match Configuration::load_from_file().await { + Ok(config) => config, + Err(error) => { + panic!("{}", error) + } + } + } +} diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs new file mode 100644 index 00000000..a1441827 --- /dev/null +++ b/src/bootstrap/logging.rs @@ -0,0 +1,56 @@ +//! Setup for the application logging. +//! +//! - `Off` +//! - `Error` +//! - `Warn` +//! - `Info` +//! - `Debug` +//! - `Trace` +use std::str::FromStr; +use std::sync::Once; + +use log::{info, LevelFilter}; + +static INIT: Once = Once::new(); + +pub fn setup() { + // todo: load log level from configuration. + + let level = config_level_or_default(&None); + + if level == log::LevelFilter::Off { + return; + } + + INIT.call_once(|| { + stdout_config(level); + }); +} + +fn config_level_or_default(log_level: &Option) -> LevelFilter { + match log_level { + None => log::LevelFilter::Warn, + Some(level) => LevelFilter::from_str(level).unwrap(), + } +} + +fn stdout_config(level: LevelFilter) { + if let Err(_err) = fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{} [{}][{}] {}", + chrono::Local::now().format("%+"), + record.target(), + record.level(), + message + )); + }) + .level(level) + .chain(std::io::stdout()) + .apply() + { + panic!("Failed to initialize logging.") + } + + info!("logging initialized."); +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs new file mode 100644 index 00000000..31348d2f --- /dev/null +++ b/src/bootstrap/mod.rs @@ -0,0 +1 @@ +pub mod logging; diff --git a/src/config.rs b/src/config.rs index 110d7fb2..65f1a25e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,14 @@ -use std::fs; use std::path::Path; +use std::{env, fs}; -use config::{Config, ConfigError, File}; +use config::{Config, ConfigError, File, FileFormat}; +use log::warn; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; +pub const CONFIG_PATH: &str = "./config.toml"; +pub const CONFIG_ENV_VAR_NAME: &str = "TORRUST_IDX_BACK_CONFIG"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Website { pub name: String, @@ -12,6 +16,7 @@ pub struct Website { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TrackerMode { + // todo: use https://crates.io/crates/torrust-tracker-primitives Public, Private, Whitelisted, @@ -123,16 +128,18 @@ impl Configuration { } } + /// Loads the configuration from the configuration file. pub async fn load_from_file() -> Result { - let mut config = Config::new(); + let config_builder = Config::builder(); - const CONFIG_PATH: &str = "config.toml"; + #[allow(unused_assignments)] + let mut config = Config::default(); if Path::new(CONFIG_PATH).exists() { - config.merge(File::with_name(CONFIG_PATH))?; + config = config_builder.add_source(File::with_name(CONFIG_PATH)).build()?; } else { - eprintln!("No config file found."); - eprintln!("Creating config file.."); + warn!("No config file found."); + warn!("Creating config file.."); let config = Configuration::default(); let _ = config.save_to_file().await; return Err(ConfigError::Message( @@ -140,7 +147,7 @@ impl Configuration { )); } - let torrust_config: TorrustConfig = match config.try_into() { + let torrust_config: TorrustConfig = match config.try_deserialize() { Ok(data) => Ok(data), Err(e) => Err(ConfigError::Message(format!("Errors while processing config: {}.", e))), }?; @@ -150,6 +157,32 @@ impl Configuration { }) } + /// Loads the configuration from the environment variable. The whole + /// configuration must be in the environment variable. It contains the same + /// configuration as the configuration file with the same format. + /// + /// # Errors + /// + /// Will return `Err` if the environment variable does not exist or has a bad configuration. + pub fn load_from_env_var(config_env_var_name: &str) -> Result { + match env::var(config_env_var_name) { + Ok(config_toml) => { + let config_builder = Config::builder() + .add_source(File::from_str(&config_toml, FileFormat::Toml)) + .build()?; + let torrust_config: TorrustConfig = config_builder.try_deserialize()?; + Ok(Configuration { + settings: RwLock::new(torrust_config), + }) + } + Err(_) => { + return Err(ConfigError::Message( + "Unable to load configuration from the configuration environment variable.".to_string(), + )) + } + } + } + pub async fn save_to_file(&self) -> Result<(), ()> { let settings = self.settings.read().await; @@ -157,7 +190,7 @@ impl Configuration { drop(settings); - fs::write("config.toml", toml_string).expect("Could not write to file!"); + fs::write(CONFIG_PATH, toml_string).expect("Could not write to file!"); Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 0d2cc49e..7958c644 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod bootstrap; pub mod common; pub mod config; pub mod console; From e6c5e302329bc30099ce7776b0e104a5efcf6a05 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 18 Apr 2023 11:49:53 +0100 Subject: [PATCH 074/357] feat: update vscode config to follow Tracker conventions --- .vscode/extensions.json | 6 ++++++ .vscode/settings.json | 12 +++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..bc463a8a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "streetsidesoftware.code-spell-checker", + "rust-lang.rust-analyzer" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index f1027e9b..9966a630 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,8 @@ { - "[rust]": { - "editor.formatOnSave": true - }, - "rust-analyzer.checkOnSave.command": "clippy", -} \ No newline at end of file + "[rust]": { + "editor.formatOnSave": true + }, + "rust-analyzer.checkOnSave.command": "clippy", + "rust-analyzer.checkOnSave.allTargets": true, + "rust-analyzer.checkOnSave.extraArgs": ["--", "-W", "clippy::pedantic"] +} From 6ecde1d87a4e496cb6c5252d009b9ac51280662a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 18 Apr 2023 16:10:20 +0100 Subject: [PATCH 075/357] feat: add docker support --- .dockerignore | 22 +++ .env.local | 5 + .github/workflows/publish_docker_image.yml | 83 +++++++++++ .github/workflows/release.yml | 2 + .github/workflows/test_docker.yml | 26 ++++ .gitignore | 5 +- Dockerfile | 72 ++++++++++ bin/install.sh | 19 +++ compose.yaml | 67 +++++++++ config-idx-back.toml.local | 32 +++++ config-tracker.toml.local | 34 +++++ config.toml.local | 31 +++++ docker/README.md | 154 +++++++++++++++++++++ docker/bin/build.sh | 13 ++ docker/bin/install.sh | 4 + docker/bin/run.sh | 11 ++ 16 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.local create mode 100644 .github/workflows/publish_docker_image.yml create mode 100644 .github/workflows/test_docker.yml create mode 100644 Dockerfile create mode 100755 bin/install.sh create mode 100644 compose.yaml create mode 100644 config-idx-back.toml.local create mode 100644 config-tracker.toml.local create mode 100644 config.toml.local create mode 100644 docker/README.md create mode 100755 docker/bin/build.sh create mode 100755 docker/bin/install.sh create mode 100755 docker/bin/run.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..89d167c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +/.env +/.env.local +/.git +/.git-blame-ignore +/.github +/.gitignore +/.vscode +/data_v2.db* +/data.db* +/bin/ +/config-idx-back.toml.local +/config-tracker.toml.local +/config.toml +/config.toml.local +/cspell.json +/data.db +/docker/ +/project-words.txt +/README.md +/rustfmt.toml +/storage/ +/target/ diff --git a/.env.local b/.env.local new file mode 100644 index 00000000..ef58fd2e --- /dev/null +++ b/.env.local @@ -0,0 +1,5 @@ +DATABASE_URL=sqlite://storage/database/data.db?mode=rwc +TORRUST_IDX_BACK_CONFIG= +TORRUST_IDX_BACK_USER_UID=1000 +TORRUST_TRACKER_CONFIG= +TORRUST_TRACKER_USER_UID=1000 diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml new file mode 100644 index 00000000..f36bb714 --- /dev/null +++ b/.github/workflows/publish_docker_image.yml @@ -0,0 +1,83 @@ +name: Publish Docker Image + +on: + push: + branches: + - "main" + - "develop" + tags: + - "v*" + +env: + TORRUST_IDX_BACK_RUN_AS_USER: appuser + +jobs: + check-secret: + runs-on: ubuntu-latest + environment: dockerhub-torrust + outputs: + publish: ${{ steps.check.outputs.publish }} + steps: + - id: check + env: + DOCKER_HUB_USERNAME: "${{ secrets.DOCKER_HUB_USERNAME }}" + if: "${{ env.DOCKER_HUB_USERNAME != '' }}" + run: echo "publish=true" >> $GITHUB_OUTPUT + + test: + needs: check-secret + if: needs.check-secret.outputs.publish == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - name: Run Tests + run: cargo test + + dockerhub: + needs: test + if: needs.check-secret.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: dockerhub-torrust + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: | + # For example: torrust/index-backend + "${{ secrets.DOCKER_HUB_USERNAME }}/${{secrets.DOCKER_HUB_REPOSITORY_NAME }}" + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + build-args: | + RUN_AS_USER=${{ env.TORRUST_IDX_BACK_RUN_AS_USER }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d7b0b30..38d427f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,5 @@ +name: Publish Github Release + on: push: branches: diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml new file mode 100644 index 00000000..30da662d --- /dev/null +++ b/.github/workflows/test_docker.yml @@ -0,0 +1,26 @@ +name: Test Docker build + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build docker image + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build docker-compose images + run: docker compose build diff --git a/.gitignore b/.gitignore index 42a0fc28..282b85e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /.env /config.toml -/data.db* /data_v2.db* +/data.db* +/storage/ /target -/uploads/ \ No newline at end of file +/uploads/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..08744910 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +FROM clux/muslrust:stable AS chef +WORKDIR /app +RUN cargo install cargo-chef + + +FROM chef AS planner +WORKDIR /app +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + + +FROM chef as development +WORKDIR /app +ARG UID=1000 +ARG RUN_AS_USER=appuser +ARG IDX_BACK_API_PORT=3000 +# Add the app user for development +ENV USER=appuser +ENV UID=$UID +RUN adduser --uid "${UID}" "${USER}" +# Build dependencies +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --recipe-path recipe.json +# Build the application +COPY . . +RUN cargo build --bin main +USER $RUN_AS_USER:$RUN_AS_USER +EXPOSE $IDX_BACK_API_PORT/tcp +CMD ["cargo", "run"] + + +FROM chef AS builder +WORKDIR /app +ARG UID=1000 +# Add the app user for production +ENV USER=appuser +ENV UID=$UID +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + "${USER}" +# Build dependencies +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json +# Build the application +COPY . . +RUN cargo build --release --target x86_64-unknown-linux-musl --bin main +# Strip the binary +# More info: https://github.com/LukeMathWalker/cargo-chef/issues/149 +RUN strip /app/target/x86_64-unknown-linux-musl/release/main + + +FROM alpine:latest +WORKDIR /app +ARG RUN_AS_USER=appuser +ARG IDX_BACK_API_PORT=3000 +RUN apk --no-cache add ca-certificates +ENV TZ=Etc/UTC +ENV RUN_AS_USER=$RUN_AS_USER +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder --chown=$RUN_AS_USER \ + /app/target/x86_64-unknown-linux-musl/release/main \ + /app/main +RUN chown -R $RUN_AS_USER:$RUN_AS_USER /app +USER $RUN_AS_USER:$RUN_AS_USER +EXPOSE $IDX_BACK_API_PORT/tcp +ENTRYPOINT ["/app/main"] \ No newline at end of file diff --git a/bin/install.sh b/bin/install.sh new file mode 100755 index 00000000..b8df7f7a --- /dev/null +++ b/bin/install.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Generate the default settings file if it does not exist +if ! [ -f "./config.toml" ]; then + cp ./config.toml.local ./config.toml +fi + +# Generate the sqlite database for the index baclend if it does not exist +if ! [ -f "./storage/database/data.db" ]; then + # todo: it should get the path from config.toml and only do it when we use sqlite + touch ./storage/database/data.db + echo ";" | sqlite3 ./storage/database/data.db +fi + +# Generate the sqlite database for the tracker if it does not exist +if ! [ -f "./storage/database/tracker.db" ]; then + touch ./storage/database/tracker.db + echo ";" | sqlite3 ./storage/database/tracker.db +fi diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..82658156 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,67 @@ +name: torrust +services: + + idx-back: + build: + context: . + target: development + user: ${TORRUST_IDX_BACK_USER_UID:-1000}:${TORRUST_IDX_BACK_USER_UID:-1000} + tty: true + environment: + - TORRUST_IDX_BACK_CONFIG=${TORRUST_IDX_BACK_CONFIG} + networks: + - server_side + ports: + - 3000:3000 + volumes: + - ./:/app + - ~/.cargo:/home/appuser/.cargo + depends_on: + - tracker + + tracker: + image: torrust/tracker:develop + user: ${TORRUST_TRACKER_USER_UID:-1000}:${TORRUST_TRACKER_USER_UID:-1000} + tty: true + environment: + - TORRUST_TRACKER_CONFIG=${TORRUST_TRACKER_CONFIG} + networks: + - server_side + ports: + - 6969:6969/udp + - 1212:1212/tcp + volumes: + - ./storage:/app/storage + depends_on: + - mysql + + mysql: + image: mysql:8.0 + command: '--default-authentication-plugin=mysql_native_password' + healthcheck: + test: + [ + 'CMD-SHELL', + 'mysqladmin ping -h 127.0.0.1 --password="$$(cat /run/secrets/db-password)" --silent' + ] + interval: 3s + retries: 5 + start_period: 30s + environment: + - MYSQL_ROOT_HOST=% + - MYSQL_ROOT_PASSWORD=root_secret_password + - MYSQL_DATABASE=torrust_index_backend + - MYSQL_USER=db_user + - MYSQL_PASSWORD=db_user_secret_password + networks: + - server_side + ports: + - 3306:3306 + volumes: + - mysql_data:/var/lib/mysql + +networks: + server_side: {} + +volumes: + mysql_data: {} diff --git a/config-idx-back.toml.local b/config-idx-back.toml.local new file mode 100644 index 00000000..2257c16f --- /dev/null +++ b/config-idx-back.toml.local @@ -0,0 +1,32 @@ +[website] +name = "Torrust" + +[tracker] +url = "udp://tracker:6969" +mode = "Public" +api_url = "http://tracker:1212" +token = "MyAccessToken" +token_valid_seconds = 7257600 + +[net] +port = 3000 + +[auth] +email_on_signup = "Optional" +min_password_length = 6 +max_password_length = 64 +secret_key = "MaxVerstappenWC2021" + +[database] +#connect_url = "sqlite://storage/database/data.db?mode=rwc" # SQLite +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL +torrent_info_update_interval = 3600 + +[mail] +email_verification_enabled = false +from = "example@email.com" +reply_to = "noreply@email.com" +username = "" +password = "" +server = "" +port = 25 diff --git a/config-tracker.toml.local b/config-tracker.toml.local new file mode 100644 index 00000000..82ceb285 --- /dev/null +++ b/config-tracker.toml.local @@ -0,0 +1,34 @@ +log_level = "info" +mode = "public" +db_driver = "Sqlite3" +db_path = "./storage/database/tracker.db" +announce_interval = 120 +min_announce_interval = 120 +max_peer_timeout = 900 +on_reverse_proxy = false +external_ip = "0.0.0.0" +tracker_usage_statistics = true +persistent_torrent_completed_stat = false +inactive_peer_cleanup_interval = 600 +remove_peerless_torrents = true + +[[udp_trackers]] +enabled = true +bind_address = "0.0.0.0:6969" + +[[http_trackers]] +enabled = false +bind_address = "0.0.0.0:7070" +ssl_enabled = false +ssl_cert_path = "" +ssl_key_path = "" + +[http_api] +enabled = true +bind_address = "0.0.0.0:1212" +ssl_enabled = false +ssl_cert_path = "" +ssl_key_path = "" + +[http_api.access_tokens] +admin = "MyAccessToken" diff --git a/config.toml.local b/config.toml.local new file mode 100644 index 00000000..c8154bc9 --- /dev/null +++ b/config.toml.local @@ -0,0 +1,31 @@ +[website] +name = "Torrust" + +[tracker] +url = "udp://localhost:6969" +mode = "Public" +api_url = "http://localhost:1212" +token = "MyAccessToken" +token_valid_seconds = 7257600 + +[net] +port = 3000 + +[auth] +email_on_signup = "Optional" +min_password_length = 6 +max_password_length = 64 +secret_key = "MaxVerstappenWC2021" + +[database] +connect_url = "sqlite://storage/database/data.db?mode=rwc" +torrent_info_update_interval = 3600 + +[mail] +email_verification_enabled = false +from = "example@email.com" +reply_to = "noreply@email.com" +username = "" +password = "" +server = "" +port = 25 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..02be6219 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,154 @@ +# Docker + +## Requirements + +- Docker version 20.10.21 +- You need to create the `storage` directory with this structure and files: + +```s +$ tree storage/ +storage/ +└── database +  ├── data.db +   └── tracker.db +``` + +## Dev environment + +### With docker + +Build and run locally: + +```s +docker context use default +export TORRUST_IDX_BACK_USER_UID=1000 +./docker/bin/build.sh $TORRUST_IDX_BACK_USER_UID +./bin/install.sh +./docker/bin/run.sh $TORRUST_IDX_BACK_USER_UID +``` + +Run using the pre-built public docker image: + +```s +export TORRUST_IDX_BACK_USER_UID=1000 +docker run -it \ + --user="$TORRUST_IDX_BACK_USER_UID" \ + --publish 3000:3000/tcp \ + --volume "$(pwd)/storage":"/app/storage" \ + torrust/index-backend +``` + +> NOTES: +> +> - You have to create the SQLite DB (`data.db`) and configuration (`config.toml`) before running the index backend. See `bin/install.sh`. +> - You have to replace the user UID (`1000`) with yours. +> - Remember to switch to your default docker context `docker context use default`. + +### With docker-compose + +The docker-compose configuration includes the MySQL service configuration. If you want to use MySQL instead of SQLite you have to change your `config.toml` or `config-idx-back.toml.local` configuration from: + +```toml +connect_url = "sqlite://storage/database/data.db?mode=rwc" +``` + +to: + +```toml +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" +``` + +If you want to inject an environment variable into docker-compose you can use the file `.env`. There is a template `.env.local`. + +Build and run it locally: + +```s +TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ +TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ + docker compose up --build +``` + +After running the "up" command you will have three running containers: + +```s +$ docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +e35b14edaceb torrust-idx-back "cargo run" 19 seconds ago Up 17 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp torrust-idx-back-1 +ddbad9fb496a torrust/tracker:develop "/app/torrust-tracker" 19 seconds ago Up 18 seconds 0.0.0.0:1212->1212/tcp, :::1212->1212/tcp, 0.0.0.0:6969->6969/udp, :::6969->6969/udp, 7070/tcp torrust-tracker-1 +f1d991d62170 mysql:8.0 "docker-entrypoint.s…" 3 hours ago Up 18 seconds (healthy) 0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp torrust-mysql-1 + torrust-mysql-1 +``` + +And you should be able to use the application, for example making a request to the API: + + + +The Tracker API is available at: + + + +> NOTICE: You have to bind the tracker services to the wildcard IP `0.0.0.0` to make it accessible from the host. + +You can stop the containers with: + +```s +docker compose down +``` + +Additionally, you can delete all resources (containers, volumes, networks) with: + +```s +docker compose down -v +``` + +### Access Mysql with docker + +These are some useful commands for MySQL. + +Open a shell in the MySQL container using docker or docker-compose. + +```s +docker exec -it torrust-mysql-1 /bin/bash +docker compose exec mysql /bin/bash +``` + +Connect to MySQL from inside the MySQL container or from the host: + +```s +mysql -h127.0.0.1 -uroot -proot_secret_password +``` + +The when MySQL container is started the first time, it creates the database, user, and permissions needed. +If you see the error "Host is not allowed to connect to this MySQL server" you can check that users have the right permissions in the database. Make sure the user `root` and `db_user` can connect from any host (`%`). + +```s +mysql> SELECT host, user FROM mysql.user; ++-----------+------------------+ +| host | user | ++-----------+------------------+ +| % | db_user | +| % | root | +| localhost | mysql.infoschema | +| localhost | mysql.session | +| localhost | mysql.sys | +| localhost | root | ++-----------+------------------+ +6 rows in set (0.00 sec) +``` + +```s +mysql> show databases; ++-----------------------+ +| Database | ++-----------------------+ +| information_schema | +| mysql | +| performance_schema | +| sys | +| torrust_index_backend | +| torrust_tracker | ++-----------------------+ +6 rows in set (0,00 sec) +``` + +If the database, user or permissions are not created the reason could be the MySQL container volume can be corrupted. Delete it and start again the containers. diff --git a/docker/bin/build.sh b/docker/bin/build.sh new file mode 100755 index 00000000..96766624 --- /dev/null +++ b/docker/bin/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} +TORRUST_IDX_BACK_RUN_AS_USER=${TORRUST_IDX_BACK_RUN_AS_USER:-appuser} + +echo "Building docker image ..." +echo "TORRUST_IDX_BACK_USER_UID: $TORRUST_IDX_BACK_USER_UID" +echo "TORRUST_IDX_BACK_RUN_AS_USER: $TORRUST_IDX_BACK_RUN_AS_USER" + +docker build \ + --build-arg UID="$TORRUST_IDX_BACK_USER_UID" \ + --build-arg RUN_AS_USER="$TORRUST_IDX_BACK_RUN_AS_USER" \ + -t torrust-index-backend . diff --git a/docker/bin/install.sh b/docker/bin/install.sh new file mode 100755 index 00000000..a5896937 --- /dev/null +++ b/docker/bin/install.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +./docker/bin/build.sh +./bin/install.sh diff --git a/docker/bin/run.sh b/docker/bin/run.sh new file mode 100755 index 00000000..92417f9a --- /dev/null +++ b/docker/bin/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} +TORRUST_IDX_BACK_CONFIG=$(cat config.toml) + +docker run -it \ + --user="$TORRUST_IDX_BACK_USER_UID" \ + --publish 3000:3000/tcp \ + --env TORRUST_IDX_BACK_CONFIG="$TORRUST_IDX_BACK_CONFIG" \ + --volume "$(pwd)/storage":"/app/storage" \ + torrust-index-backend From eedb0ceef9b71711446655264fb4f3700ca42ed2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 18 Apr 2023 17:23:06 +0100 Subject: [PATCH 076/357] tests: e2e tests scaffolding --- .cargo/config.toml | 7 ++++ .env.local | 1 + .github/workflows/develop.yml | 35 +++++++----------- .github/workflows/release.yml | 3 ++ .gitignore | 2 ++ Cargo.toml | 3 ++ bin/install.sh | 3 ++ compose.yaml | 27 ++++++++++++++ config-idx-back.toml.local | 4 +-- docker/README.md | 8 +++-- docker/bin/e2e-env-down.sh | 3 ++ docker/bin/e2e-env-up.sh | 10 ++++++ docker/bin/run-e2e-tests.sh | 57 +++++++++++++++++++++++++++++ tests/e2e/client.rs | 67 +++++++++++++++++++++++++++++++++++ tests/e2e/connection_info.rs | 16 +++++++++ tests/e2e/contexts/about.rs | 21 +++++++++++ tests/e2e/contexts/mod.rs | 1 + tests/e2e/http.rs | 54 ++++++++++++++++++++++++++++ tests/e2e/mod.rs | 9 +++++ tests/mod.rs | 1 + 20 files changed, 304 insertions(+), 28 deletions(-) create mode 100644 .cargo/config.toml create mode 100755 docker/bin/e2e-env-down.sh create mode 100755 docker/bin/e2e-env-up.sh create mode 100755 docker/bin/run-e2e-tests.sh create mode 100644 tests/e2e/client.rs create mode 100644 tests/e2e/connection_info.rs create mode 100644 tests/e2e/contexts/about.rs create mode 100644 tests/e2e/contexts/mod.rs create mode 100644 tests/e2e/http.rs create mode 100644 tests/e2e/mod.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..e67234cb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +[alias] +cov = "llvm-cov" +cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" +cov-html = "llvm-cov --html" +time = "build --timings --all-targets" +e2e = "test --features e2e-tests" + diff --git a/.env.local b/.env.local index ef58fd2e..90b3e4b3 100644 --- a/.env.local +++ b/.env.local @@ -3,3 +3,4 @@ TORRUST_IDX_BACK_CONFIG= TORRUST_IDX_BACK_USER_UID=1000 TORRUST_TRACKER_CONFIG= TORRUST_TRACKER_USER_UID=1000 +TORRUST_TRACKER_API_TOKEN=MyAccessToken diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 1b27a4e7..dca7e7e1 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -1,6 +1,6 @@ name: Development Checks -on: [push,pull_request] +on: [push, pull_request] jobs: run: @@ -14,27 +14,16 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - name: Format - uses: ClementTsang/cargo-action@main - with: - command: fmt - args: --all --check + run: cargo fmt --all --check - name: Check - uses: ClementTsang/cargo-action@main - with: - command: check - args: --all-targets + run: cargo check --all-targets - name: Clippy - uses: ClementTsang/cargo-action@main - with: - command: clippy - args: --all-targets - - name: Build - uses: ClementTsang/cargo-action@main - with: - command: build - args: --all-targets - - name: Test - uses: ClementTsang/cargo-action@main - with: - command: test - args: --all-targets \ No newline at end of file + run: cargo clippy --all-targets + - name: Unit and integration tests + run: cargo test --all-targets + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@nextest + - name: Test Coverage + run: cargo llvm-cov nextest + - name: E2E Tests + run: ./docker/bin/run-e2e-tests.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38d427f6..10c62fb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ jobs: - uses: Swatinem/rust-cache@v1 - name: Run tests run: cargo test + - name: Stop databases + working-directory: ./tests + run: docker-compose down tag: needs: test diff --git a/.gitignore b/.gitignore index 282b85e3..eb90c276 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.coverage/ /.env /config.toml /data_v2.db* @@ -5,3 +6,4 @@ /storage/ /target /uploads/ + diff --git a/Cargo.toml b/Cargo.toml index 1eb837e9..ab3fd7ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,9 @@ default-run = "main" [profile.dev.package.sqlx-macros] opt-level = 3 +[features] +e2e-tests = [] + [dependencies] actix-web = "4.0.0-beta.8" actix-multipart = "0.4.0-beta.5" diff --git a/bin/install.sh b/bin/install.sh index b8df7f7a..041863b2 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -5,6 +5,9 @@ if ! [ -f "./config.toml" ]; then cp ./config.toml.local ./config.toml fi +# Generate storage directory if it does not exist +mkdir -p "./storage/database" + # Generate the sqlite database for the index baclend if it does not exist if ! [ -f "./storage/database/data.db" ]; then # todo: it should get the path from config.toml and only do it when we use sqlite diff --git a/compose.yaml b/compose.yaml index 82658156..35447943 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,15 +4,30 @@ services: idx-back: build: context: . + args: + RUN_AS_USER: appuser + UID: ${TORRUST_IDX_BACK_USER_UID:-1000} target: development user: ${TORRUST_IDX_BACK_USER_UID:-1000}:${TORRUST_IDX_BACK_USER_UID:-1000} tty: true environment: - TORRUST_IDX_BACK_CONFIG=${TORRUST_IDX_BACK_CONFIG} + - CARGO_HOME=/home/appuser/.cargo networks: - server_side ports: - 3000:3000 + # todo: implement healthcheck + #healthcheck: + # test: + # [ + # "CMD-SHELL", + # "cargo run healthcheck" + # ] + # interval: 10s + # retries: 5 + # start_period: 10s + # timeout: 3s volumes: - ./:/app - ~/.cargo:/home/appuser/.cargo @@ -25,11 +40,23 @@ services: tty: true environment: - TORRUST_TRACKER_CONFIG=${TORRUST_TRACKER_CONFIG} + - TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} networks: - server_side ports: - 6969:6969/udp - 1212:1212/tcp + # todo: implement healthcheck + #healthcheck: + # test: + # [ + # "CMD-SHELL", + # "/app/main healthcheck" + # ] + # interval: 10s + # retries: 5 + # start_period: 10s + # timeout: 3s volumes: - ./storage:/app/storage depends_on: diff --git a/config-idx-back.toml.local b/config-idx-back.toml.local index 2257c16f..1051dcb9 100644 --- a/config-idx-back.toml.local +++ b/config-idx-back.toml.local @@ -18,8 +18,8 @@ max_password_length = 64 secret_key = "MaxVerstappenWC2021" [database] -#connect_url = "sqlite://storage/database/data.db?mode=rwc" # SQLite -connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL +connect_url = "sqlite://storage/database/data.db?mode=rwc" # SQLite +#connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL torrent_info_update_interval = 3600 [mail] diff --git a/docker/README.md b/docker/README.md index 02be6219..664e58b8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -63,9 +63,11 @@ If you want to inject an environment variable into docker-compose you can use th Build and run it locally: ```s -TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ -TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ - docker compose up --build +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ + TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ + TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + docker compose up -d --build ``` After running the "up" command you will have three running containers: diff --git a/docker/bin/e2e-env-down.sh b/docker/bin/e2e-env-down.sh new file mode 100755 index 00000000..5e50d101 --- /dev/null +++ b/docker/bin/e2e-env-down.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker compose down diff --git a/docker/bin/e2e-env-up.sh b/docker/bin/e2e-env-up.sh new file mode 100755 index 00000000..a5de770c --- /dev/null +++ b/docker/bin/e2e-env-up.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + docker compose build + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ + TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ + TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + docker compose up -d diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh new file mode 100755 index 00000000..5eb63c33 --- /dev/null +++ b/docker/bin/run-e2e-tests.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +CURRENT_USER_NAME=$(whoami) +CURRENT_USER_ID=$(id -u) +echo "User name: $CURRENT_USER_NAME" +echo "User id: $CURRENT_USER_ID" + +TORRUST_IDX_BACK_USER_UID=$CURRENT_USER_ID +TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID +export TORRUST_IDX_BACK_USER_UID +export TORRUST_TRACKER_USER_UID + +wait_for_container_to_be_healthy() { + local container_name="$1" + local max_retries="$2" + local retry_interval="$3" + local retry_count=0 + + while [ $retry_count -lt "$max_retries" ]; do + container_health="$(docker inspect --format='{{json .State.Health}}' "$container_name")" + if [ "$container_health" != "{}" ]; then + container_status="$(echo "$container_health" | jq -r '.Status')" + if [ "$container_status" == "healthy" ]; then + echo "Container $container_name is healthy" + return 0 + fi + fi + + retry_count=$((retry_count + 1)) + echo "Waiting for container $container_name to become healthy (attempt $retry_count of $max_retries)..." + sleep "$retry_interval" + done + + echo "Timeout reached, container $container_name is not healthy" + return 1 +} + +cp .env.local .env +./bin/install.sh + +# Start E2E testing environment +./docker/bin/e2e-env-up.sh + +wait_for_container_to_be_healthy torrust-mysql-1 10 3 +# todo: implement healthchecks for tracker and backend and wait until they are healthy +#wait_for_container torrust-tracker-1 10 3 +#wait_for_container torrust-idx-back-1 10 3 +sleep 20s + +# Just to make sure that everything is up and running +docker ps + +# Run E2E tests +cargo test --features e2e-tests + +# Stop E2E testing environment +./docker/bin/e2e-env-down.sh diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs new file mode 100644 index 00000000..3d249d66 --- /dev/null +++ b/tests/e2e/client.rs @@ -0,0 +1,67 @@ +use reqwest::Response; + +use crate::e2e::connection_info::ConnectionInfo; +use crate::e2e::http::{Query, ReqwestQuery}; + +/// API Client +pub struct Client { + connection_info: ConnectionInfo, + base_path: String, +} + +impl Client { + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { + connection_info, + base_path: "/".to_string(), + } + } + + pub async fn entrypoint(&self) -> Response { + self.get("", Query::default()).await + } + + pub async fn get(&self, path: &str, params: Query) -> Response { + self.get_request_with_query(path, params).await + } + + /* + pub async fn post(&self, path: &str) -> Response { + reqwest::Client::new().post(self.base_url(path).clone()).send().await.unwrap() + } + + async fn delete(&self, path: &str) -> Response { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .send() + .await + .unwrap() + } + + pub async fn get_request(&self, path: &str) -> Response { + get(&self.base_url(path), None).await + } + */ + + pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response { + get(&self.base_url(path), Some(params)).await + } + + fn base_url(&self, path: &str) -> String { + format!("http://{}{}{path}", &self.connection_info.bind_address, &self.base_path) + } +} + +async fn get(path: &str, query: Option) -> Response { + match query { + Some(params) => reqwest::Client::builder() + .build() + .unwrap() + .get(path) + .query(&ReqwestQuery::from(params)) + .send() + .await + .unwrap(), + None => reqwest::Client::builder().build().unwrap().get(path).send().await.unwrap(), + } +} diff --git a/tests/e2e/connection_info.rs b/tests/e2e/connection_info.rs new file mode 100644 index 00000000..f70dae6f --- /dev/null +++ b/tests/e2e/connection_info.rs @@ -0,0 +1,16 @@ +pub fn connection_with_no_token(bind_address: &str) -> ConnectionInfo { + ConnectionInfo::anonymous(bind_address) +} + +#[derive(Clone)] +pub struct ConnectionInfo { + pub bind_address: String, +} + +impl ConnectionInfo { + pub fn anonymous(bind_address: &str) -> Self { + Self { + bind_address: bind_address.to_string(), + } + } +} diff --git a/tests/e2e/contexts/about.rs b/tests/e2e/contexts/about.rs new file mode 100644 index 00000000..99bcb276 --- /dev/null +++ b/tests/e2e/contexts/about.rs @@ -0,0 +1,21 @@ +use crate::e2e::client::Client; +use crate::e2e::connection_info::connection_with_no_token; + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_load_the_about_page_at_the_api_entrypoint() { + let client = Client::new(connection_with_no_token("localhost:3000")); + + let response = client.entrypoint().await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "text/html; charset=utf-8"); + + let title = format!("About"); + let response_text = response.text().await.unwrap(); + + assert!( + response_text.contains(&title), + ":\n response: `\"{response_text}\"`\n does not contain: `\"{title}\"`." + ); +} diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs new file mode 100644 index 00000000..ced75210 --- /dev/null +++ b/tests/e2e/contexts/mod.rs @@ -0,0 +1 @@ +pub mod about; diff --git a/tests/e2e/http.rs b/tests/e2e/http.rs new file mode 100644 index 00000000..d682027f --- /dev/null +++ b/tests/e2e/http.rs @@ -0,0 +1,54 @@ +pub type ReqwestQuery = Vec; +pub type ReqwestQueryParam = (String, String); + +/// URL Query component +#[derive(Default, Debug)] +pub struct Query { + params: Vec, +} + +impl Query { + pub fn empty() -> Self { + Self { params: vec![] } + } + + pub fn params(params: Vec) -> Self { + Self { params } + } + + pub fn add_param(&mut self, param: QueryParam) { + self.params.push(param); + } +} + +impl From for ReqwestQuery { + fn from(url_search_params: Query) -> Self { + url_search_params + .params + .iter() + .map(|param| ReqwestQueryParam::from((*param).clone())) + .collect() + } +} + +/// URL query param +#[derive(Clone, Debug)] +pub struct QueryParam { + name: String, + value: String, +} + +impl QueryParam { + pub fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + +impl From for ReqwestQueryParam { + fn from(param: QueryParam) -> Self { + (param.name, param.value) + } +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs new file mode 100644 index 00000000..35de4dcf --- /dev/null +++ b/tests/e2e/mod.rs @@ -0,0 +1,9 @@ +//! End-to-end tests. +//! +//! ``` +//! cargo test -- --ignored +//! ``` +mod client; +mod connection_info; +mod contexts; +mod http; diff --git a/tests/mod.rs b/tests/mod.rs index 27bea3bd..f90fa4f2 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,2 +1,3 @@ mod databases; +mod e2e; pub mod upgrades; From 08f0aacd79b5d6996731e530681daf917d39cd3e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 14 Apr 2023 15:33:21 +0100 Subject: [PATCH 077/357] chore: add code owners for github workflows --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..b6221300 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +/.github/ @da2ce7 @josecelano @WarmBeer From 5678e4d4342de83e8d2cc16eaa05784134bef056 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 21 Apr 2023 12:51:15 +0100 Subject: [PATCH 078/357] tests: [#107] E2E tests for API entrypoint and about routes --- docker/README.md | 4 ++-- tests/e2e/asserts.rs | 15 +++++++++++++++ tests/e2e/client.rs | 12 +++++------- tests/e2e/contexts/about.rs | 21 --------------------- tests/e2e/mod.rs | 19 +++++++++++++++++-- tests/e2e/response.rs | 17 +++++++++++++++++ tests/e2e/routes/about.rs | 26 ++++++++++++++++++++++++++ tests/e2e/{contexts => routes}/mod.rs | 1 + tests/e2e/routes/root.rs | 15 +++++++++++++++ 9 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 tests/e2e/asserts.rs delete mode 100644 tests/e2e/contexts/about.rs create mode 100644 tests/e2e/response.rs create mode 100644 tests/e2e/routes/about.rs rename tests/e2e/{contexts => routes}/mod.rs (51%) create mode 100644 tests/e2e/routes/root.rs diff --git a/docker/README.md b/docker/README.md index 664e58b8..d38a8066 100644 --- a/docker/README.md +++ b/docker/README.md @@ -21,7 +21,7 @@ Build and run locally: ```s docker context use default -export TORRUST_IDX_BACK_USER_UID=1000 +export TORRUST_IDX_BACK_USER_UID=$(id -u) ./docker/bin/build.sh $TORRUST_IDX_BACK_USER_UID ./bin/install.sh ./docker/bin/run.sh $TORRUST_IDX_BACK_USER_UID @@ -30,7 +30,7 @@ export TORRUST_IDX_BACK_USER_UID=1000 Run using the pre-built public docker image: ```s -export TORRUST_IDX_BACK_USER_UID=1000 +export TORRUST_IDX_BACK_USER_UID=$(id -u) docker run -it \ --user="$TORRUST_IDX_BACK_USER_UID" \ --publish 3000:3000/tcp \ diff --git a/tests/e2e/asserts.rs b/tests/e2e/asserts.rs new file mode 100644 index 00000000..d57954ad --- /dev/null +++ b/tests/e2e/asserts.rs @@ -0,0 +1,15 @@ +use crate::e2e::response::Response; + +pub fn assert_response_title(response: &Response, title: &str) { + let title_element = format!("{title}"); + + assert!( + response.body.contains(&title), + ":\n response does not contain the title element: `\"{title_element}\"`." + ); +} + +pub fn assert_ok(response: &Response) { + assert_eq!(response.status, 200); + assert_eq!(response.content_type, "text/html; charset=utf-8"); +} diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs index 3d249d66..927734e0 100644 --- a/tests/e2e/client.rs +++ b/tests/e2e/client.rs @@ -1,7 +1,8 @@ -use reqwest::Response; +use reqwest::Response as ReqwestResponse; use crate::e2e::connection_info::ConnectionInfo; use crate::e2e::http::{Query, ReqwestQuery}; +use crate::e2e::response::Response; /// API Client pub struct Client { @@ -17,10 +18,6 @@ impl Client { } } - pub async fn entrypoint(&self) -> Response { - self.get("", Query::default()).await - } - pub async fn get(&self, path: &str, params: Query) -> Response { self.get_request_with_query(path, params).await } @@ -53,7 +50,7 @@ impl Client { } async fn get(path: &str, query: Option) -> Response { - match query { + let response: ReqwestResponse = match query { Some(params) => reqwest::Client::builder() .build() .unwrap() @@ -63,5 +60,6 @@ async fn get(path: &str, query: Option) -> Response { .await .unwrap(), None => reqwest::Client::builder().build().unwrap().get(path).send().await.unwrap(), - } + }; + Response::from(response).await } diff --git a/tests/e2e/contexts/about.rs b/tests/e2e/contexts/about.rs deleted file mode 100644 index 99bcb276..00000000 --- a/tests/e2e/contexts/about.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::e2e::client::Client; -use crate::e2e::connection_info::connection_with_no_token; - -#[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] -async fn it_should_load_the_about_page_at_the_api_entrypoint() { - let client = Client::new(connection_with_no_token("localhost:3000")); - - let response = client.entrypoint().await; - - assert_eq!(response.status(), 200); - assert_eq!(response.headers().get("content-type").unwrap(), "text/html; charset=utf-8"); - - let title = format!("About"); - let response_text = response.text().await.unwrap(); - - assert!( - response_text.contains(&title), - ":\n response: `\"{response_text}\"`\n does not contain: `\"{title}\"`." - ); -} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 35de4dcf..3d6415b6 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -1,9 +1,24 @@ //! End-to-end tests. //! +//! Execute E2E tests with: +//! +//! ``` +//! cargo test --features e2e-tests //! ``` -//! cargo test -- --ignored +//! +//! or the Cargo alias +//! //! ``` +//! cargo e2e +//! ``` +//! +//! > **NOTICE**: E2E tests are not executed by default, because they require +//! a running instance of the API. +//! +//! See the docker documentation for more information on how to run the API. +mod asserts; mod client; mod connection_info; -mod contexts; mod http; +mod response; +mod routes; diff --git a/tests/e2e/response.rs b/tests/e2e/response.rs new file mode 100644 index 00000000..6861953e --- /dev/null +++ b/tests/e2e/response.rs @@ -0,0 +1,17 @@ +use reqwest::Response as ReqwestResponse; + +pub struct Response { + pub status: u16, + pub content_type: String, + pub body: String, +} + +impl Response { + pub async fn from(response: ReqwestResponse) -> Self { + Self { + status: response.status().as_u16(), + content_type: response.headers().get("content-type").unwrap().to_str().unwrap().to_owned(), + body: response.text().await.unwrap(), + } + } +} diff --git a/tests/e2e/routes/about.rs b/tests/e2e/routes/about.rs new file mode 100644 index 00000000..51c1e050 --- /dev/null +++ b/tests/e2e/routes/about.rs @@ -0,0 +1,26 @@ +use crate::e2e::asserts::{assert_ok, assert_response_title}; +use crate::e2e::client::Client; +use crate::e2e::connection_info::connection_with_no_token; +use crate::e2e::http::Query; + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_load_the_about_page_with_information_about_the_api() { + let client = Client::new(connection_with_no_token("localhost:3000")); + + let response = client.get("about", Query::empty()).await; + + assert_ok(&response); + assert_response_title(&response, "About"); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_load_the_license_page_at_the_api_entrypoint() { + let client = Client::new(connection_with_no_token("localhost:3000")); + + let response = client.get("about/license", Query::empty()).await; + + assert_ok(&response); + assert_response_title(&response, "Licensing"); +} diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/routes/mod.rs similarity index 51% rename from tests/e2e/contexts/mod.rs rename to tests/e2e/routes/mod.rs index ced75210..395f4d3c 100644 --- a/tests/e2e/contexts/mod.rs +++ b/tests/e2e/routes/mod.rs @@ -1 +1,2 @@ pub mod about; +pub mod root; diff --git a/tests/e2e/routes/root.rs b/tests/e2e/routes/root.rs new file mode 100644 index 00000000..0a236006 --- /dev/null +++ b/tests/e2e/routes/root.rs @@ -0,0 +1,15 @@ +use crate::e2e::asserts::{assert_ok, assert_response_title}; +use crate::e2e::client::Client; +use crate::e2e::connection_info::connection_with_no_token; +use crate::e2e::http::Query; + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_load_the_about_page_at_the_api_entrypoint() { + let client = Client::new(connection_with_no_token("localhost:3000")); + + let response = client.get("", Query::empty()).await; + + assert_ok(&response); + assert_response_title(&response, "About"); +} From 652f50b41bee884349e7ebfd553af7db4a88123f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 21 Apr 2023 13:32:52 +0100 Subject: [PATCH 079/357] refactor: [#109] extract structs and functions --- tests/e2e/asserts.rs | 7 ++++++- tests/e2e/client.rs | 12 ++++++++++++ tests/e2e/connection_info.rs | 2 +- tests/e2e/env.rs | 20 ++++++++++++++++++++ tests/e2e/mod.rs | 1 + tests/e2e/routes/about.rs | 18 ++++++++---------- tests/e2e/routes/mod.rs | 1 + tests/e2e/routes/root.rs | 12 +++++------- 8 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/env.rs diff --git a/tests/e2e/asserts.rs b/tests/e2e/asserts.rs index d57954ad..6fb1a7eb 100644 --- a/tests/e2e/asserts.rs +++ b/tests/e2e/asserts.rs @@ -9,7 +9,12 @@ pub fn assert_response_title(response: &Response, title: &str) { ); } -pub fn assert_ok(response: &Response) { +pub fn assert_text_ok(response: &Response) { assert_eq!(response.status, 200); assert_eq!(response.content_type, "text/html; charset=utf-8"); } + +pub fn assert_json_ok(response: &Response) { + assert_eq!(response.status, 200); + assert_eq!(response.content_type, "application/json"); +} diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs index 927734e0..a5a81b48 100644 --- a/tests/e2e/client.rs +++ b/tests/e2e/client.rs @@ -18,6 +18,18 @@ impl Client { } } + pub async fn root(&self) -> Response { + self.get("", Query::empty()).await + } + + pub async fn about(&self) -> Response { + self.get("about", Query::empty()).await + } + + pub async fn license(&self) -> Response { + self.get("about/license", Query::empty()).await + } + pub async fn get(&self, path: &str, params: Query) -> Response { self.get_request_with_query(path, params).await } diff --git a/tests/e2e/connection_info.rs b/tests/e2e/connection_info.rs index f70dae6f..db019f54 100644 --- a/tests/e2e/connection_info.rs +++ b/tests/e2e/connection_info.rs @@ -1,4 +1,4 @@ -pub fn connection_with_no_token(bind_address: &str) -> ConnectionInfo { +pub fn anonymous_connection(bind_address: &str) -> ConnectionInfo { ConnectionInfo::anonymous(bind_address) } diff --git a/tests/e2e/env.rs b/tests/e2e/env.rs new file mode 100644 index 00000000..c4f09ee1 --- /dev/null +++ b/tests/e2e/env.rs @@ -0,0 +1,20 @@ +use crate::e2e::client::Client; +use crate::e2e::connection_info::anonymous_connection; + +pub struct TestEnv { + pub authority: String, +} + +impl TestEnv { + pub fn guess_client(&self) -> Client { + Client::new(anonymous_connection(&self.authority)) + } +} + +impl Default for TestEnv { + fn default() -> Self { + Self { + authority: "localhost:3000".to_string(), + } + } +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 3d6415b6..80151694 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -19,6 +19,7 @@ mod asserts; mod client; mod connection_info; +pub mod env; mod http; mod response; mod routes; diff --git a/tests/e2e/routes/about.rs b/tests/e2e/routes/about.rs index 51c1e050..f403ec6c 100644 --- a/tests/e2e/routes/about.rs +++ b/tests/e2e/routes/about.rs @@ -1,26 +1,24 @@ -use crate::e2e::asserts::{assert_ok, assert_response_title}; -use crate::e2e::client::Client; -use crate::e2e::connection_info::connection_with_no_token; -use crate::e2e::http::Query; +use crate::e2e::asserts::{assert_response_title, assert_text_ok}; +use crate::e2e::env::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_with_information_about_the_api() { - let client = Client::new(connection_with_no_token("localhost:3000")); + let client = TestEnv::default().guess_client(); - let response = client.get("about", Query::empty()).await; + let response = client.about().await; - assert_ok(&response); + assert_text_ok(&response); assert_response_title(&response, "About"); } #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let client = Client::new(connection_with_no_token("localhost:3000")); + let client = TestEnv::default().guess_client(); - let response = client.get("about/license", Query::empty()).await; + let response = client.license().await; - assert_ok(&response); + assert_text_ok(&response); assert_response_title(&response, "Licensing"); } diff --git a/tests/e2e/routes/mod.rs b/tests/e2e/routes/mod.rs index 395f4d3c..2c19d98e 100644 --- a/tests/e2e/routes/mod.rs +++ b/tests/e2e/routes/mod.rs @@ -1,2 +1,3 @@ pub mod about; + pub mod root; diff --git a/tests/e2e/routes/root.rs b/tests/e2e/routes/root.rs index 0a236006..b5b7df32 100644 --- a/tests/e2e/routes/root.rs +++ b/tests/e2e/routes/root.rs @@ -1,15 +1,13 @@ -use crate::e2e::asserts::{assert_ok, assert_response_title}; -use crate::e2e::client::Client; -use crate::e2e::connection_info::connection_with_no_token; -use crate::e2e::http::Query; +use crate::e2e::asserts::{assert_response_title, assert_text_ok}; +use crate::e2e::env::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_at_the_api_entrypoint() { - let client = Client::new(connection_with_no_token("localhost:3000")); + let client = TestEnv::default().guess_client(); - let response = client.get("", Query::empty()).await; + let response = client.root().await; - assert_ok(&response); + assert_text_ok(&response); assert_response_title(&response, "About"); } From 73a26ae5e520a1b47a146446f5e385e240408f9a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 21 Apr 2023 14:15:50 +0100 Subject: [PATCH 080/357] tests: [#109] E2E test for category routes --- tests/e2e/asserts.rs | 11 ++++++++++- tests/e2e/client.rs | 3 ++- tests/e2e/env.rs | 2 +- tests/e2e/routes/about.rs | 4 ++-- tests/e2e/routes/category.rs | 21 +++++++++++++++++++++ tests/e2e/routes/mod.rs | 2 +- tests/e2e/routes/root.rs | 2 +- 7 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/routes/category.rs diff --git a/tests/e2e/asserts.rs b/tests/e2e/asserts.rs index 6fb1a7eb..54778db7 100644 --- a/tests/e2e/asserts.rs +++ b/tests/e2e/asserts.rs @@ -1,10 +1,12 @@ use crate::e2e::response::Response; +// Text responses + pub fn assert_response_title(response: &Response, title: &str) { let title_element = format!("{title}"); assert!( - response.body.contains(&title), + response.body.contains(title), ":\n response does not contain the title element: `\"{title_element}\"`." ); } @@ -14,6 +16,13 @@ pub fn assert_text_ok(response: &Response) { assert_eq!(response.content_type, "text/html; charset=utf-8"); } +pub fn _assert_text_bad_request(response: &Response) { + assert_eq!(response.status, 400); + assert_eq!(response.content_type, "text/plain; charset=utf-8"); +} + +// JSON responses + pub fn assert_json_ok(response: &Response) { assert_eq!(response.status, 200); assert_eq!(response.content_type, "application/json"); diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs index a5a81b48..45ead7ea 100644 --- a/tests/e2e/client.rs +++ b/tests/e2e/client.rs @@ -36,7 +36,8 @@ impl Client { /* pub async fn post(&self, path: &str) -> Response { - reqwest::Client::new().post(self.base_url(path).clone()).send().await.unwrap() + let response = reqwest::Client::new().post(self.base_url(path).clone()).send().await.unwrap(); + Response::from(response).await } async fn delete(&self, path: &str) -> Response { diff --git a/tests/e2e/env.rs b/tests/e2e/env.rs index c4f09ee1..cfa2b347 100644 --- a/tests/e2e/env.rs +++ b/tests/e2e/env.rs @@ -6,7 +6,7 @@ pub struct TestEnv { } impl TestEnv { - pub fn guess_client(&self) -> Client { + pub fn unauthenticated_client(&self) -> Client { Client::new(anonymous_connection(&self.authority)) } } diff --git a/tests/e2e/routes/about.rs b/tests/e2e/routes/about.rs index f403ec6c..2274d078 100644 --- a/tests/e2e/routes/about.rs +++ b/tests/e2e/routes/about.rs @@ -4,7 +4,7 @@ use crate::e2e::env::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_with_information_about_the_api() { - let client = TestEnv::default().guess_client(); + let client = TestEnv::default().unauthenticated_client(); let response = client.about().await; @@ -15,7 +15,7 @@ async fn it_should_load_the_about_page_with_information_about_the_api() { #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let client = TestEnv::default().guess_client(); + let client = TestEnv::default().unauthenticated_client(); let response = client.license().await; diff --git a/tests/e2e/routes/category.rs b/tests/e2e/routes/category.rs new file mode 100644 index 00000000..e4254c1f --- /dev/null +++ b/tests/e2e/routes/category.rs @@ -0,0 +1,21 @@ +use crate::e2e::asserts::assert_json_ok; +use crate::e2e::env::TestEnv; +use crate::e2e::http::Query; + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get("category", Query::empty()).await; + + assert_json_ok(&response); +} + +/* todo: + - it_should_not_allow_adding_a_new_category_to_unauthenticated_clients + - it should allow adding a new category to authenticated clients + - it should not allow adding a new category with an empty name + - it should not allow adding a new category with an empty icon + - ... +*/ diff --git a/tests/e2e/routes/mod.rs b/tests/e2e/routes/mod.rs index 2c19d98e..73340f4e 100644 --- a/tests/e2e/routes/mod.rs +++ b/tests/e2e/routes/mod.rs @@ -1,3 +1,3 @@ pub mod about; - +pub mod category; pub mod root; diff --git a/tests/e2e/routes/root.rs b/tests/e2e/routes/root.rs index b5b7df32..fa496479 100644 --- a/tests/e2e/routes/root.rs +++ b/tests/e2e/routes/root.rs @@ -4,7 +4,7 @@ use crate::e2e::env::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_at_the_api_entrypoint() { - let client = TestEnv::default().guess_client(); + let client = TestEnv::default().unauthenticated_client(); let response = client.root().await; From f25769220b059efaeddd2fd2cf82cb200b8da67a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 24 Apr 2023 17:29:06 +0100 Subject: [PATCH 081/357] feat: [#111] add cargo dependency rand To generate random user IDs for testing. --- Cargo.lock | 1 + Cargo.toml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 4fe190ba..f4d8310b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2684,6 +2684,7 @@ dependencies = [ "lettre", "log", "pbkdf2", + "rand", "rand_core", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index ab3fd7ff..b57b4d26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,3 +41,6 @@ pbkdf2 = "0.11.0" text-colorizer = "1.0.0" log = "0.4.17" fern = "0.6.2" + +[dev-dependencies] +rand = "0.8.5" From 2b589237dcbaba982b7dc701f08644c25c2037d5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 24 Apr 2023 17:31:02 +0100 Subject: [PATCH 082/357] tests: [#111] E2E test for user routes --- compose.yaml | 10 + config-idx-back.toml.local | 6 +- config-tracker.toml.local | 2 +- project-words.txt | 1 + src/mailer.rs | 22 +- src/routes/user.rs | 8 + tests/e2e/asserts.rs | 12 +- tests/e2e/client.rs | 99 +++++-- tests/e2e/connection_info.rs | 13 + tests/e2e/{routes => contexts}/about.rs | 2 +- tests/e2e/{routes => contexts}/category.rs | 5 +- tests/e2e/{routes => contexts}/mod.rs | 1 + tests/e2e/{routes => contexts}/root.rs | 2 +- tests/e2e/contexts/user.rs | 314 +++++++++++++++++++++ tests/e2e/{env.rs => environment.rs} | 6 +- tests/e2e/http.rs | 2 +- tests/e2e/mod.rs | 18 +- tests/e2e/response.rs | 7 +- 18 files changed, 482 insertions(+), 48 deletions(-) rename tests/e2e/{routes => contexts}/about.rs (94%) rename tests/e2e/{routes => contexts}/category.rs (82%) rename tests/e2e/{routes => contexts}/mod.rs (77%) rename tests/e2e/{routes => contexts}/root.rs (90%) create mode 100644 tests/e2e/contexts/user.rs rename tests/e2e/{env.rs => environment.rs} (62%) diff --git a/compose.yaml b/compose.yaml index 35447943..8c672f00 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,8 @@ services: - ~/.cargo:/home/appuser/.cargo depends_on: - tracker + - mailcatcher + - mysql tracker: image: torrust/tracker:develop @@ -62,6 +64,14 @@ services: depends_on: - mysql + mailcatcher: + image: dockage/mailcatcher:0.8.2 + networks: + - server_side + ports: + - 1080:1080 + - 1025:1025 + mysql: image: mysql:8.0 command: '--default-authentication-plugin=mysql_native_password' diff --git a/config-idx-back.toml.local b/config-idx-back.toml.local index 1051dcb9..5d1ff7e8 100644 --- a/config-idx-back.toml.local +++ b/config-idx-back.toml.local @@ -18,7 +18,7 @@ max_password_length = 64 secret_key = "MaxVerstappenWC2021" [database] -connect_url = "sqlite://storage/database/data.db?mode=rwc" # SQLite +connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc" # SQLite #connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL torrent_info_update_interval = 3600 @@ -28,5 +28,5 @@ from = "example@email.com" reply_to = "noreply@email.com" username = "" password = "" -server = "" -port = 25 +server = "mailcatcher" +port = 1025 diff --git a/config-tracker.toml.local b/config-tracker.toml.local index 82ceb285..9db1b578 100644 --- a/config-tracker.toml.local +++ b/config-tracker.toml.local @@ -1,7 +1,7 @@ log_level = "info" mode = "public" db_driver = "Sqlite3" -db_path = "./storage/database/tracker.db" +db_path = "./storage/database/torrust_tracker_e2e_testing.db" announce_interval = 120 min_announce_interval = 120 max_peer_timeout = 900 diff --git a/project-words.txt b/project-words.txt index e6f61e3a..e2e24938 100644 --- a/project-words.txt +++ b/project-words.txt @@ -23,6 +23,7 @@ Leechers LEECHERS lettre luckythelab +mailcatcher nanos NCCA nilm diff --git a/src/mailer.rs b/src/mailer.rs index a8fd4de8..bd8f0383 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -40,13 +40,21 @@ impl MailerService { async fn get_mailer(cfg: &Configuration) -> Mailer { let settings = cfg.settings.read().await; - let creds = Credentials::new(settings.mail.username.to_owned(), settings.mail.password.to_owned()); - - AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) - .port(settings.mail.port) - .credentials(creds) - .authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain]) - .build() + if !settings.mail.username.is_empty() && !settings.mail.password.is_empty() { + // SMTP authentication + let creds = Credentials::new(settings.mail.username.clone(), settings.mail.password.clone()); + + AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) + .port(settings.mail.port) + .credentials(creds) + .authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain]) + .build() + } else { + // SMTP without authentication + AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) + .port(settings.mail.port) + .build() + } } pub async fn send_verification_mail( diff --git a/src/routes/user.rs b/src/routes/user.rs index df9a385a..8de0f576 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -2,6 +2,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use log::{debug, info}; use pbkdf2::Pbkdf2; use rand_core::OsRng; use serde::{Deserialize, Serialize}; @@ -20,6 +21,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { web::scope("/user") .service(web::resource("/register").route(web::post().to(register))) .service(web::resource("/login").route(web::post().to(login))) + // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. .service(web::resource("/ban/{user}").route(web::delete().to(ban_user))) .service(web::resource("/token/verify").route(web::post().to(verify_token))) .service(web::resource("/token/renew").route(web::post().to(renew_token))) @@ -47,6 +49,8 @@ pub struct Token { } pub async fn register(req: HttpRequest, mut payload: web::Json, app_data: WebAppData) -> ServiceResult { + info!("registering user: {}", payload.username); + let settings = app_data.cfg.settings.read().await; match settings.auth.email_on_signup { @@ -253,6 +257,8 @@ pub async fn verify_email(req: HttpRequest, app_data: WebAppData) -> String { // TODO: add reason and date_expiry parameters to request pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult { + debug!("banning user"); + let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator @@ -262,6 +268,8 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult Self { Self { - connection_info, - base_path: "/".to_string(), + http_client: Http::new(connection_info), } } - pub async fn root(&self) -> Response { - self.get("", Query::empty()).await - } + // Context: about pub async fn about(&self) -> Response { - self.get("about", Query::empty()).await + self.http_client.get("about", Query::empty()).await } pub async fn license(&self) -> Response { - self.get("about/license", Query::empty()).await + self.http_client.get("about/license", Query::empty()).await } - pub async fn get(&self, path: &str, params: Query) -> Response { - self.get_request_with_query(path, params).await + // Context: category + + pub async fn get_categories(&self) -> Response { + self.http_client.get("category", Query::empty()).await } - /* - pub async fn post(&self, path: &str) -> Response { - let response = reqwest::Client::new().post(self.base_url(path).clone()).send().await.unwrap(); - Response::from(response).await + // Context: root + + pub async fn root(&self) -> Response { + self.http_client.get("", Query::empty()).await } - async fn delete(&self, path: &str) -> Response { - reqwest::Client::new() - .delete(self.base_url(path).clone()) + // Context: user + + pub async fn register_user(&self, registration_form: RegistrationForm) -> Response { + self.http_client.post("user/register", ®istration_form).await + } + + pub async fn login_user(&self, registration_form: LoginForm) -> Response { + self.http_client.post("user/login", ®istration_form).await + } + + pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Response { + self.http_client.post("user/token/verify", &token_verification_form).await + } + + pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Response { + self.http_client.post("user/token/renew", &token_verification_form).await + } + + pub async fn ban_user(&self, username: Username) -> Response { + self.http_client.delete(&format!("user/ban/{}", &username.value)).await + } +} + +/// Generic HTTP Client +struct Http { + connection_info: ConnectionInfo, + base_path: String, +} + +impl Http { + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { + connection_info, + base_path: "/".to_string(), + } + } + + pub async fn get(&self, path: &str, params: Query) -> Response { + self.get_request_with_query(path, params).await + } + + pub async fn post(&self, path: &str, form: &T) -> Response { + let response = reqwest::Client::new() + .post(self.base_url(path).clone()) + .json(&form) .send() .await - .unwrap() + .unwrap(); + Response::from(response).await } - pub async fn get_request(&self, path: &str) -> Response { - get(&self.base_url(path), None).await + async fn delete(&self, path: &str) -> Response { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::new() + .delete(self.base_url(path).clone()) + .bearer_auth(token) + .send() + .await + .unwrap(), + None => reqwest::Client::new() + .delete(self.base_url(path).clone()) + .send() + .await + .unwrap(), + }; + Response::from(response).await } - */ pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response { get(&self.base_url(path), Some(params)).await diff --git a/tests/e2e/connection_info.rs b/tests/e2e/connection_info.rs index db019f54..e6c96cf9 100644 --- a/tests/e2e/connection_info.rs +++ b/tests/e2e/connection_info.rs @@ -2,15 +2,28 @@ pub fn anonymous_connection(bind_address: &str) -> ConnectionInfo { ConnectionInfo::anonymous(bind_address) } +pub fn authenticated_connection(bind_address: &str, token: &str) -> ConnectionInfo { + ConnectionInfo::new(bind_address, token) +} + #[derive(Clone)] pub struct ConnectionInfo { pub bind_address: String, + pub token: Option, } impl ConnectionInfo { + pub fn new(bind_address: &str, token: &str) -> Self { + Self { + bind_address: bind_address.to_string(), + token: Some(token.to_string()), + } + } + pub fn anonymous(bind_address: &str) -> Self { Self { bind_address: bind_address.to_string(), + token: None, } } } diff --git a/tests/e2e/routes/about.rs b/tests/e2e/contexts/about.rs similarity index 94% rename from tests/e2e/routes/about.rs rename to tests/e2e/contexts/about.rs index 2274d078..49bf6c41 100644 --- a/tests/e2e/routes/about.rs +++ b/tests/e2e/contexts/about.rs @@ -1,5 +1,5 @@ use crate::e2e::asserts::{assert_response_title, assert_text_ok}; -use crate::e2e::env::TestEnv; +use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/routes/category.rs b/tests/e2e/contexts/category.rs similarity index 82% rename from tests/e2e/routes/category.rs rename to tests/e2e/contexts/category.rs index e4254c1f..61c2de7a 100644 --- a/tests/e2e/routes/category.rs +++ b/tests/e2e/contexts/category.rs @@ -1,13 +1,12 @@ use crate::e2e::asserts::assert_json_ok; -use crate::e2e::env::TestEnv; -use crate::e2e::http::Query; +use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { let client = TestEnv::default().unauthenticated_client(); - let response = client.get("category", Query::empty()).await; + let response = client.get_categories().await; assert_json_ok(&response); } diff --git a/tests/e2e/routes/mod.rs b/tests/e2e/contexts/mod.rs similarity index 77% rename from tests/e2e/routes/mod.rs rename to tests/e2e/contexts/mod.rs index 73340f4e..e96cfc48 100644 --- a/tests/e2e/routes/mod.rs +++ b/tests/e2e/contexts/mod.rs @@ -1,3 +1,4 @@ pub mod about; pub mod category; pub mod root; +pub mod user; diff --git a/tests/e2e/routes/root.rs b/tests/e2e/contexts/root.rs similarity index 90% rename from tests/e2e/routes/root.rs rename to tests/e2e/contexts/root.rs index fa496479..d7ea0e03 100644 --- a/tests/e2e/routes/root.rs +++ b/tests/e2e/contexts/root.rs @@ -1,5 +1,5 @@ use crate::e2e::asserts::{assert_response_title, assert_text_ok}; -use crate::e2e::env::TestEnv; +use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/contexts/user.rs b/tests/e2e/contexts/user.rs new file mode 100644 index 00000000..7fe182e4 --- /dev/null +++ b/tests/e2e/contexts/user.rs @@ -0,0 +1,314 @@ +use serde::{Deserialize, Serialize}; + +use crate::e2e::contexts::user::fixtures::{logged_in_user, random_user_registration, registered_user}; +use crate::e2e::environment::TestEnv; + +/* + +This test suite is not complete. It's just a starting point to show how to +write E2E tests. ANyway, the goal is not to fully cover all the app features +with E2E tests. The goal is to cover the most important features and to +demonstrate how to write E2E tests. Some important pending tests could be: + +todo: + +- It should allow renewing a token one week before it expires. +- It should allow verifying user registration via email. + +The first one requires to mock the time. Consider extracting the mod + into +an independent crate. + +The second one requires: +- To call the mailcatcher API to get the verification URL. +- To enable email verification in the configuration. +- To fix current tests to verify the email for newly created users. +- To find out which email is the one that contains the verification URL for a +given test. That maybe done using the email recipient if that's possible with +the mailcatcher API. + +*/ + +// Request data + +#[derive(Clone, Serialize)] +pub struct RegistrationForm { + pub username: String, + pub email: Option, + pub password: String, + pub confirm_password: String, +} + +type RegisteredUser = RegistrationForm; + +#[derive(Serialize)] +pub struct LoginForm { + pub login: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct TokenVerificationForm { + pub token: String, +} + +#[derive(Serialize)] +pub struct TokenRenewalForm { + pub token: String, +} + +pub struct Username { + pub value: String, +} + +impl Username { + pub fn new(value: String) -> Self { + Self { value } + } +} + +// Responses data + +#[derive(Deserialize)] +pub struct SuccessfulLoginResponse { + pub data: LoggedInUserData, +} + +#[derive(Deserialize, Debug)] +pub struct LoggedInUserData { + pub token: String, + pub username: String, + pub admin: bool, +} + +#[derive(Deserialize)] +pub struct TokenVerifiedResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct BannedUserResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct TokenRenewalResponse { + pub data: TokenRenewalData, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TokenRenewalData { + pub token: String, + pub username: String, + pub admin: bool, +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_a_guess_user_to_register() { + let client = TestEnv::default().unauthenticated_client(); + + let form = random_user_registration(); + + let response = client.register_user(form).await; + + assert_eq!(response.body, "", "wrong response body, it should be an empty string"); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "text/plain; charset=utf-8"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_a_registered_user_to_login() { + let client = TestEnv::default().unauthenticated_client(); + + let registered_user = registered_user().await; + + let response = client + .login_user(LoginForm { + login: registered_user.username.clone(), + password: registered_user.password.clone(), + }) + .await; + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + let logged_in_user = res.data; + + assert_eq!(logged_in_user.username, registered_user.username); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { + let client = TestEnv::default().unauthenticated_client(); + + let logged_in_user = logged_in_user().await; + + let response = client + .verify_token(TokenVerificationForm { + token: logged_in_user.token.clone(), + }) + .await; + + let res: TokenVerifiedResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, "Token is valid."); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week() { + let logged_in_user = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&logged_in_user.token); + + let response = client + .renew_token(TokenRenewalForm { + token: logged_in_user.token.clone(), + }) + .await; + + println!("Response body: {}", response.body); + + let res: TokenRenewalResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!( + res.data, + TokenRenewalData { + token: logged_in_user.token.clone(), // The same token is returned + username: logged_in_user.username.clone(), + admin: logged_in_user.admin, + } + ); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +mod banned_user_list { + use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user, registered_user}; + use crate::e2e::contexts::user::{BannedUserResponse, Username}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_an_admin_to_ban_a_user() { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + let registered_user = registered_user().await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + let res: BannedUserResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, format!("Banned user: {}", registered_user.username)); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_a_non_admin_to_ban_a_user() { + let logged_non_admin = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&logged_non_admin.token); + + let registered_user = registered_user().await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_guess_to_ban_a_user() { + let client = TestEnv::default().unauthenticated_client(); + + let registered_user = registered_user().await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 401); + } +} + +pub mod fixtures { + use std::sync::Arc; + + use rand::Rng; + use torrust_index_backend::databases::database::connect_database; + + use super::{LoggedInUserData, LoginForm, RegisteredUser, RegistrationForm, SuccessfulLoginResponse}; + use crate::e2e::environment::TestEnv; + + pub async fn logged_in_admin() -> LoggedInUserData { + let user = logged_in_user().await; + + // todo: get from E2E config file `config-idx-back.toml.local` + let connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc"; + + let database = Arc::new(connect_database(connect_url).await.expect("Database error.")); + + let user_profile = database.get_user_profile_from_username(&user.username).await.unwrap(); + + database.grant_admin_role(user_profile.user_id).await.unwrap(); + + user + } + + pub async fn logged_in_user() -> LoggedInUserData { + let client = TestEnv::default().unauthenticated_client(); + + let registered_user = registered_user().await; + + let response = client + .login_user(LoginForm { + login: registered_user.username.clone(), + password: registered_user.password.clone(), + }) + .await; + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + res.data + } + + pub async fn registered_user() -> RegisteredUser { + let client = TestEnv::default().unauthenticated_client(); + + let form = random_user_registration(); + + let registered_user = form.clone(); + + let _response = client.register_user(form).await; + + registered_user + } + + pub fn random_user_registration() -> RegistrationForm { + let user_id = random_user_id(); + RegistrationForm { + username: format!("username_{user_id}"), + email: Some(format!("email_{user_id}@email.com")), + password: "password".to_string(), + confirm_password: "password".to_string(), + } + } + + fn random_user_id() -> u64 { + let mut rng = rand::thread_rng(); + rng.gen_range(0..1_000_000) + } +} diff --git a/tests/e2e/env.rs b/tests/e2e/environment.rs similarity index 62% rename from tests/e2e/env.rs rename to tests/e2e/environment.rs index cfa2b347..f69e2024 100644 --- a/tests/e2e/env.rs +++ b/tests/e2e/environment.rs @@ -1,5 +1,5 @@ +use super::connection_info::{anonymous_connection, authenticated_connection}; use crate::e2e::client::Client; -use crate::e2e::connection_info::anonymous_connection; pub struct TestEnv { pub authority: String, @@ -9,6 +9,10 @@ impl TestEnv { pub fn unauthenticated_client(&self) -> Client { Client::new(anonymous_connection(&self.authority)) } + + pub fn authenticated_client(&self, token: &str) -> Client { + Client::new(authenticated_connection(&self.authority, token)) + } } impl Default for TestEnv { diff --git a/tests/e2e/http.rs b/tests/e2e/http.rs index d682027f..7bfb64ef 100644 --- a/tests/e2e/http.rs +++ b/tests/e2e/http.rs @@ -12,7 +12,7 @@ impl Query { Self { params: vec![] } } - pub fn params(params: Vec) -> Self { + pub fn with_params(params: Vec) -> Self { Self { params } } diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 80151694..1e2c1b02 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -6,7 +6,7 @@ //! cargo test --features e2e-tests //! ``` //! -//! or the Cargo alias +//! or the Cargo alias: //! //! ``` //! cargo e2e @@ -15,11 +15,23 @@ //! > **NOTICE**: E2E tests are not executed by default, because they require //! a running instance of the API. //! +//! You can also run only one test with: +//! +//! ``` +//! cargo test --features e2e-tests TEST_NAME -- --nocapture +//! cargo test --features e2e-tests it_should_register_a_new_user -- --nocapture +//! ``` +//! +//! > **NOTICE**: E2E tests always use the same databases +//! `storage/database/torrust_index_backend_e2e_testing.db` and +//! `./storage/database/torrust_tracker_e2e_testing.db`. If you want to use a +//! clean database, delete the files before running the tests. +//! //! See the docker documentation for more information on how to run the API. mod asserts; mod client; mod connection_info; -pub mod env; +mod contexts; +mod environment; mod http; mod response; -mod routes; diff --git a/tests/e2e/response.rs b/tests/e2e/response.rs index 6861953e..df04680f 100644 --- a/tests/e2e/response.rs +++ b/tests/e2e/response.rs @@ -2,7 +2,7 @@ use reqwest::Response as ReqwestResponse; pub struct Response { pub status: u16, - pub content_type: String, + pub content_type: Option, pub body: String, } @@ -10,7 +10,10 @@ impl Response { pub async fn from(response: ReqwestResponse) -> Self { Self { status: response.status().as_u16(), - content_type: response.headers().get("content-type").unwrap().to_str().unwrap().to_owned(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), body: response.text().await.unwrap(), } } From 2b6c04fdf9026f50f50506aaed8da956041952da Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 25 Apr 2023 14:54:50 +0100 Subject: [PATCH 083/357] test: [#109] add more E2E tests for categories --- src/routes/category.rs | 4 + tests/e2e/client.rs | 49 ++++++- tests/e2e/contexts/category.rs | 226 ++++++++++++++++++++++++++++++++- tests/e2e/response.rs | 1 + 4 files changed, 272 insertions(+), 8 deletions(-) diff --git a/src/routes/category.rs b/src/routes/category.rs index defca8a8..823c267e 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -49,6 +49,10 @@ pub async fn delete_category( payload: web::Json, app_data: WebAppData, ) -> ServiceResult { + // code-review: why do we need to send the whole category object to delete it? + // And we should use the ID instead of the name, because the name could change + // or we could add support for multiple languages. + // check for user let user = app_data.auth.get_user_compact_from_request(&req).await?; diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs index 344177e6..e079308b 100644 --- a/tests/e2e/client.rs +++ b/tests/e2e/client.rs @@ -1,6 +1,7 @@ use reqwest::Response as ReqwestResponse; use serde::Serialize; +use super::contexts::category::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::user::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; use crate::e2e::connection_info::ConnectionInfo; use crate::e2e::http::{Query, ReqwestQuery}; @@ -34,6 +35,14 @@ impl Client { self.http_client.get("category", Query::empty()).await } + pub async fn add_category(&self, add_category_form: AddCategoryForm) -> Response { + self.http_client.post("category", &add_category_form).await + } + + pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> Response { + self.http_client.delete_with_body("category", &delete_category_form).await + } + // Context: root pub async fn root(&self) -> Response { @@ -82,12 +91,21 @@ impl Http { } pub async fn post(&self, path: &str, form: &T) -> Response { - let response = reqwest::Client::new() - .post(self.base_url(path).clone()) - .json(&form) - .send() - .await - .unwrap(); + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::new() + .post(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await + .unwrap(), + None => reqwest::Client::new() + .post(self.base_url(path).clone()) + .json(&form) + .send() + .await + .unwrap(), + }; Response::from(response).await } @@ -108,6 +126,25 @@ impl Http { Response::from(response).await } + async fn delete_with_body(&self, path: &str, form: &T) -> Response { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::new() + .delete(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await + .unwrap(), + None => reqwest::Client::new() + .delete(self.base_url(path).clone()) + .json(&form) + .send() + .await + .unwrap(), + }; + Response::from(response).await + } + pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response { get(&self.base_url(path), Some(params)).await } diff --git a/tests/e2e/contexts/category.rs b/tests/e2e/contexts/category.rs index 61c2de7a..417b2516 100644 --- a/tests/e2e/contexts/category.rs +++ b/tests/e2e/contexts/category.rs @@ -1,6 +1,39 @@ +use serde::{Deserialize, Serialize}; + use crate::e2e::asserts::assert_json_ok; +use crate::e2e::contexts::category::fixtures::{add_category, random_category_name}; +use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user}; use crate::e2e::environment::TestEnv; +// Request data + +#[derive(Serialize)] +pub struct AddCategoryForm { + pub name: String, + pub icon: Option, +} + +pub type DeleteCategoryForm = AddCategoryForm; + +// Response data + +#[derive(Deserialize)] +pub struct AddedCategoryResponse { + pub data: String, +} + +#[derive(Deserialize, Debug)] +pub struct ListResponse { + pub data: Vec, +} + +#[derive(Deserialize, Debug, PartialEq)] +pub struct ListItem { + pub category_id: i64, + pub name: String, + pub num_torrents: i64, +} + #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { @@ -11,10 +44,199 @@ async fn it_should_return_an_empty_category_list_when_there_are_no_categories() assert_json_ok(&response); } +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_return_a_category_list() { + // Add a category + let category_name = random_category_name(); + let response = add_category(&category_name).await; + assert_eq!(response.status, 200); + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get_categories().await; + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + // There should be at least the category we added. + // Since this is an E2E test, there might be more categories. + assert!(res.data.len() > 1); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { + let client = TestEnv::default().unauthenticated_client(); + + let response = client + .add_category(AddCategoryForm { + name: "CATEGORY NAME".to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 401); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_not_allow_adding_a_new_category_to_non_admins() { + let logged_non_admin = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&logged_non_admin.token); + + let response = client + .add_category(AddCategoryForm { + name: "CATEGORY NAME".to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_admins_to_add_new_categories() { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + let category_name = random_category_name(); + + let response = client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, category_name); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_not_allow_adding_duplicated_categories() { + // Add a category + let random_category_name = random_category_name(); + let response = add_category(&random_category_name).await; + assert_eq!(response.status, 200); + + // Try to add the same category again + let response = add_category(&random_category_name).await; + assert_eq!(response.status, 400); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_admins_to_delete_categories() { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + // Add a category + let category_name = random_category_name(); + let response = add_category(&category_name).await; + assert_eq!(response.status, 200); + + let response = client + .delete_category(DeleteCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, category_name); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_not_allow_non_admins_to_delete_categories() { + // Add a category + let category_name = random_category_name(); + let response = add_category(&category_name).await; + assert_eq!(response.status, 200); + + let logged_in_non_admin = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&logged_in_non_admin.token); + + let response = client + .delete_category(DeleteCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_not_allow_guests_to_delete_categories() { + // Add a category + let category_name = random_category_name(); + let response = add_category(&category_name).await; + assert_eq!(response.status, 200); + + let client = TestEnv::default().unauthenticated_client(); + + let response = client + .delete_category(DeleteCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 401); +} + /* todo: - - it_should_not_allow_adding_a_new_category_to_unauthenticated_clients - it should allow adding a new category to authenticated clients - it should not allow adding a new category with an empty name - - it should not allow adding a new category with an empty icon + - it should allow adding a new category with an optional icon - ... */ + +pub mod fixtures { + + use rand::Rng; + + use super::AddCategoryForm; + use crate::e2e::contexts::user::fixtures::logged_in_admin; + use crate::e2e::environment::TestEnv; + use crate::e2e::response::Response; + + pub async fn add_category(category_name: &str) -> Response { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await + } + + pub fn random_category_name() -> String { + format!("category name {}", random_id()) + } + + fn random_id() -> u64 { + let mut rng = rand::thread_rng(); + rng.gen_range(0..1_000_000) + } +} diff --git a/tests/e2e/response.rs b/tests/e2e/response.rs index df04680f..70261cf2 100644 --- a/tests/e2e/response.rs +++ b/tests/e2e/response.rs @@ -1,5 +1,6 @@ use reqwest::Response as ReqwestResponse; +#[derive(Debug)] pub struct Response { pub status: u16, pub content_type: Option, From 9b2266d0b2db16cc65fe4a5c71681775336bbdc5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Apr 2023 10:05:40 +0100 Subject: [PATCH 084/357] tests: [#109] E2E tests for settings routes --- docker/bin/e2e-env-restart.sh | 4 + src/routes/settings.rs | 2 +- tests/e2e/client.rs | 60 ++++--- tests/e2e/contexts/mod.rs | 1 + tests/e2e/contexts/settings.rs | 275 +++++++++++++++++++++++++++++++++ tests/e2e/contexts/user.rs | 2 - tests/e2e/mod.rs | 3 +- 7 files changed, 322 insertions(+), 25 deletions(-) create mode 100755 docker/bin/e2e-env-restart.sh create mode 100644 tests/e2e/contexts/settings.rs diff --git a/docker/bin/e2e-env-restart.sh b/docker/bin/e2e-env-restart.sh new file mode 100755 index 00000000..84731380 --- /dev/null +++ b/docker/bin/e2e-env-restart.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +./docker/bin/e2e-env-down.sh +./docker/bin/e2e-env-up.sh diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 6ba5d2aa..414ebefb 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -27,7 +27,7 @@ pub async fn get_settings(req: HttpRequest, app_data: WebAppData) -> ServiceResu return Err(ServiceError::Unauthorized); } - let settings = app_data.cfg.settings.read().await; + let settings: tokio::sync::RwLockReadGuard = app_data.cfg.settings.read().await; Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) } diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs index e079308b..e668b3fb 100644 --- a/tests/e2e/client.rs +++ b/tests/e2e/client.rs @@ -1,7 +1,7 @@ -use reqwest::Response as ReqwestResponse; use serde::Serialize; use super::contexts::category::{AddCategoryForm, DeleteCategoryForm}; +use super::contexts::settings::UpdateSettingsForm; use super::contexts::user::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; use crate::e2e::connection_info::ConnectionInfo; use crate::e2e::http::{Query, ReqwestQuery}; @@ -49,6 +49,24 @@ impl Client { self.http_client.get("", Query::empty()).await } + // Context: settings + + pub async fn get_public_settings(&self) -> Response { + self.http_client.get("settings/public", Query::empty()).await + } + + pub async fn get_site_name(&self) -> Response { + self.http_client.get("settings/name", Query::empty()).await + } + + pub async fn get_settings(&self) -> Response { + self.http_client.get("settings", Query::empty()).await + } + + pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> Response { + self.http_client.post("settings", &update_settings_form).await + } + // Context: user pub async fn register_user(&self, registration_form: RegistrationForm) -> Response { @@ -87,7 +105,26 @@ impl Http { } pub async fn get(&self, path: &str, params: Query) -> Response { - self.get_request_with_query(path, params).await + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .bearer_auth(token) + .send() + .await + .unwrap(), + None => reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .send() + .await + .unwrap(), + }; + Response::from(response).await } pub async fn post(&self, path: &str, form: &T) -> Response { @@ -145,26 +182,7 @@ impl Http { Response::from(response).await } - pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response { - get(&self.base_url(path), Some(params)).await - } - fn base_url(&self, path: &str) -> String { format!("http://{}{}{path}", &self.connection_info.bind_address, &self.base_path) } } - -async fn get(path: &str, query: Option) -> Response { - let response: ReqwestResponse = match query { - Some(params) => reqwest::Client::builder() - .build() - .unwrap() - .get(path) - .query(&ReqwestQuery::from(params)) - .send() - .await - .unwrap(), - None => reqwest::Client::builder().build().unwrap().get(path).send().await.unwrap(), - }; - Response::from(response).await -} diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs index e96cfc48..1ba75c5e 100644 --- a/tests/e2e/contexts/mod.rs +++ b/tests/e2e/contexts/mod.rs @@ -1,4 +1,5 @@ pub mod about; pub mod category; pub mod root; +pub mod settings; pub mod user; diff --git a/tests/e2e/contexts/settings.rs b/tests/e2e/contexts/settings.rs new file mode 100644 index 00000000..08eb0a10 --- /dev/null +++ b/tests/e2e/contexts/settings.rs @@ -0,0 +1,275 @@ +use serde::{Deserialize, Serialize}; + +use crate::e2e::contexts::user::fixtures::logged_in_admin; +use crate::e2e::environment::TestEnv; + +// Request data + +pub type UpdateSettingsForm = Settings; + +// Response data + +#[derive(Deserialize)] +pub struct AllSettingsResponse { + pub data: Settings, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Settings { + pub website: Website, + pub tracker: Tracker, + pub net: Net, + pub auth: Auth, + pub database: Database, + pub mail: Mail, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Website { + pub name: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Tracker { + pub url: String, + pub mode: String, + pub api_url: String, + pub token: String, + pub token_valid_seconds: u64, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Net { + pub port: u64, + pub base_url: Option, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Auth { + pub email_on_signup: String, + pub min_password_length: u64, + pub max_password_length: u64, + pub secret_key: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Database { + pub connect_url: String, + pub torrent_info_update_interval: u64, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Mail { + pub email_verification_enabled: bool, + pub from: String, + pub reply_to: String, + pub username: String, + pub password: String, + pub server: String, + pub port: u64, +} + +#[derive(Deserialize)] +pub struct PublicSettingsResponse { + pub data: Public, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Public { + pub website_name: String, + pub tracker_url: String, + pub tracker_mode: String, + pub email_on_signup: String, +} + +#[derive(Deserialize)] +pub struct SiteNameResponse { + pub data: String, +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_guests_to_get_the_public_settings() { + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get_public_settings().await; + + let res: PublicSettingsResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!( + res.data, + Public { + website_name: "Torrust".to_string(), + tracker_url: "udp://tracker:6969".to_string(), + tracker_mode: "Public".to_string(), + email_on_signup: "Optional".to_string(), + } + ); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_guests_to_get_the_site_name() { + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get_site_name().await; + + let res: SiteNameResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, "Torrust"); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_admins_to_get_all_the_settings() { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + let response = client.get_settings().await; + + let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!( + res.data, + Settings { + website: Website { + name: "Torrust".to_string(), + }, + tracker: Tracker { + url: "udp://tracker:6969".to_string(), + mode: "Public".to_string(), + api_url: "http://tracker:1212".to_string(), + token: "MyAccessToken".to_string(), + token_valid_seconds: 7_257_600, + }, + net: Net { + port: 3000, + base_url: None, + }, + auth: Auth { + email_on_signup: "Optional".to_string(), + min_password_length: 6, + max_password_length: 64, + secret_key: "MaxVerstappenWC2021".to_string(), + }, + database: Database { + connect_url: "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string(), + torrent_info_update_interval: 3600, + }, + mail: Mail { + email_verification_enabled: false, + from: "example@email.com".to_string(), + reply_to: "noreply@email.com".to_string(), + username: String::new(), + password: String::new(), + server: "mailcatcher".to_string(), + port: 1025, + } + } + ); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_admins_to_update_all_the_settings() { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + // todo: we can't actually change the settings because it would affect other E2E tests. + // Location for the `config.toml` file is hardcoded. We could use a ENV variable to change it. + + let response = client + .update_settings(UpdateSettingsForm { + website: Website { + name: "Torrust".to_string(), + }, + tracker: Tracker { + url: "udp://tracker:6969".to_string(), + mode: "Public".to_string(), + api_url: "http://tracker:1212".to_string(), + token: "MyAccessToken".to_string(), + token_valid_seconds: 7_257_600, + }, + net: Net { + port: 3000, + base_url: None, + }, + auth: Auth { + email_on_signup: "Optional".to_string(), + min_password_length: 6, + max_password_length: 64, + secret_key: "MaxVerstappenWC2021".to_string(), + }, + database: Database { + connect_url: "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string(), + torrent_info_update_interval: 3600, + }, + mail: Mail { + email_verification_enabled: false, + from: "example@email.com".to_string(), + reply_to: "noreply@email.com".to_string(), + username: String::new(), + password: String::new(), + server: "mailcatcher".to_string(), + port: 1025, + }, + }) + .await; + + let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!( + res.data, + Settings { + website: Website { + name: "Torrust".to_string(), + }, + tracker: Tracker { + url: "udp://tracker:6969".to_string(), + mode: "Public".to_string(), + api_url: "http://tracker:1212".to_string(), + token: "MyAccessToken".to_string(), + token_valid_seconds: 7_257_600, + }, + net: Net { + port: 3000, + base_url: None, + }, + auth: Auth { + email_on_signup: "Optional".to_string(), + min_password_length: 6, + max_password_length: 64, + secret_key: "MaxVerstappenWC2021".to_string(), + }, + database: Database { + connect_url: "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string(), + torrent_info_update_interval: 3600, + }, + mail: Mail { + email_verification_enabled: false, + from: "example@email.com".to_string(), + reply_to: "noreply@email.com".to_string(), + username: String::new(), + password: String::new(), + server: "mailcatcher".to_string(), + port: 1025, + } + } + ); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} diff --git a/tests/e2e/contexts/user.rs b/tests/e2e/contexts/user.rs index 7fe182e4..1d0c456f 100644 --- a/tests/e2e/contexts/user.rs +++ b/tests/e2e/contexts/user.rs @@ -177,8 +177,6 @@ async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_w }) .await; - println!("Response body: {}", response.body); - let res: TokenRenewalResponse = serde_json::from_str(&response.body).unwrap(); assert_eq!( diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 1e2c1b02..80c340b7 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -4,6 +4,7 @@ //! //! ``` //! cargo test --features e2e-tests +//! cargo test --features e2e-tests -- --nocapture //! ``` //! //! or the Cargo alias: @@ -27,7 +28,7 @@ //! `./storage/database/torrust_tracker_e2e_testing.db`. If you want to use a //! clean database, delete the files before running the tests. //! -//! See the docker documentation for more information on how to run the API. +//! See the [docker documentation](https://github.com/torrust/torrust-index-backend/tree/develop/docker) for more information on how to run the API. mod asserts; mod client; mod connection_info; From c7c6ffd1b4b767cac7295ba43e6b497aac91b58d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Apr 2023 14:31:03 +0100 Subject: [PATCH 085/357] feat: [#120] add dependency feature To submit multipart forms with reqwest. It'll be used in E2E tests. --- Cargo.lock | 20 ++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f4d8310b..e75869b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1313,6 +1313,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.1.2" @@ -1871,6 +1881,7 @@ dependencies = [ "lazy_static", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2762,6 +2773,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.5" diff --git a/Cargo.toml b/Cargo.toml index b57b4d26..9325eaee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rand_core = { version = "0.6", features = ["std"] } chrono = "0.4.19" jsonwebtoken = "8.1.1" sha-1 = "0.10.0" -reqwest = { version = "0.11.4", features = [ "json" ] } +reqwest = { version = "0.11.4", features = [ "json", "multipart" ] } tokio = {version = "1.13", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} lettre = { version = "0.10.0-rc.3", features = ["builder", "tokio1", "tokio1-rustls-tls", "smtp-transport"]} sailfish = "0.4.0" From a5bdaade655a2c2b1b29bd58b2d25c930356cc73 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Apr 2023 15:59:59 +0100 Subject: [PATCH 086/357] feat: [#120] add cargo dependency tempfile It'll be used to store temporary torrent files for E2E tests. --- Cargo.lock | 240 +++++++++++++++++++++++++++++++++++++++++++++++------ Cargo.toml | 1 + 2 files changed, 216 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e75869b0..3dbe547b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,7 +300,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", "winapi", ] @@ -696,6 +696,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -704,9 +725,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "fastrand" -version = "1.5.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b394ed3d285a429378d3b384b9eb1285267e7df4b166df24b7a6939a04dc392e" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" dependencies = [ "instant", ] @@ -728,7 +749,7 @@ checksum = "975ccf83d8d9d0d84682850a38c8169027be83368805971cc4f238c2b245bc98" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.2.16", "winapi", ] @@ -969,6 +990,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" + [[package]] name = "hex" version = "0.4.3" @@ -1105,6 +1132,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-lifetimes" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" +dependencies = [ + "hermit-abi 0.3.1", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.3.1" @@ -1226,9 +1264,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.132" +version = "0.2.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "libm" @@ -1253,6 +1291,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +[[package]] +name = "linux-raw-sys" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eb31c1778188ae1e64398743890d0877fef36d11521ac60406b42016e8c2cf" + [[package]] name = "local-channel" version = "0.1.2" @@ -1476,7 +1520,7 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", ] @@ -1564,7 +1608,7 @@ dependencies = [ "cfg-if", "instant", "libc", - "redox_syscall", + "redox_syscall 0.2.16", "smallvec", "winapi", ] @@ -1824,6 +1868,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.3" @@ -1831,7 +1884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ "getrandom", - "redox_syscall", + "redox_syscall 0.2.16", "thiserror", ] @@ -1852,15 +1905,6 @@ version = "0.6.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - [[package]] name = "reqwest" version = "0.11.4" @@ -1962,6 +2006,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.37.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0661814f891c57c930a610266415528da53c4933e6dea5fb350cbfe048a9ece" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + [[package]] name = "rustls" version = "0.19.1" @@ -2469,16 +2527,15 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.2.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" dependencies = [ "cfg-if", - "libc", - "rand", - "redox_syscall", - "remove_dir_all", - "winapi", + "fastrand", + "redox_syscall 0.3.5", + "rustix", + "windows-sys 0.45.0", ] [[package]] @@ -2707,6 +2764,7 @@ dependencies = [ "serde_json", "sha-1 0.10.0", "sqlx", + "tempfile", "text-colorizer", "tokio", "toml", @@ -2989,6 +3047,138 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + [[package]] name = "winreg" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 9325eaee..ec2f1ff1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,3 +44,4 @@ fern = "0.6.2" [dev-dependencies] rand = "0.8.5" +tempfile = "3.5.0" From bac7e64322d9852fdd053dcef41c098387265a65 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Apr 2023 19:06:33 +0100 Subject: [PATCH 087/357] feat: [#120] add cargo dependency uuid It'll be used to generate unique identifiers for entities in E2E tests. --- Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + 2 files changed, 11 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 3dbe547b..3152c22d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2769,6 +2769,7 @@ dependencies = [ "tokio", "toml", "urlencoding", + "uuid", ] [[package]] @@ -2900,6 +2901,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821" +[[package]] +name = "uuid" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55a3fef2a1e3b3a00ce878640918820d3c51081576ac657d23af9fc7928fdb" +dependencies = [ + "getrandom", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index ec2f1ff1..22a1ec91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,3 +45,4 @@ fern = "0.6.2" [dev-dependencies] rand = "0.8.5" tempfile = "3.5.0" +uuid = { version = "1.3", features = [ "v4"] } From ea36618aff07dad1105686fb11e52e6e9981eb4f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 27 Apr 2023 08:20:01 +0100 Subject: [PATCH 088/357] feat: [#120] add cargo dependency which It'll be used to check if the command `imdl` is installed. It's needed in E2E tests to generate random torrents. --- Cargo.lock | 12 ++++++++++++ Cargo.toml | 1 + 2 files changed, 13 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 3152c22d..31cd6ae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2770,6 +2770,7 @@ dependencies = [ "toml", "urlencoding", "uuid", + "which", ] [[package]] @@ -3035,6 +3036,17 @@ dependencies = [ "webpki", ] +[[package]] +name = "which" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +dependencies = [ + "either", + "libc", + "once_cell", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 22a1ec91..6d1d7b63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,3 +46,4 @@ fern = "0.6.2" rand = "0.8.5" tempfile = "3.5.0" uuid = { version = "1.3", features = [ "v4"] } +which = "4.4.0" From e3ed34474f199d4d8fe2fdcd3003d8be1e15ca11 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 26 Apr 2023 19:07:56 +0100 Subject: [PATCH 089/357] tests: [#120] E2E tests for torrent routes --- .github/workflows/develop.yml | 2 +- bin/install.sh | 2 +- docker/bin/e2e-env-reset.sh | 26 ++ docker/bin/run-e2e-tests.sh | 3 + project-words.txt | 4 + tests/e2e/asserts.rs | 10 +- tests/e2e/client.rs | 144 ++++++++-- tests/e2e/contexts/category.rs | 12 +- tests/e2e/contexts/mod.rs | 1 + tests/e2e/contexts/torrent/asserts.rs | 45 ++++ tests/e2e/contexts/torrent/contract.rs | 345 ++++++++++++++++++++++++ tests/e2e/contexts/torrent/file.rs | 83 ++++++ tests/e2e/contexts/torrent/fixtures.rs | 145 ++++++++++ tests/e2e/contexts/torrent/mod.rs | 6 + tests/e2e/contexts/torrent/requests.rs | 51 ++++ tests/e2e/contexts/torrent/responses.rs | 104 +++++++ tests/e2e/mod.rs | 2 +- tests/e2e/response.rs | 21 -- tests/e2e/responses.rs | 70 +++++ 19 files changed, 1021 insertions(+), 55 deletions(-) create mode 100755 docker/bin/e2e-env-reset.sh create mode 100644 tests/e2e/contexts/torrent/asserts.rs create mode 100644 tests/e2e/contexts/torrent/contract.rs create mode 100644 tests/e2e/contexts/torrent/file.rs create mode 100644 tests/e2e/contexts/torrent/fixtures.rs create mode 100644 tests/e2e/contexts/torrent/mod.rs create mode 100644 tests/e2e/contexts/torrent/requests.rs create mode 100644 tests/e2e/contexts/torrent/responses.rs delete mode 100644 tests/e2e/response.rs create mode 100644 tests/e2e/responses.rs diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index dca7e7e1..59e05a71 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -24,6 +24,6 @@ jobs: - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - name: Test Coverage - run: cargo llvm-cov nextest + run: cargo llvm-cov nextest - name: E2E Tests run: ./docker/bin/run-e2e-tests.sh diff --git a/bin/install.sh b/bin/install.sh index 041863b2..d4d33a43 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -8,7 +8,7 @@ fi # Generate storage directory if it does not exist mkdir -p "./storage/database" -# Generate the sqlite database for the index baclend if it does not exist +# Generate the sqlite database for the index backend if it does not exist if ! [ -f "./storage/database/data.db" ]; then # todo: it should get the path from config.toml and only do it when we use sqlite touch ./storage/database/data.db diff --git a/docker/bin/e2e-env-reset.sh b/docker/bin/e2e-env-reset.sh new file mode 100755 index 00000000..ae7e3aff --- /dev/null +++ b/docker/bin/e2e-env-reset.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Delete the SQLite databases and recreate them. + +./docker/bin/e2e-env-down.sh + +rm -f ./storage/database/torrust_index_backend_e2e_testing.db +rm -f ./storage/database/torrust_tracker_e2e_testing.db + +# Generate storage directory if it does not exist +mkdir -p "./storage/database" + +# Generate the sqlite database for the index backend if it does not exist +if ! [ -f "./storage/database/torrust_index_backend_e2e_testing.db" ]; then + # todo: it should get the path from config.toml and only do it when we use sqlite + touch ./storage/database/torrust_index_backend_e2e_testing.db + echo ";" | sqlite3 ./storage/database/torrust_index_backend_e2e_testing.db +fi + +# Generate the sqlite database for the tracker if it does not exist +if ! [ -f "./storage/database/torrust_tracker_e2e_testing.db" ]; then + touch ./storage/database/torrust_tracker_e2e_testing.db + echo ";" | sqlite3 ./storage/database/torrust_tracker_e2e_testing.db +fi + +./docker/bin/e2e-env-up.sh diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh index 5eb63c33..cc3b5351 100755 --- a/docker/bin/run-e2e-tests.sh +++ b/docker/bin/run-e2e-tests.sh @@ -35,6 +35,9 @@ wait_for_container_to_be_healthy() { return 1 } +# Install tool to create torrent files +cargo install imdl + cp .env.local .env ./bin/install.sh diff --git a/project-words.txt b/project-words.txt index e2e24938..220cba8b 100644 --- a/project-words.txt +++ b/project-words.txt @@ -17,6 +17,8 @@ hasher Hasher httpseeds imagoodboy +imdl +infohash jsonwebtoken leechers Leechers @@ -41,6 +43,8 @@ strftime sublicensable sublist subpoints +tempdir +tempfile torrust Torrust upgrader diff --git a/tests/e2e/asserts.rs b/tests/e2e/asserts.rs index 115637e7..f2968904 100644 --- a/tests/e2e/asserts.rs +++ b/tests/e2e/asserts.rs @@ -1,8 +1,8 @@ -use crate::e2e::response::Response; +use crate::e2e::responses::TextResponse; // Text responses -pub fn assert_response_title(response: &Response, title: &str) { +pub fn assert_response_title(response: &TextResponse, title: &str) { let title_element = format!("{title}"); assert!( @@ -11,14 +11,14 @@ pub fn assert_response_title(response: &Response, title: &str) { ); } -pub fn assert_text_ok(response: &Response) { +pub fn assert_text_ok(response: &TextResponse) { assert_eq!(response.status, 200); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "text/html; charset=utf-8"); } } -pub fn _assert_text_bad_request(response: &Response) { +pub fn _assert_text_bad_request(response: &TextResponse) { assert_eq!(response.status, 400); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "text/plain; charset=utf-8"); @@ -27,7 +27,7 @@ pub fn _assert_text_bad_request(response: &Response) { // JSON responses -pub fn assert_json_ok(response: &Response) { +pub fn assert_json_ok(response: &TextResponse) { assert_eq!(response.status, 200); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "application/json"); diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs index e668b3fb..727299aa 100644 --- a/tests/e2e/client.rs +++ b/tests/e2e/client.rs @@ -1,11 +1,14 @@ +use reqwest::multipart; use serde::Serialize; use super::contexts::category::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::settings::UpdateSettingsForm; +use super::contexts::torrent::requests::{TorrentId, UpdateTorrentFrom}; use super::contexts::user::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; +use super::responses::{self, BinaryResponse}; use crate::e2e::connection_info::ConnectionInfo; use crate::e2e::http::{Query, ReqwestQuery}; -use crate::e2e::response::Response; +use crate::e2e::responses::TextResponse; /// API Client pub struct Client { @@ -21,71 +24,99 @@ impl Client { // Context: about - pub async fn about(&self) -> Response { + pub async fn about(&self) -> TextResponse { self.http_client.get("about", Query::empty()).await } - pub async fn license(&self) -> Response { + pub async fn license(&self) -> TextResponse { self.http_client.get("about/license", Query::empty()).await } // Context: category - pub async fn get_categories(&self) -> Response { + pub async fn get_categories(&self) -> TextResponse { self.http_client.get("category", Query::empty()).await } - pub async fn add_category(&self, add_category_form: AddCategoryForm) -> Response { + pub async fn add_category(&self, add_category_form: AddCategoryForm) -> TextResponse { self.http_client.post("category", &add_category_form).await } - pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> Response { + pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> TextResponse { self.http_client.delete_with_body("category", &delete_category_form).await } // Context: root - pub async fn root(&self) -> Response { + pub async fn root(&self) -> TextResponse { self.http_client.get("", Query::empty()).await } // Context: settings - pub async fn get_public_settings(&self) -> Response { + pub async fn get_public_settings(&self) -> TextResponse { self.http_client.get("settings/public", Query::empty()).await } - pub async fn get_site_name(&self) -> Response { + pub async fn get_site_name(&self) -> TextResponse { self.http_client.get("settings/name", Query::empty()).await } - pub async fn get_settings(&self) -> Response { + pub async fn get_settings(&self) -> TextResponse { self.http_client.get("settings", Query::empty()).await } - pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> Response { + pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> TextResponse { self.http_client.post("settings", &update_settings_form).await } + // Context: torrent + + pub async fn get_torrents(&self) -> TextResponse { + self.http_client.get("torrents", Query::empty()).await + } + + pub async fn get_torrent(&self, id: TorrentId) -> TextResponse { + self.http_client.get(&format!("torrent/{id}"), Query::empty()).await + } + + pub async fn delete_torrent(&self, id: TorrentId) -> TextResponse { + self.http_client.delete(&format!("torrent/{id}")).await + } + + pub async fn update_torrent(&self, id: TorrentId, update_torrent_form: UpdateTorrentFrom) -> TextResponse { + self.http_client.put(&format!("torrent/{id}"), &update_torrent_form).await + } + + pub async fn upload_torrent(&self, form: multipart::Form) -> TextResponse { + self.http_client.post_multipart("torrent/upload", form).await + } + + pub async fn download_torrent(&self, id: TorrentId) -> responses::BinaryResponse { + self.http_client + .get_binary(&format!("torrent/download/{id}"), Query::empty()) + .await + } + // Context: user - pub async fn register_user(&self, registration_form: RegistrationForm) -> Response { + pub async fn register_user(&self, registration_form: RegistrationForm) -> TextResponse { self.http_client.post("user/register", ®istration_form).await } - pub async fn login_user(&self, registration_form: LoginForm) -> Response { + pub async fn login_user(&self, registration_form: LoginForm) -> TextResponse { self.http_client.post("user/login", ®istration_form).await } - pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Response { + pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> TextResponse { self.http_client.post("user/token/verify", &token_verification_form).await } - pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Response { + pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> TextResponse { self.http_client.post("user/token/renew", &token_verification_form).await } - pub async fn ban_user(&self, username: Username) -> Response { + pub async fn ban_user(&self, username: Username) -> TextResponse { self.http_client.delete(&format!("user/ban/{}", &username.value)).await } } @@ -104,7 +135,30 @@ impl Http { } } - pub async fn get(&self, path: &str, params: Query) -> Response { + pub async fn get(&self, path: &str, params: Query) -> TextResponse { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .bearer_auth(token) + .send() + .await + .unwrap(), + None => reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .send() + .await + .unwrap(), + }; + TextResponse::from(response).await + } + + pub async fn get_binary(&self, path: &str, params: Query) -> BinaryResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() .build() @@ -124,10 +178,10 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + BinaryResponse::from(response).await } - pub async fn post(&self, path: &str, form: &T) -> Response { + pub async fn post(&self, path: &str, form: &T) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::new() .post(self.base_url(path).clone()) @@ -143,10 +197,52 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + TextResponse::from(response).await + } + + pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> TextResponse { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::builder() + .build() + .unwrap() + .post(self.base_url(path).clone()) + .multipart(form) + .bearer_auth(token) + .send() + .await + .unwrap(), + None => reqwest::Client::builder() + .build() + .unwrap() + .post(self.base_url(path).clone()) + .multipart(form) + .send() + .await + .unwrap(), + }; + TextResponse::from(response).await + } + + pub async fn put(&self, path: &str, form: &T) -> TextResponse { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::new() + .put(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await + .unwrap(), + None => reqwest::Client::new() + .put(self.base_url(path).clone()) + .json(&form) + .send() + .await + .unwrap(), + }; + TextResponse::from(response).await } - async fn delete(&self, path: &str) -> Response { + async fn delete(&self, path: &str) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::new() .delete(self.base_url(path).clone()) @@ -160,10 +256,10 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + TextResponse::from(response).await } - async fn delete_with_body(&self, path: &str, form: &T) -> Response { + async fn delete_with_body(&self, path: &str, form: &T) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::new() .delete(self.base_url(path).clone()) @@ -179,7 +275,7 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + TextResponse::from(response).await } fn base_url(&self, path: &str) -> String { diff --git a/tests/e2e/contexts/category.rs b/tests/e2e/contexts/category.rs index 417b2516..ca6ed51a 100644 --- a/tests/e2e/contexts/category.rs +++ b/tests/e2e/contexts/category.rs @@ -217,9 +217,17 @@ pub mod fixtures { use super::AddCategoryForm; use crate::e2e::contexts::user::fixtures::logged_in_admin; use crate::e2e::environment::TestEnv; - use crate::e2e::response::Response; + use crate::e2e::responses::TextResponse; - pub async fn add_category(category_name: &str) -> Response { + pub fn software_predefined_category_name() -> String { + "software".to_string() + } + + pub fn software_predefined_category_id() -> i64 { + 5 + } + + pub async fn add_category(category_name: &str) -> TextResponse { let logged_in_admin = logged_in_admin().await; let client = TestEnv::default().authenticated_client(&logged_in_admin.token); diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs index 1ba75c5e..a6f14141 100644 --- a/tests/e2e/contexts/mod.rs +++ b/tests/e2e/contexts/mod.rs @@ -2,4 +2,5 @@ pub mod about; pub mod category; pub mod root; pub mod settings; +pub mod torrent; pub mod user; diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs new file mode 100644 index 00000000..d4b2be4a --- /dev/null +++ b/tests/e2e/contexts/torrent/asserts.rs @@ -0,0 +1,45 @@ +use super::responses::TorrentDetails; + +/// Assert that the torrent details match the expected ones. +/// It ignores some fields that are not relevant for the E2E tests +/// or hard to assert due to the concurrent nature of the tests. +pub fn assert_expected_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) { + assert_eq!( + torrent.torrent_id, expected_torrent.torrent_id, + "torrent `file_size` mismatch" + ); + assert_eq!(torrent.uploader, expected_torrent.uploader, "torrent `uploader` mismatch"); + assert_eq!(torrent.info_hash, expected_torrent.info_hash, "torrent `info_hash` mismatch"); + assert_eq!(torrent.title, expected_torrent.title, "torrent `title` mismatch"); + assert_eq!( + torrent.description, expected_torrent.description, + "torrent `description` mismatch" + ); + assert_eq!( + torrent.category.category_id, expected_torrent.category.category_id, + "torrent `category.category_id` mismatch" + ); + assert_eq!( + torrent.category.name, expected_torrent.category.name, + "torrent `category.name` mismatch" + ); + // assert_eq!(torrent.category.num_torrents, expected_torrent.category.num_torrents, "torrent `category.num_torrents` mismatch"); // Ignored + // assert_eq!(torrent.upload_date, expected_torrent.upload_date, "torrent `upload_date` mismatch"); // Ignored, can't mock time easily for now. + assert_eq!(torrent.file_size, expected_torrent.file_size, "torrent `file_size` mismatch"); + assert_eq!(torrent.seeders, expected_torrent.seeders, "torrent `seeders` mismatch"); + assert_eq!(torrent.leechers, expected_torrent.leechers, "torrent `leechers` mismatch"); + assert_eq!(torrent.files, expected_torrent.files, "torrent `files` mismatch"); + assert_eq!(torrent.trackers, expected_torrent.trackers, "torrent `trackers` mismatch"); + assert_eq!( + torrent.magnet_link, expected_torrent.magnet_link, + "torrent `magnet_link` mismatch" + ); +} + +/// Full assert for test debugging purposes. +pub fn _assert_eq_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) { + assert_eq!( + torrent, expected_torrent, + "\nUnexpected torrent details:\n{torrent:#?} torrent details should match the previously indexed torrent:\n{expected_torrent:#?}" + ); +} diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs new file mode 100644 index 00000000..e7521de8 --- /dev/null +++ b/tests/e2e/contexts/torrent/contract.rs @@ -0,0 +1,345 @@ +//! API contract for `torrent` context. + +/* +todo: + +Download torrent file: + +- It should allow authenticated users to download a torrent with a personal tracker url + +Delete torrent: + +- After deleting a torrent, it should be removed from the tracker whitelist + +Get torrent info: + +- The torrent info: + - should contain the tracker URL + - If no user owned tracker key can be found, it should use the default tracker url + - If user owned tracker key can be found, it should use the personal tracker url + - should contain the magnet link with the trackers from the torrent file + - should contain realtime seeders and leechers from the tracker +*/ + +mod for_guests { + use torrust_index_backend::utils::parse_torrent::decode_torrent; + + use crate::e2e::contexts::category::fixtures::software_predefined_category_id; + use crate::e2e::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::torrent::responses::{Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse}; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_guests_to_get_torrents() { + let uploader = logged_in_user().await; + let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get_torrents().await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + assert!(torrent_list_response.data.total > 0); + assert!(torrent_list_response.data.contains(indexed_torrent.torrent_id)); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_guests_to_get_torrent_details_searching_by_id() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get_torrent(uploaded_torrent.torrent_id).await; + + let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); + + let expected_torrent = TorrentDetails { + torrent_id: uploaded_torrent.torrent_id, + uploader: uploader.username, + info_hash: test_torrent.file_info.info_hash.to_uppercase(), + title: test_torrent.index_info.title.clone(), + description: test_torrent.index_info.description, + category: Category { + category_id: software_predefined_category_id(), + name: test_torrent.index_info.category, + num_torrents: 19, // Ignored in assertion + }, + upload_date: "2023-04-27 07:56:08".to_string(), // Ignored in assertion + file_size: test_torrent.file_info.content_size, + seeders: 0, + leechers: 0, + files: vec![File { + path: vec![test_torrent.file_info.files[0].clone()], + // Using one file torrent for testing: content_size = first file size + length: test_torrent.file_info.content_size, + md5sum: None, + }], + // code-review: why is this duplicated? + trackers: vec!["udp://tracker:6969".to_string(), "udp://tracker:6969".to_string()], + magnet_link: format!( + // cspell:disable-next-line + "magnet:?xt=urn:btih:{}&dn={}&tr=udp%3A%2F%2Ftracker%3A6969&tr=udp%3A%2F%2Ftracker%3A6969", + test_torrent.file_info.info_hash.to_uppercase(), + test_torrent.index_info.title + ), + }; + + assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_id() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.download_torrent(uploaded_torrent.torrent_id).await; + + let torrent = decode_torrent(&response.bytes).unwrap(); + let mut expected_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + + // code-review: The backend does not generate exactly the same torrent + // that was uploaded and created by the `imdl` command-line tool. + // So we need to update the expected torrent to match the one generated + // by the backend. For some of them it makes sense (`announce` and `announce_list`), + // for others it does not. + expected_torrent.info.private = Some(0); + expected_torrent.announce = Some("udp://tracker:6969".to_string()); + expected_torrent.encoding = None; + expected_torrent.announce_list = Some(vec![vec!["udp://tracker:6969".to_string()]]); + expected_torrent.creation_date = None; + expected_torrent.created_by = None; + + assert_eq!(torrent, expected_torrent); + assert!(response.is_bittorrent_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_guests_to_delete_torrents() { + let uploader = logged_in_user().await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + + assert_eq!(response.status, 401); + } +} + +mod for_authenticated_users { + + use crate::e2e::contexts::torrent::fixtures::random_torrent; + use crate::e2e::contexts::torrent::requests::UploadTorrentMultipartForm; + use crate::e2e::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_authenticated_users_to_upload_new_torrents() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + let test_torrent = random_torrent(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + let _uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + // code-review: the response only returns the torrent autoincrement ID + // generated by the DB. So we can't assert that the torrent was uploaded. + // We could return the infohash. + // We are going to use the infohash to get the torrent. See issue: + // https://github.com/torrust/torrust-index-backend/issues/115 + + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + let mut test_torrent = random_torrent(); + + test_torrent.index_info.category = "non-existing-category".to_string(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same title as the first one + let mut second_torrent = random_torrent(); + second_torrent.index_info.title = first_torrent_title; + let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_uploading_a_torrent_with_a_infohash_that_already_exists() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let mut first_torrent_clone = first_torrent.clone(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same infohash as the first one. + // We need to change the title otherwise the torrent will be rejected + // because of the duplicate title. + first_torrent_clone.index_info.title = format!("{}-clone", first_torrent_title); + let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + mod and_non_admins { + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_non_admins_to_delete_torrents() { + let uploader = logged_in_user().await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().authenticated_client(&uploader.token); + + let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + + assert_eq!(response.status, 403); + } + } + + mod and_torrent_owners { + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom; + use crate::e2e::contexts::torrent::responses::UpdatedTorrentResponse; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_torrent_owners_to_update_their_torrents() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().authenticated_client(&uploader.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + uploaded_torrent.torrent_id, + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + } + + mod and_admins { + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom; + use crate::e2e::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_admins_to_delete_torrents_searching_by_id() { + let uploader = logged_in_user().await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&admin.token); + + let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + + let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(deleted_torrent_response.data.torrent_id, uploaded_torrent.torrent_id); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_admins_to_update_someone_elses_torrents() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + uploaded_torrent.torrent_id, + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + } +} diff --git a/tests/e2e/contexts/torrent/file.rs b/tests/e2e/contexts/torrent/file.rs new file mode 100644 index 00000000..e06b1385 --- /dev/null +++ b/tests/e2e/contexts/torrent/file.rs @@ -0,0 +1,83 @@ +//! Utility functions for torrent files. +//! +//! It's a wrapper around the [imdl](https://crates.io/crates/imdl) program. +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::Deserialize; +use which::which; + +/// Attributes parsed from a torrent file. +#[derive(Deserialize, Clone)] +pub struct TorrentFileInfo { + pub name: String, + pub comment: Option, + pub creation_date: u64, + pub created_by: String, + pub source: Option, + pub info_hash: String, + pub torrent_size: u64, + pub content_size: u64, + pub private: bool, + pub tracker: Option, + pub announce_list: Vec, + pub update_url: Option, + pub dht_nodes: Vec, + pub piece_size: u64, + pub piece_count: u64, + pub file_count: u64, + pub files: Vec, +} + +/// Creates a torrent file for the given file. +/// This function requires the `imdl` program to be installed. +/// +pub fn create_torrent(dir: &Path, file_name: &str) -> PathBuf { + guard_that_torrent_edition_cmd_is_installed(); + + let input_file_path = Path::new(dir).join(file_name); + let output_file_path = Path::new(dir).join(format!("{file_name}.torrent")); + + let _output = Command::new("imdl") + .args(["torrent", "create", "--show"]) + .args(["--input", &format!("{}", input_file_path.to_string_lossy())]) + .args(["--output", &format!("{}", output_file_path.to_string_lossy())]) + .output() + .unwrap_or_else(|_| panic!("failed to create torrent file: {:?}", output_file_path.to_string_lossy())); + + //io::stdout().write_all(&output.stdout).unwrap(); + //io::stderr().write_all(&output.stderr).unwrap(); + + output_file_path +} + +/// Parses torrent file. +/// This function requires the `imdl` program to be installed. +/// +pub fn parse_torrent(torrent_file_path: &Path) -> TorrentFileInfo { + guard_that_torrent_edition_cmd_is_installed(); + + let output = Command::new("imdl") + .args(["torrent", "show", "--json", &torrent_file_path.to_string_lossy()]) + .output() + .unwrap_or_else(|_| panic!("failed to open torrent file: {:?}", &torrent_file_path.to_string_lossy())); + + match std::str::from_utf8(&output.stdout) { + Ok(parsed_torrent_json) => { + let res: TorrentFileInfo = serde_json::from_str(parsed_torrent_json).unwrap(); + res + } + Err(err) => panic!("got non UTF-8 data from 'imdl'. Error: {err}"), + } +} + +/// It panics if the `imdl` console application is not installed. +fn guard_that_torrent_edition_cmd_is_installed() { + const IMDL_BINARY: &str = "imdl"; + match which(IMDL_BINARY) { + Ok(_path) => (), + Err(err) => { + panic!("Can't create torrent with \"imdl\": {err}. Please install it with: `cargo install imdl`"); + } + } +} diff --git a/tests/e2e/contexts/torrent/fixtures.rs b/tests/e2e/contexts/torrent/fixtures.rs new file mode 100644 index 00000000..96d69d9a --- /dev/null +++ b/tests/e2e/contexts/torrent/fixtures.rs @@ -0,0 +1,145 @@ +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use tempfile::{tempdir, TempDir}; +use uuid::Uuid; + +use super::file::{create_torrent, parse_torrent, TorrentFileInfo}; +use super::requests::{BinaryFile, UploadTorrentMultipartForm}; +use super::responses::{Id, UploadedTorrentResponse}; +use crate::e2e::contexts::category::fixtures::software_predefined_category_name; +use crate::e2e::contexts::user::LoggedInUserData; +use crate::e2e::environment::TestEnv; + +/// Information about a torrent that is going to added to the index. +#[derive(Clone)] +pub struct TorrentIndexInfo { + pub title: String, + pub description: String, + pub category: String, + pub torrent_file: BinaryFile, +} + +impl From for UploadTorrentMultipartForm { + fn from(indexed_torrent: TorrentIndexInfo) -> UploadTorrentMultipartForm { + UploadTorrentMultipartForm { + title: indexed_torrent.title, + description: indexed_torrent.description, + category: indexed_torrent.category, + torrent_file: indexed_torrent.torrent_file, + } + } +} + +/// Torrent that has been added to the index. +pub struct TorrentListedInIndex { + pub torrent_id: Id, + pub title: String, + pub description: String, + pub category: String, + pub torrent_file: BinaryFile, +} + +impl TorrentListedInIndex { + pub fn from(torrent_to_index: TorrentIndexInfo, torrent_id: Id) -> Self { + Self { + torrent_id, + title: torrent_to_index.title, + description: torrent_to_index.description, + category: torrent_to_index.category, + torrent_file: torrent_to_index.torrent_file, + } + } +} + +/// Add a new random torrent to the index +pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (TestTorrent, TorrentListedInIndex) { + let random_torrent = random_torrent(); + let indexed_torrent = upload_torrent(uploader, &random_torrent.index_info).await; + (random_torrent, indexed_torrent) +} + +/// Upload a torrent to the index +pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo) -> TorrentListedInIndex { + let client = TestEnv::default().authenticated_client(&uploader.token); + + let form: UploadTorrentMultipartForm = torrent.clone().into(); + + let response = client.upload_torrent(form.into()).await; + + let res: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + TorrentListedInIndex::from(torrent.clone(), res.data.torrent_id) +} + +#[derive(Clone)] +pub struct TestTorrent { + /// Parsed info from torrent file. + pub file_info: TorrentFileInfo, + /// Torrent info needed to add the torrent to the index. + pub index_info: TorrentIndexInfo, +} + +impl TestTorrent { + pub fn random() -> Self { + let temp_dir = temp_dir(); + + let torrents_dir_path = temp_dir.path().to_owned(); + + // Random ID to identify all the torrent related entities: files, fields, ... + // That makes easier to debug the tests outputs. + let id = Uuid::new_v4(); + + // Create a random torrent file + let torrent_path = random_torrent_file(&torrents_dir_path, &id); + + // Load torrent binary file + let torrent_file = BinaryFile::from_file_at_path(&torrent_path); + + // Load torrent file metadata + let torrent_info = parse_torrent(&torrent_path); + + let torrent_to_index = TorrentIndexInfo { + title: format!("title-{id}"), + description: format!("description-{id}"), + category: software_predefined_category_name(), + torrent_file, + }; + + TestTorrent { + file_info: torrent_info, + index_info: torrent_to_index, + } + } +} + +pub fn random_torrent() -> TestTorrent { + TestTorrent::random() +} + +pub fn random_torrent_file(dir: &Path, id: &Uuid) -> PathBuf { + // Create random text file + let file_name = random_txt_file(dir, id); + + // Create torrent file for the text file + create_torrent(dir, &file_name) +} + +pub fn random_txt_file(dir: &Path, id: &Uuid) -> String { + // Sample file name + let file_name = format!("file-{id}.txt"); + + // Sample file path + let file_path = dir.join(file_name.clone()); + + // Write sample text to the temporary file + let mut file = File::create(file_path).unwrap(); + file.write_all(id.as_bytes()).unwrap(); + + file_name +} + +pub fn temp_dir() -> TempDir { + tempdir().unwrap() +} diff --git a/tests/e2e/contexts/torrent/mod.rs b/tests/e2e/contexts/torrent/mod.rs new file mode 100644 index 00000000..4f3882e6 --- /dev/null +++ b/tests/e2e/contexts/torrent/mod.rs @@ -0,0 +1,6 @@ +pub mod asserts; +pub mod contract; +pub mod file; +pub mod fixtures; +pub mod requests; +pub mod responses; diff --git a/tests/e2e/contexts/torrent/requests.rs b/tests/e2e/contexts/torrent/requests.rs new file mode 100644 index 00000000..fb1ec578 --- /dev/null +++ b/tests/e2e/contexts/torrent/requests.rs @@ -0,0 +1,51 @@ +use std::fs; +use std::path::Path; + +use reqwest::multipart::Form; +use serde::{Deserialize, Serialize}; + +pub type TorrentId = i64; + +pub struct UploadTorrentMultipartForm { + pub title: String, + pub description: String, + pub category: String, + pub torrent_file: BinaryFile, +} + +#[derive(Clone)] +pub struct BinaryFile { + pub name: String, + pub contents: Vec, +} + +impl BinaryFile { + pub fn from_file_at_path(path: &Path) -> Self { + BinaryFile { + name: path.file_name().unwrap().to_owned().into_string().unwrap(), + contents: fs::read(path).unwrap(), + } + } +} + +impl From for Form { + fn from(form: UploadTorrentMultipartForm) -> Self { + Form::new() + .text("title", form.title) + .text("description", form.description) + .text("category", form.category) + .part( + "torrent", + reqwest::multipart::Part::bytes(form.torrent_file.contents) + .file_name(form.torrent_file.name) + .mime_str("application/x-bittorrent") + .unwrap(), + ) + } +} + +#[derive(Deserialize, Serialize)] +pub struct UpdateTorrentFrom { + pub title: Option, + pub description: Option, +} diff --git a/tests/e2e/contexts/torrent/responses.rs b/tests/e2e/contexts/torrent/responses.rs new file mode 100644 index 00000000..f2b6739c --- /dev/null +++ b/tests/e2e/contexts/torrent/responses.rs @@ -0,0 +1,104 @@ +use serde::Deserialize; + +pub type Id = i64; +pub type CategoryId = i64; +pub type UtcDateTime = String; // %Y-%m-%d %H:%M:%S + +#[derive(Deserialize, PartialEq, Debug)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Deserialize)] +pub struct TorrentListResponse { + pub data: TorrentList, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentList { + pub total: u32, + pub results: Vec, +} + +impl TorrentList { + pub fn contains(&self, torrent_id: Id) -> bool { + self.results.iter().any(|item| item.torrent_id == torrent_id) + } +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct ListItem { + pub torrent_id: i64, + pub uploader: String, + pub info_hash: String, + pub title: String, + pub description: Option, + pub category_id: i64, + pub date_uploaded: String, + pub file_size: i64, + pub seeders: i64, + pub leechers: i64, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentDetailsResponse { + pub data: TorrentDetails, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentDetails { + pub torrent_id: Id, + pub uploader: String, + pub info_hash: String, + pub title: String, + pub description: String, + pub category: Category, + pub upload_date: UtcDateTime, + pub file_size: u64, + pub seeders: u64, + pub leechers: u64, + pub files: Vec, + pub trackers: Vec, + pub magnet_link: String, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Category { + pub category_id: CategoryId, + pub name: String, + pub num_torrents: u64, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct File { + pub path: Vec, + pub length: u64, + pub md5sum: Option, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UploadedTorrentResponse { + pub data: UploadedTorrent, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UploadedTorrent { + pub torrent_id: Id, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct DeletedTorrentResponse { + pub data: DeletedTorrent, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct DeletedTorrent { + pub torrent_id: Id, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UpdatedTorrentResponse { + pub data: UpdatedTorrent, +} + +pub type UpdatedTorrent = TorrentDetails; diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 80c340b7..ce9ef1d1 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -35,4 +35,4 @@ mod connection_info; mod contexts; mod environment; mod http; -mod response; +mod responses; diff --git a/tests/e2e/response.rs b/tests/e2e/response.rs deleted file mode 100644 index 70261cf2..00000000 --- a/tests/e2e/response.rs +++ /dev/null @@ -1,21 +0,0 @@ -use reqwest::Response as ReqwestResponse; - -#[derive(Debug)] -pub struct Response { - pub status: u16, - pub content_type: Option, - pub body: String, -} - -impl Response { - pub async fn from(response: ReqwestResponse) -> Self { - Self { - status: response.status().as_u16(), - content_type: response - .headers() - .get("content-type") - .map(|content_type| content_type.to_str().unwrap().to_owned()), - body: response.text().await.unwrap(), - } - } -} diff --git a/tests/e2e/responses.rs b/tests/e2e/responses.rs new file mode 100644 index 00000000..0be8bbd6 --- /dev/null +++ b/tests/e2e/responses.rs @@ -0,0 +1,70 @@ +use reqwest::Response as ReqwestResponse; + +#[derive(Debug)] +pub struct TextResponse { + pub status: u16, + pub content_type: Option, + pub body: String, +} + +impl TextResponse { + pub async fn from(response: ReqwestResponse) -> Self { + Self { + status: response.status().as_u16(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), + body: response.text().await.unwrap(), + } + } + + pub fn is_json_and_ok(&self) -> bool { + self.is_ok() && self.is_json() + } + + pub fn is_json(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/json"; + } + false + } + + pub fn is_ok(&self) -> bool { + self.status == 200 + } +} + +#[derive(Debug)] +pub struct BinaryResponse { + pub status: u16, + pub content_type: Option, + pub bytes: Vec, +} + +impl BinaryResponse { + pub async fn from(response: ReqwestResponse) -> Self { + Self { + status: response.status().as_u16(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), + bytes: response.bytes().await.unwrap().to_vec(), + } + } + pub fn is_bittorrent_and_ok(&self) -> bool { + self.is_ok() && self.is_bittorrent() + } + + pub fn is_bittorrent(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/x-bittorrent"; + } + false + } + + pub fn is_ok(&self) -> bool { + self.status == 200 + } +} From e8f8803fa5b55063514301e40c860e868d1c28d1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 08:37:50 +0100 Subject: [PATCH 090/357] chore(deps): [#122] bump argon2 from 0.4.1 to 0.5.0 and pbkdf2 0.11.0 to 0.12.1 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 4 ++-- src/routes/user.rs | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31cd6ae5..676b274d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,9 +265,9 @@ dependencies = [ [[package]] name = "argon2" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4ce4441f99dbd377ca8a8f57b698c44d0d6e712d8329b5040da5a64aa1ce73" +checksum = "95c2fcf79ad1932ac6269a738109997a83c227c09b75842ae564dc8ede6a861c" dependencies = [ "base64ct", "blake2", @@ -349,11 +349,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.4" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cf849ee05b2ee5fba5e36f97ff8ec2533916700fc0758d40d92136a42f3388" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.3", + "digest 0.10.6", ] [[package]] @@ -631,9 +631,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.3" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" dependencies = [ "block-buffer 0.10.2", "crypto-common", @@ -1008,7 +1008,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.3", + "digest 0.10.6", ] [[package]] @@ -1615,9 +1615,9 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", "rand_core", @@ -1638,11 +1638,11 @@ checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" [[package]] name = "pbkdf2" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +checksum = "f0ca0b5a68607598bf3bad68f32227a8164f6254833f84eafaac409cd6746c31" dependencies = [ - "digest 0.10.3", + "digest 0.10.6", "hmac", "password-hash", "sha2", @@ -1852,9 +1852,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] @@ -1974,7 +1974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cf22754c49613d2b3b119f0e5d46e34a2c628a937e3024b8762de4e7d8c710b" dependencies = [ "byteorder", - "digest 0.10.3", + "digest 0.10.6", "num-bigint-dig", "num-integer", "num-iter", @@ -2233,7 +2233,7 @@ checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.3", + "digest 0.10.6", ] [[package]] @@ -2250,7 +2250,7 @@ checksum = "006769ba83e921b3085caa8334186b00cf92b4cb1a6cf4632fbccc8eff5c7549" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.3", + "digest 0.10.6", ] [[package]] @@ -2261,7 +2261,7 @@ checksum = "cf9db03534dff993187064c4e0c05a5708d2a9728ace9a8959b77bedf415dac5" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.3", + "digest 0.10.6", ] [[package]] @@ -2366,7 +2366,7 @@ dependencies = [ "bytes", "crc", "crossbeam-queue", - "digest 0.10.3", + "digest 0.10.6", "dotenvy", "either", "event-listener", diff --git a/Cargo.toml b/Cargo.toml index 6d1d7b63..fffdee5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ serde_json = "1" serde_bencode = "0.2.3" serde_bytes = "0.11" urlencoding = "2.1.0" -argon2 = "0.4.1" +argon2 = "0.5" rand_core = { version = "0.6", features = ["std"] } chrono = "0.4.19" jsonwebtoken = "8.1.1" @@ -37,7 +37,7 @@ tokio = {version = "1.13", features = ["macros", "io-util", "net", "time", "rt-m lettre = { version = "0.10.0-rc.3", features = ["builder", "tokio1", "tokio1-rustls-tls", "smtp-transport"]} sailfish = "0.4.0" regex = "1.6.0" -pbkdf2 = "0.11.0" +pbkdf2 = { version = "0.12", features = ["simple"] } text-colorizer = "1.0.0" log = "0.4.17" fern = "0.6.2" diff --git a/src/routes/user.rs b/src/routes/user.rs index 8de0f576..7fb36d46 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -3,8 +3,8 @@ use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use log::{debug, info}; +use pbkdf2::password_hash::rand_core::OsRng; use pbkdf2::Pbkdf2; -use rand_core::OsRng; use serde::{Deserialize, Serialize}; use crate::common::WebAppData; From 4a5b7bb9516a8fbdc4ed0a977b50a60a90c1f2c2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 08:41:55 +0100 Subject: [PATCH 091/357] chore(deps): [#122] bump regex from 1.6.0 to 1.8.1 --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 676b274d..6aa75c90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.18" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" dependencies = [ "memchr", ] @@ -1890,9 +1890,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.6.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" dependencies = [ "aho-corasick", "memchr", @@ -1901,9 +1901,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.27" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" +checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" [[package]] name = "reqwest" diff --git a/Cargo.toml b/Cargo.toml index fffdee5e..c6ef873e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ reqwest = { version = "0.11.4", features = [ "json", "multipart" ] } tokio = {version = "1.13", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} lettre = { version = "0.10.0-rc.3", features = ["builder", "tokio1", "tokio1-rustls-tls", "smtp-transport"]} sailfish = "0.4.0" -regex = "1.6.0" +regex = "1.8" pbkdf2 = { version = "0.12", features = ["simple"] } text-colorizer = "1.0.0" log = "0.4.17" From 49e582b12b198efa582271bddc79cbd4a4c03710 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 08:45:34 +0100 Subject: [PATCH 092/357] chore(deps): [#122] bump sailfish from 0.4.0 to 0.6.1 --- Cargo.lock | 159 ++++++++++++++++++++++++++++++++++++++--------------- Cargo.toml | 2 +- 2 files changed, 115 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6aa75c90..f46bbb72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2f86cd6857c135e6e9fe57b1619a88d1f94a7df34c00e11fe13e64fd3438837" dependencies = [ "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -234,7 +234,7 @@ checksum = "0d048c6986743105c1e8e9729fbc8d5d1667f2f62393a58be8d85a7d9a5a6c8d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -282,7 +282,7 @@ checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -475,7 +475,7 @@ dependencies = [ "rust-ini", "serde", "serde_json", - "toml", + "toml 0.5.8", "yaml-rust", ] @@ -617,7 +617,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -743,14 +743,14 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.15" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "975ccf83d8d9d0d84682850a38c8169027be83368805971cc4f238c2b245bc98" +checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153" dependencies = [ "cfg-if", "libc", "redox_syscall 0.2.16", - "winapi", + "windows-sys 0.48.0", ] [[package]] @@ -881,7 +881,7 @@ checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -1013,11 +1013,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2456aef2e6b6a9784192ae780c0f15bc57df0e918585282325e8c8ac27737654" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" dependencies = [ - "winapi", + "windows-sys 0.48.0", ] [[package]] @@ -1702,7 +1702,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -1733,7 +1733,7 @@ checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -1790,18 +1790,18 @@ checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" [[package]] name = "proc-macro2" -version = "1.0.36" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] name = "quote" -version = "1.0.19" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f53dc8cf16a769a6f677e09e7ff2cd4be1ea0f48754aac39520536962011de0d" +checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" dependencies = [ "proc-macro2", ] @@ -2035,15 +2035,15 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.5" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" [[package]] name = "sailfish" -version = "0.4.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "948a7edfc2f03d7c58a097dda25ed29440a72e8528894a6e182fe9171195fed1" +checksum = "29a48cead573ab494535cd9f24838a721a613e5da899ee974c8e2fdbb3d60222" dependencies = [ "itoap", "ryu", @@ -2053,9 +2053,9 @@ dependencies = [ [[package]] name = "sailfish-compiler" -version = "0.4.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f0a01133d6ce146020e6416ac6a823f813f1cbb30ff77548b4fa20749524947" +checksum = "f26deb100e96e303d266d1852525b1da033678ed9eabd539f5ed5eba01333394" dependencies = [ "filetime", "home", @@ -2063,15 +2063,15 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", - "toml", + "syn 2.0.15", + "toml 0.7.3", ] [[package]] name = "sailfish-macros" -version = "0.4.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86326c1f1dce0b316e0a47071f683b185417dc64e1a704380b5c706b09e871b1" +checksum = "41162f9a79f2541458a1cf20f8d81d55bb2bcbdd03d295c29765d9bcb43188c9" dependencies = [ "proc-macro2", "sailfish-compiler", @@ -2152,9 +2152,9 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.144" +version = "1.0.160" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860" +checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" dependencies = [ "serde_derive", ] @@ -2180,13 +2180,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.144" +version = "1.0.160" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00" +checksum = "291a097c63d8497e00160b166a967a4a79c64f3facdd01cbd7502231688d77df" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.15", ] [[package]] @@ -2200,6 +2200,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.0" @@ -2418,7 +2427,7 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-rt", - "syn", + "syn 1.0.94", "url", ] @@ -2467,7 +2476,7 @@ dependencies = [ "quote", "serde", "serde_derive", - "syn", + "syn 1.0.94", ] [[package]] @@ -2483,7 +2492,7 @@ dependencies = [ "serde_derive", "serde_json", "sha1 0.6.0", - "syn", + "syn 1.0.94", ] [[package]] @@ -2519,6 +2528,17 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "syn" +version = "2.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tap" version = "1.0.1" @@ -2564,7 +2584,7 @@ checksum = "e8f2591983642de85c921015f3f070c665a197ed69e417af436115e3a1407487" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -2630,7 +2650,7 @@ dependencies = [ "proc-macro2", "quote", "standback", - "syn", + "syn 1.0.94", ] [[package]] @@ -2676,7 +2696,7 @@ checksum = "54473be61f4ebe4efd09cec9bd5d16fa51d70ea0192213d754d2d500457db110" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.94", ] [[package]] @@ -2734,6 +2754,40 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b403acf6f2bb0859c93c7f0d967cb4a75a7ac552100f9322faf64dc047669b21" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "torrust-index-backend" version = "2.0.0-dev.1" @@ -2767,7 +2821,7 @@ dependencies = [ "tempfile", "text-colorizer", "tokio", - "toml", + "toml 0.5.8", "urlencoding", "uuid", "which", @@ -2851,6 +2905,12 @@ dependencies = [ "matches", ] +[[package]] +name = "unicode-ident" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" + [[package]] name = "unicode-normalization" version = "0.1.19" @@ -2919,9 +2979,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "want" @@ -2962,7 +3022,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 1.0.94", "wasm-bindgen-shared", ] @@ -2996,7 +3056,7 @@ checksum = "0195807922713af1e67dc66132c7328206ed9766af3858164fb583eedc25fbad" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.94", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3201,6 +3261,15 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +[[package]] +name = "winnow" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index c6ef873e..5dbb6998 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ sha-1 = "0.10.0" reqwest = { version = "0.11.4", features = [ "json", "multipart" ] } tokio = {version = "1.13", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} lettre = { version = "0.10.0-rc.3", features = ["builder", "tokio1", "tokio1-rustls-tls", "smtp-transport"]} -sailfish = "0.4.0" +sailfish = "0.6" regex = "1.8" pbkdf2 = { version = "0.12", features = ["simple"] } text-colorizer = "1.0.0" From 5be1b83373a8f46380670c49b62c0905b4656731 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:20:56 +0100 Subject: [PATCH 093/357] chore(deps): [#122] bump lettre from 0.10.0-rc.3 to 0.10.4 --- Cargo.lock | 193 +++++++++++++++++++++++------------------------------ Cargo.toml | 2 +- 2 files changed, 84 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f46bbb72..00bacbb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,7 +45,7 @@ dependencies = [ "actix-tls", "actix-utils", "ahash", - "base64", + "base64 0.13.0", "bitflags", "brotli2", "bytes", @@ -323,6 +323,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "base64" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" + [[package]] name = "base64ct" version = "1.0.1" @@ -335,18 +341,6 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "bitvec" -version = "0.19.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -469,7 +463,7 @@ dependencies = [ "async-trait", "json5", "lazy_static", - "nom 7.0.0", + "nom", "pathdiff", "ron", "rust-ini", @@ -687,6 +681,22 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "email-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" +dependencies = [ + "base64 0.21.0", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" + [[package]] name = "encoding_rs" version = "0.8.28" @@ -808,12 +818,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "funty" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" - [[package]] name = "futures" version = "0.3.15" @@ -1113,6 +1117,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indexmap" version = "1.9.1" @@ -1211,7 +1225,7 @@ version = "8.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa4b4af834c6cfd35d8763d359661b90f2e45d8f750a0849156c7f4671af09c" dependencies = [ - "base64", + "base64 0.13.0", "pem", "ring", "serde", @@ -1236,29 +1250,31 @@ dependencies = [ [[package]] name = "lettre" -version = "0.10.0-rc.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8697ded52353bdd6fec234b3135972433397e86d0493d9fc38fbf407b7c106a" +checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d" dependencies = [ "async-trait", - "base64", + "base64 0.21.0", + "email-encoding", + "email_address", "fastrand", "futures-io", "futures-util", "hostname", "httpdate", - "idna", + "idna 0.3.0", "mime", "native-tls", - "nom 6.1.2", + "nom", "once_cell", "quoted_printable", - "r2d2", - "regex", "rustls", + "rustls-pemfile", + "socket2", "tokio", + "tokio-native-tls", "tokio-rustls", - "webpki", "webpki-roots", ] @@ -1423,18 +1439,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nom" -version = "6.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" -dependencies = [ - "bitvec", - "funty", - "memchr", - "version_check", -] - [[package]] name = "nom" version = "7.0.0" @@ -1654,7 +1658,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c64931a1a212348ec4f3b4362585eca7159d0d09cbdf4a7f74f02173596fd4" dependencies = [ - "base64", + "base64 0.13.0", ] [[package]] @@ -1808,26 +1812,9 @@ dependencies = [ [[package]] name = "quoted_printable" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1238256b09923649ec89b08104c4dfe9f6cb2fea734a5db5384e44916d59e9c5" - -[[package]] -name = "r2d2" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545c5bc2b880973c9c10e4067418407a0ccaa3091781d1671d46eb35107cb26f" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - -[[package]] -name = "radium" -version = "0.5.3" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" +checksum = "a24039f627d8285853cc90dcddf8c1ebfaa91f834566948872b225b9a28ed1b6" [[package]] name = "rand" @@ -1911,7 +1898,7 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246e9f61b9bb77df069a947682be06e31ac43ea37862e244a69f177694ea6d22" dependencies = [ - "base64", + "base64 0.13.0", "bytes", "encoding_rs", "futures-core", @@ -1962,7 +1949,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" dependencies = [ - "base64", + "base64 0.13.0", "bitflags", "serde", ] @@ -2022,15 +2009,33 @@ dependencies = [ [[package]] name = "rustls" -version = "0.19.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +checksum = "07180898a28ed6a7f7ba2311594308f595e3dd2e3c3812fa0a80a47b45f17e5d" dependencies = [ - "base64", "log", "ring", + "rustls-webpki", "sct", - "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" +dependencies = [ + "base64 0.21.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.100.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +dependencies = [ + "ring", + "untrusted", ] [[package]] @@ -2087,15 +2092,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "scheduled-thread-pool" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6f74fd1204073fa02d5d5d68bec8021be4c38690b61264b2fdb48083d0e7d7" -dependencies = [ - "parking_lot", -] - [[package]] name = "scopeguard" version = "1.1.0" @@ -2104,9 +2100,9 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "sct" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" dependencies = [ "ring", "untrusted", @@ -2308,9 +2304,9 @@ checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" [[package]] name = "socket2" -version = "0.4.1" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765f090f0e423d2b55843402a07915add955e7d60657db13707a159727326cad" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" dependencies = [ "libc", "winapi", @@ -2348,7 +2344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" dependencies = [ "itertools", - "nom 7.0.0", + "nom", "unicode_categories", ] @@ -2539,12 +2535,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tempfile" version = "3.5.0" @@ -2711,13 +2701,12 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" dependencies = [ "rustls", "tokio", - "webpki", ] [[package]] @@ -2951,7 +2940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.2.3", "matches", "percent-encoding", ] @@ -3077,23 +3066,13 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki" -version = "0.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "webpki-roots" -version = "0.21.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" +checksum = "aa54963694b65584e170cf5dc46aeb4dcaa5584e652ff5f3952e56d66aff0125" dependencies = [ - "webpki", + "rustls-webpki", ] [[package]] @@ -3279,12 +3258,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "wyz" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" - [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index 5dbb6998..449fe716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ jsonwebtoken = "8.1.1" sha-1 = "0.10.0" reqwest = { version = "0.11.4", features = [ "json", "multipart" ] } tokio = {version = "1.13", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} -lettre = { version = "0.10.0-rc.3", features = ["builder", "tokio1", "tokio1-rustls-tls", "smtp-transport"]} +lettre = { version = "0.10", features = ["builder", "tokio1", "tokio1-rustls-tls", "tokio1-native-tls", "smtp-transport"]} sailfish = "0.6" regex = "1.8" pbkdf2 = { version = "0.12", features = ["simple"] } From cfad80936877264be76db0eb02f7b205241969d4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:30:19 +0100 Subject: [PATCH 094/357] chore(deps): [#122] bump tokio from 1.13.1 to 1.28.0 --- Cargo.lock | 77 +++++++++++++++++++++++++++++++++++++++++------------- Cargo.toml | 2 +- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00bacbb5..df87cad1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ dependencies = [ "actix-utils", "futures-core", "log", - "mio", + "mio 0.7.13", "num_cpus", "slab", "tokio", @@ -307,9 +307,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base-x" @@ -868,7 +868,7 @@ checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.11.1", ] [[package]] @@ -936,7 +936,7 @@ checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.10.2+wasi-snapshot-preview1", ] [[package]] @@ -1333,10 +1333,11 @@ checksum = "84f9a2d3e27ce99ce2c3aad0b09b1a7b916293ea9b2bf624c13fe646fadd8da4" [[package]] name = "lock_api" -version = "0.4.4" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" dependencies = [ + "autocfg", "scopeguard", ] @@ -1412,6 +1413,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "mio" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.45.0", +] + [[package]] name = "miow" version = "0.3.7" @@ -1600,7 +1613,17 @@ checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" dependencies = [ "instant", "lock_api", - "parking_lot_core", + "parking_lot_core 0.8.3", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.7", ] [[package]] @@ -1617,6 +1640,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot_core" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "windows-sys 0.45.0", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -2660,33 +2696,32 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.13.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52963f91310c08d91cb7bff5786dfc8b79642ab839e188187e92105dbfb9d2c8" +checksum = "c3c786bf8134e5a3a166db9b29ab8f48134739014a3eca7bc6bfa95d673b136f" dependencies = [ "autocfg", "bytes", "libc", - "memchr", - "mio", + "mio 0.8.6", "num_cpus", - "once_cell", - "parking_lot", + "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", - "winapi", + "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "1.3.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54473be61f4ebe4efd09cec9bd5d16fa51d70ea0192213d754d2d500457db110" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 1.0.94", + "syn 2.0.15", ] [[package]] @@ -2988,6 +3023,12 @@ version = "0.10.2+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "wasm-bindgen" version = "0.2.76" diff --git a/Cargo.toml b/Cargo.toml index 449fe716..d57c18bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ chrono = "0.4.19" jsonwebtoken = "8.1.1" sha-1 = "0.10.0" reqwest = { version = "0.11.4", features = [ "json", "multipart" ] } -tokio = {version = "1.13", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} +tokio = {version = "1.28", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} lettre = { version = "0.10", features = ["builder", "tokio1", "tokio1-rustls-tls", "tokio1-native-tls", "smtp-transport"]} sailfish = "0.6" regex = "1.8" From 30bccd33810e92634e636e05e72acc3b2a713600 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:34:32 +0100 Subject: [PATCH 095/357] chore(deps): [#122] bump reqwest from 0.11.4 to 0.11.16 --- Cargo.lock | 60 +++++++++++++++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df87cad1..8bdedb37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ "log", "pin-project-lite", "tokio", - "tokio-util", + "tokio-util 0.6.7", ] [[package]] @@ -172,7 +172,7 @@ dependencies = [ "futures-core", "http", "log", - "tokio-util", + "tokio-util 0.6.7", ] [[package]] @@ -941,9 +941,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.4" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7f3675cfef6a30c8031cf9e6493ebdc3bb3272a3fea3923c4210d1830e6a472" +checksum = "17f8a914c2987b688368b5138aa05321db91f4090cf26118185672ad588bce21" dependencies = [ "bytes", "fnv", @@ -954,7 +954,7 @@ dependencies = [ "indexmap", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.8", "tracing", ] @@ -1059,9 +1059,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.5.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" [[package]] name = "httpdate" @@ -1071,9 +1071,9 @@ checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440" [[package]] name = "hyper" -version = "0.14.12" +version = "0.14.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13f67199e765030fa08fe0bd581af683f0d5bc04ea09c2b1102012c5fb90e7fd" +checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4" dependencies = [ "bytes", "futures-channel", @@ -1084,7 +1084,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa 0.4.7", + "itoa 1.0.3", "pin-project-lite", "socket2", "tokio", @@ -1436,9 +1436,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.8" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" dependencies = [ "lazy_static", "libc", @@ -1930,26 +1930,27 @@ checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" [[package]] name = "reqwest" -version = "0.11.4" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "246e9f61b9bb77df069a947682be06e31ac43ea37862e244a69f177694ea6d22" +checksum = "27b71749df584b7f4cac2c426c127a7c785a5106cc98f7a8feb044115f0fa254" dependencies = [ - "base64 0.13.0", + "base64 0.21.0", "bytes", "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "hyper", "hyper-tls", "ipnet", "js-sys", - "lazy_static", "log", "mime", "mime_guess", "native-tls", + "once_cell", "percent-encoding", "pin-project-lite", "serde", @@ -1957,6 +1958,7 @@ dependencies = [ "serde_urlencoded", "tokio", "tokio-native-tls", + "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", @@ -2243,12 +2245,12 @@ dependencies = [ [[package]] name = "serde_urlencoded" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 0.4.7", + "itoa 1.0.3", "ryu", "serde", ] @@ -2769,6 +2771,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + [[package]] name = "toml" version = "0.5.8" @@ -3036,8 +3052,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce9b1b516211d33767048e5d47fa2a381ed8b76fc48d2ce4aa39877f9f183e0" dependencies = [ "cfg-if", - "serde", - "serde_json", "wasm-bindgen-macro", ] @@ -3292,9 +3306,9 @@ dependencies = [ [[package]] name = "winreg" -version = "0.7.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] diff --git a/Cargo.toml b/Cargo.toml index d57c18bf..15bcd996 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rand_core = { version = "0.6", features = ["std"] } chrono = "0.4.19" jsonwebtoken = "8.1.1" sha-1 = "0.10.0" -reqwest = { version = "0.11.4", features = [ "json", "multipart" ] } +reqwest = { version = "0.11", features = [ "json", "multipart" ] } tokio = {version = "1.28", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} lettre = { version = "0.10", features = ["builder", "tokio1", "tokio1-rustls-tls", "tokio1-native-tls", "smtp-transport"]} sailfish = "0.6" From 9de32254e4dcb0e4bdb2640ff3f41a81d95fd912 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:35:46 +0100 Subject: [PATCH 096/357] chore(deps): [#122] bump sha-1 from 0.10.0 to 0.10.1 --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8bdedb37..0c250f48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2270,9 +2270,9 @@ dependencies = [ [[package]] name = "sha-1" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", "cpufeatures", @@ -2434,7 +2434,7 @@ dependencies = [ "percent-encoding", "rand", "rsa", - "sha-1 0.10.0", + "sha-1 0.10.1", "sha2", "smallvec", "sqlformat", @@ -2856,7 +2856,7 @@ dependencies = [ "serde_bytes", "serde_derive", "serde_json", - "sha-1 0.10.0", + "sha-1 0.10.1", "sqlx", "tempfile", "text-colorizer", diff --git a/Cargo.toml b/Cargo.toml index 15bcd996..28b412a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ argon2 = "0.5" rand_core = { version = "0.6", features = ["std"] } chrono = "0.4.19" jsonwebtoken = "8.1.1" -sha-1 = "0.10.0" +sha-1 = "0.10" reqwest = { version = "0.11", features = [ "json", "multipart" ] } tokio = {version = "1.28", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} lettre = { version = "0.10", features = ["builder", "tokio1", "tokio1-rustls-tls", "tokio1-native-tls", "smtp-transport"]} From 6a1a96588192e62e38617d387b511dd1d4bc9fcf Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:39:13 +0100 Subject: [PATCH 097/357] chore(deps): [#122] bump jsonwebtoken from 8.1.1 to 8.3.0 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c250f48..4cd3d2e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1221,11 +1221,11 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "8.1.1" +version = "8.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa4b4af834c6cfd35d8763d359661b90f2e45d8f750a0849156c7f4671af09c" +checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ - "base64 0.13.0", + "base64 0.21.0", "pem", "ring", "serde", diff --git a/Cargo.toml b/Cargo.toml index 28b412a8..27ecbde1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ urlencoding = "2.1.0" argon2 = "0.5" rand_core = { version = "0.6", features = ["std"] } chrono = "0.4.19" -jsonwebtoken = "8.1.1" +jsonwebtoken = "8.3" sha-1 = "0.10" reqwest = { version = "0.11", features = [ "json", "multipart" ] } tokio = {version = "1.28", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} From 84cde9b887628accda911b8913e5c9938eec59f8 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:43:07 +0100 Subject: [PATCH 098/357] chore(deps): [#122] bump chrono from 0.4.19 to 0.4.24 --- Cargo.lock | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 +- 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4cd3d2e7..ab560f05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "argon2" version = "0.5.0" @@ -432,17 +441,29 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ - "libc", + "iana-time-zone", + "js-sys", "num-integer", "num-traits", "time 0.1.43", + "wasm-bindgen", "winapi", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "colored" version = "2.0.0" @@ -514,9 +535,9 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" @@ -591,6 +612,50 @@ dependencies = [ "typenum", ] +[[package]] +name = "cxx" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f61f1b6389c3fe1c316bf8a4dccc90a38208354b330925bce1f74a6c4756eb93" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cee708e8962df2aeb38f594aae5d827c022b6460ac71a7a3e2c3c2aae5a07b" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn 2.0.15", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7944172ae7e4068c533afbb984114a56c46e9ccddda550499caa222902c7f7bb" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "der" version = "0.5.1" @@ -1106,6 +1171,30 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "idna" version = "0.2.3" @@ -1301,6 +1390,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-cplusplus" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" +dependencies = [ + "cc", +] + [[package]] name = "linked-hash-map" version = "0.5.4" @@ -2136,6 +2234,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "scratch" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" + [[package]] name = "sct" version = "0.7.0" @@ -2586,6 +2690,15 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + [[package]] name = "text-colorizer" version = "1.0.0" @@ -2966,6 +3079,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "unicode-xid" version = "0.2.2" @@ -3157,12 +3276,30 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.0", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 27ecbde1..2142f9d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ serde_bytes = "0.11" urlencoding = "2.1.0" argon2 = "0.5" rand_core = { version = "0.6", features = ["std"] } -chrono = "0.4.19" +chrono = "0.4" jsonwebtoken = "8.3" sha-1 = "0.10" reqwest = { version = "0.11", features = [ "json", "multipart" ] } From a420a829087d36e3aea2d71f8a036885e6cc2d5a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:47:04 +0100 Subject: [PATCH 099/357] chore(deps): [#122] bump urlencoding from 2.1.0 to 2.1.2 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab560f05..9e7d6488 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3117,9 +3117,9 @@ dependencies = [ [[package]] name = "urlencoding" -version = "2.1.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821" +checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" [[package]] name = "uuid" diff --git a/Cargo.toml b/Cargo.toml index 2142f9d8..e41bdca9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ serde_derive = "1" serde_json = "1" serde_bencode = "0.2.3" serde_bytes = "0.11" -urlencoding = "2.1.0" +urlencoding = "2.1" argon2 = "0.5" rand_core = { version = "0.6", features = ["std"] } chrono = "0.4" From 45846aa224e5ad3a89495b85fe752214b0d20edd Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:50:30 +0100 Subject: [PATCH 100/357] chore(deps): [#122] bump serde_bytes from 0.11.5 to v0.11.9 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e7d6488..1b4755f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2309,9 +2309,9 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.5" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ae07dd2f88a366f15bd0632ba725227018c69a1c8550a927324f8eb8368bb9" +checksum = "416bda436f9aab92e02c8e10d49a15ddd339cea90b6e340fe51ed97abb548294" dependencies = [ "serde", ] From e4bfc09d098b26ba1af79c3155b570ebde3b8282 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:53:09 +0100 Subject: [PATCH 101/357] chore(deps): [#122] bump serde_json from 1.0.64 to v1.0.96 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b4755f3..0a3b9a5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2329,11 +2329,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.64" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" dependencies = [ - "itoa 0.4.7", + "itoa 1.0.3", "ryu", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index e41bdca9..3455fe74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ toml = "0.5" derive_more = "0.99" serde = { version = "1.0", features = ["rc"] } serde_derive = "1" -serde_json = "1" +serde_json = "1.0" serde_bencode = "0.2.3" serde_bytes = "0.11" urlencoding = "2.1" From b45f6fb20bed6c303bf72dc8270e79d1b6110347 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:56:34 +0100 Subject: [PATCH 102/357] chore(deps): [#122] bump derive_more from 0.99.14 to v0.99.17 --- Cargo.lock | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a3b9a5b..5d440721 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,13 +669,14 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.14" +version = "0.99.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc7b9cef1e351660e5443924e4f43ab25fbbed3e9a5f052df3677deb4d6b320" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" dependencies = [ "convert_case", "proc-macro2", "quote", + "rustc_version 0.4.0", "syn 1.0.94", ] @@ -2126,7 +2127,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "semver", + "semver 0.9.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver 1.0.17", ] [[package]] @@ -2282,6 +2292,12 @@ dependencies = [ "semver-parser", ] +[[package]] +name = "semver" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" + [[package]] name = "semver-parser" version = "0.7.0" @@ -2597,7 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" dependencies = [ "discard", - "rustc_version", + "rustc_version 0.2.3", "stdweb-derive", "stdweb-internal-macros", "stdweb-internal-runtime", From ec9fee776c4e507cbc6f63e7f516c29570a3bd32 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 09:59:01 +0100 Subject: [PATCH 103/357] chore(deps): [#122] bump toml from 0.5.8 to 0.7.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d440721..919aff3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2990,7 +2990,7 @@ dependencies = [ "tempfile", "text-colorizer", "tokio", - "toml 0.5.8", + "toml 0.7.3", "urlencoding", "uuid", "which", diff --git a/Cargo.toml b/Cargo.toml index 3455fe74..72fe5c02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ async-trait = "0.1.52" futures = "0.3.5" sqlx = { version = "0.6.1", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } config = "0.13" -toml = "0.5" +toml = "0.7" derive_more = "0.99" serde = { version = "1.0", features = ["rc"] } serde_derive = "1" From cd1a9ec0ef10450532987ec406b5c8d36067e225 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 10:01:13 +0100 Subject: [PATCH 104/357] chore(deps): [#122] bump sqlx from 0.6.1 to 0.6.3 --- Cargo.lock | 62 ++++++++++++++++++++++++------------------------------ Cargo.toml | 2 +- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 919aff3d..781a6f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2f86cd6857c135e6e9fe57b1619a88d1f94a7df34c00e11fe13e64fd3438837" dependencies = [ "quote", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -234,7 +234,7 @@ checksum = "0d048c6986743105c1e8e9729fbc8d5d1667f2f62393a58be8d85a7d9a5a6c8d" dependencies = [ "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -291,7 +291,7 @@ checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3" dependencies = [ "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -677,7 +677,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.0", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -951,7 +951,7 @@ checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17" dependencies = [ "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -1841,7 +1841,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -1872,7 +1872,7 @@ checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" dependencies = [ "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -2497,9 +2497,9 @@ dependencies = [ [[package]] name = "sqlformat" -version = "0.1.8" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" +checksum = "0c12bc9199d1db8234678b7051747c07f517cdcf019262d1847b94ec8b1aee3e" dependencies = [ "itertools", "nom", @@ -2508,9 +2508,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788841def501aabde58d3666fcea11351ec3962e6ea75dbcd05c84a71d68bcd1" +checksum = "f8de3b03a925878ed54a954f621e64bf55a3c1bd29652d0d1a17830405350188" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2518,9 +2518,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c21d3b5e7cadfe9ba7cdc1295f72cc556c750b4419c27c219c0693198901f8e" +checksum = "fa8241483a83a3f33aa5fff7e7d9def398ff9990b2752b6c6112b83c6d246029" dependencies = [ "ahash", "atoi", @@ -2554,7 +2554,7 @@ dependencies = [ "percent-encoding", "rand", "rsa", - "sha-1 0.10.1", + "sha1 0.10.4", "sha2", "smallvec", "sqlformat", @@ -2568,9 +2568,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4adfd2df3557bddd3b91377fc7893e8fa899e9b4061737cbade4e1bb85f1b45c" +checksum = "9966e64ae989e7e575b19d7265cb79d7fc3cbbdf179835cb0d716f294c2049c9" dependencies = [ "dotenvy", "either", @@ -2581,15 +2581,15 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-rt", - "syn 1.0.94", + "syn 1.0.109", "url", ] [[package]] name = "sqlx-rt" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be52fc7c96c136cedea840ed54f7d446ff31ad670c9dea95ebcb998530971a3" +checksum = "804d3f245f894e61b1e6263c84b23ca675d96753b5abfd5cc8597d86806e8024" dependencies = [ "native-tls", "once_cell", @@ -2630,7 +2630,7 @@ dependencies = [ "quote", "serde", "serde_derive", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -2646,7 +2646,7 @@ dependencies = [ "serde_derive", "serde_json", "sha1 0.6.0", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -2673,13 +2673,13 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.94" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07e33e919ebcd69113d5be0e4d70c5707004ff45188910106854f38b960df4a" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] @@ -2741,7 +2741,7 @@ checksum = "e8f2591983642de85c921015f3f070c665a197ed69e417af436115e3a1407487" dependencies = [ "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -2807,7 +2807,7 @@ dependencies = [ "proc-macro2", "quote", "standback", - "syn 1.0.94", + "syn 1.0.109", ] [[package]] @@ -3101,12 +3101,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" -[[package]] -name = "unicode-xid" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -3201,7 +3195,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", "wasm-bindgen-shared", ] @@ -3235,7 +3229,7 @@ checksum = "0195807922713af1e67dc66132c7328206ed9766af3858164fb583eedc25fbad" dependencies = [ "proc-macro2", "quote", - "syn 1.0.94", + "syn 1.0.109", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index 72fe5c02..8fd538f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ actix-multipart = "0.4.0-beta.5" actix-cors = "0.6.0-beta.2" async-trait = "0.1.52" futures = "0.3.5" -sqlx = { version = "0.6.1", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } +sqlx = { version = "0.6", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } config = "0.13" toml = "0.7" derive_more = "0.99" From e4c19c1b2fb38774e764764d00f43e1bb5f9dca4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 10:02:58 +0100 Subject: [PATCH 105/357] chore(deps): [#122] bump futures from 0.3.15 to 0.3.24 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 781a6f8f..d847b360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,9 +886,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.15" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" +checksum = "7f21eda599937fba36daeb58a22e8f5cee2d14c4a17b5b7739c7c8e5e3b8230c" dependencies = [ "futures-channel", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index 8fd538f0..ce53dbe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ actix-web = "4.0.0-beta.8" actix-multipart = "0.4.0-beta.5" actix-cors = "0.6.0-beta.2" async-trait = "0.1.52" -futures = "0.3.5" +futures = "0.3" sqlx = { version = "0.6", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } config = "0.13" toml = "0.7" From 9174a0cc9428d4b835f9f557259ea81392ee3cfe Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 10:04:44 +0100 Subject: [PATCH 106/357] chore(deps): [#122] bump async-trait from 0.1.52 to 0.1.68 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d847b360..3136ab3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,13 +285,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.52" +version = "0.1.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3" +checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.15", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ce53dbe3..692a79c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ e2e-tests = [] actix-web = "4.0.0-beta.8" actix-multipart = "0.4.0-beta.5" actix-cors = "0.6.0-beta.2" -async-trait = "0.1.52" +async-trait = "0.1" futures = "0.3" sqlx = { version = "0.6", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } config = "0.13" From 488601d9e882bc36278b3860caa373889a537e10 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 11:44:11 +0100 Subject: [PATCH 107/357] chore(deps): [#122] bump actix-cors, actix-multipart, actix-web actix-cors 0.6.0-beta.2 -> 0.6.4 actix-multipart 0.4.0-beta.5 -> 0.6.0 actix-web 4.0.0-beta.8 -> 4.3.1 --- Cargo.lock | 589 +++++++++++++++--------------------------- Cargo.toml | 6 +- src/routes/torrent.rs | 9 +- 3 files changed, 216 insertions(+), 388 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3136ab3c..e2af8483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,27 +4,28 @@ version = 3 [[package]] name = "actix-codec" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d5dbeb2d9e51344cb83ca7cc170f1217f9fe25bfc50160e6e200b5c31c1019a" +checksum = "57a7559404a7f3573127aab53c08ce37a6c6a315c374a31070f3c91cd1b4a7fe" dependencies = [ "bitflags", "bytes", "futures-core", "futures-sink", "log", + "memchr", "pin-project-lite", "tokio", - "tokio-util 0.6.7", + "tokio-util", ] [[package]] name = "actix-cors" -version = "0.6.0-beta.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01552b8facccd5d7a4cc5d8e2b07d306160c97a4968181c2db965533389c8725" +checksum = "b340e9cfa5b08690aae90fb61beb44e9b06f44fe3d0f93781aaa58cfba86245e" dependencies = [ - "actix-service", + "actix-utils", "actix-web", "derive_more", "futures-util", @@ -35,53 +36,48 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.0.0-beta.9" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01260589f1aafad11224002741eb37bc603b4ce55b4e3556d2b2122f9aac7c51" +checksum = "c2079246596c18b4a33e274ae10c0e50613f4d32a4198e09c7b93771013fed74" dependencies = [ "actix-codec", "actix-rt", "actix-service", - "actix-tls", "actix-utils", - "ahash", - "base64 0.13.0", + "ahash 0.8.3", + "base64 0.21.0", "bitflags", - "brotli2", + "brotli", "bytes", "bytestring", "derive_more", "encoding_rs", "flate2", "futures-core", - "futures-util", "h2", "http", "httparse", - "itoa 0.4.7", + "httpdate", + "itoa", "language-tags", "local-channel", - "log", "mime", - "once_cell", "percent-encoding", - "pin-project", "pin-project-lite", "rand", - "regex", - "serde", - "sha-1 0.9.8", + "sha1", "smallvec", - "time 0.2.27", "tokio", + "tokio-util", + "tracing", "zstd", ] [[package]] name = "actix-macros" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2f86cd6857c135e6e9fe57b1619a88d1f94a7df34c00e11fe13e64fd3438837" +checksum = "465a6172cf69b960917811022d8f29bc0b7fa1398bc4f78b3c466673db1213b6" dependencies = [ "quote", "syn 1.0.109", @@ -89,10 +85,11 @@ dependencies = [ [[package]] name = "actix-multipart" -version = "0.4.0-beta.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a32d8964e147f1e411b38cd08a28eb37915be6797191a394fe0ad73f36441a99" +checksum = "dee489e3c01eae4d1c35b03c4493f71cb40d93f66b14558feb1b1a807671cc4e" dependencies = [ + "actix-multipart-derive", "actix-utils", "actix-web", "bytes", @@ -102,49 +99,67 @@ dependencies = [ "httparse", "local-waker", "log", + "memchr", "mime", - "twoway", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec592f234db8a253cf80531246a4407c8a70530423eea80688a6c5a44a110e7" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] name = "actix-router" -version = "0.2.7" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad299af73649e1fc893e333ccf86f377751eb95ff875d095131574c6f43452c" +checksum = "d66ff4d247d2b160861fa2866457e85706833527840e4133f8f49aa423a38799" dependencies = [ "bytestring", "http", - "log", "regex", "serde", + "tracing", ] [[package]] name = "actix-rt" -version = "2.2.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d7cd957c9ed92288a7c3c96af81fa5291f65247a76a34dac7b6af74e52ba0" +checksum = "15265b6b8e2347670eb363c47fc8c75208b4a4994b27192f345fcbe707804f3e" dependencies = [ - "actix-macros", "futures-core", "tokio", ] [[package]] name = "actix-server" -version = "2.0.0-beta.5" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26369215fcc3b0176018b3b68756a8bcc275bb000e6212e454944913a1f9bf87" +checksum = "3e8613a75dd50cc45f473cee3c34d59ed677c0f7b44480ce3b8247d7dc519327" dependencies = [ "actix-rt", "actix-service", "actix-utils", "futures-core", - "log", - "mio 0.7.13", + "futures-util", + "mio", "num_cpus", - "slab", + "socket2", "tokio", + "tracing", ] [[package]] @@ -158,23 +173,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "actix-tls" -version = "3.0.0-beta.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65b7bb60840962ef0332f7ea01a57d73a24d2cb663708511ff800250bbfef569" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "derive_more", - "futures-core", - "http", - "log", - "tokio-util 0.6.7", -] - [[package]] name = "actix-utils" version = "3.0.0" @@ -187,9 +185,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.0.0-beta.8" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c503f726f895e55dac39adeafd14b5ee00cc956796314e9227fc7ae2e176f443" +checksum = "cd3cb42f9566ab176e1ef0b8b3a896529062b4efc6be0123046095914c4c1c96" dependencies = [ "actix-codec", "actix-http", @@ -200,38 +198,39 @@ dependencies = [ "actix-service", "actix-utils", "actix-web-codegen", - "ahash", + "ahash 0.7.6", "bytes", + "bytestring", "cfg-if", "cookie", "derive_more", - "either", "encoding_rs", "futures-core", "futures-util", - "itoa 0.4.7", + "http", + "itoa", "language-tags", "log", "mime", "once_cell", - "paste", - "pin-project", + "pin-project-lite", "regex", "serde", "serde_json", "serde_urlencoded", "smallvec", "socket2", - "time 0.2.27", + "time 0.3.14", "url", ] [[package]] name = "actix-web-codegen" -version = "0.5.0-beta.3" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d048c6986743105c1e8e9729fbc8d5d1667f2f62393a58be8d85a7d9a5a6c8d" +checksum = "2262160a7ae29e3415554a3f1fc04c764b1540c116aa524683208078b7a75bc9" dependencies = [ + "actix-router", "proc-macro2", "quote", "syn 1.0.109", @@ -254,6 +253,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "1.0.1" @@ -263,6 +274,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -320,12 +346,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" -[[package]] -name = "base-x" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4521f3e3d031370679b3b140beb36dfe4801b09ac77e30c61941f97df3ef28b" - [[package]] name = "base64" version = "0.13.0" @@ -356,16 +376,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.6", -] - -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array", + "digest", ] [[package]] @@ -378,23 +389,24 @@ dependencies = [ ] [[package]] -name = "brotli-sys" -version = "0.3.2" +name = "brotli" +version = "3.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4445dea95f4c2b41cde57cc9fee236ae4dbae88d8fcbdb4750fc1bb5d86aaecd" +checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" dependencies = [ - "cc", - "libc", + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", ] [[package]] -name = "brotli2" -version = "0.3.2" +name = "brotli-decompressor" +version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cb036c3eade309815c15ddbacec5b22c4d1f3983a774ab2eac2e3e9ea85568e" +checksum = "4b6561fd3f895a11e8f72af2cb7d22e08366bebc2b6b57f7744c4bda27034744" dependencies = [ - "brotli-sys", - "libc", + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -500,12 +512,6 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" -[[package]] -name = "const_fn" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92cfa0fd5690b3cf8c1ef2cabbd9b7ef22fa53cf5e1f92b05103f6d5d1cf6e7" - [[package]] name = "convert_case" version = "0.4.0" @@ -514,12 +520,12 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] name = "cookie" -version = "0.15.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f1c7727e460397e56abc4bddc1d49e07a1ad78fc98eb2e1c8f032a58a2f80d" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ "percent-encoding", - "time 0.2.27", + "time 0.3.14", "version_check", ] @@ -656,6 +662,41 @@ dependencies = [ "syn 2.0.15", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "der" version = "0.5.1" @@ -676,26 +717,17 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "rustc_version 0.4.0", + "rustc_version", "syn 1.0.109", ] -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - [[package]] name = "digest" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" dependencies = [ - "block-buffer 0.10.2", + "block-buffer", "crypto-common", "subtle", ] @@ -720,12 +752,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "discard" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" - [[package]] name = "dlv-list" version = "0.3.0" @@ -996,13 +1022,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.3" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" dependencies = [ "cfg-if", "libc", - "wasi 0.10.2+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1020,7 +1046,7 @@ dependencies = [ "indexmap", "slab", "tokio", - "tokio-util 0.7.8", + "tokio-util", "tracing", ] @@ -1030,7 +1056,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.6", ] [[package]] @@ -1078,7 +1104,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.6", + "digest", ] [[package]] @@ -1103,13 +1129,13 @@ dependencies = [ [[package]] name = "http" -version = "0.2.4" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" dependencies = [ "bytes", "fnv", - "itoa 0.4.7", + "itoa", ] [[package]] @@ -1150,7 +1176,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa 1.0.3", + "itoa", "pin-project-lite", "socket2", "tokio", @@ -1196,6 +1222,12 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -1262,12 +1294,6 @@ dependencies = [ "either", ] -[[package]] -name = "itoa" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" - [[package]] name = "itoa" version = "1.0.3" @@ -1499,19 +1525,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mio" -version = "0.7.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" -dependencies = [ - "libc", - "log", - "miow", - "ntapi", - "winapi", -] - [[package]] name = "mio" version = "0.8.6" @@ -1520,19 +1533,10 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.45.0", ] -[[package]] -name = "miow" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" -dependencies = [ - "winapi", -] - [[package]] name = "native-tls" version = "0.2.11" @@ -1562,15 +1566,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "ntapi" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" -dependencies = [ - "winapi", -] - [[package]] name = "num-bigint" version = "0.4.3" @@ -1655,12 +1650,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0" -[[package]] -name = "opaque-debug" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" - [[package]] name = "openssl" version = "0.10.36" @@ -1752,6 +1741,12 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "parse-size" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae" + [[package]] name = "password-hash" version = "0.5.0" @@ -1781,7 +1776,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0ca0b5a68607598bf3bad68f32227a8164f6254833f84eafaac409cd6746c31" dependencies = [ - "digest 0.10.6", + "digest", "hmac", "password-hash", "sha2", @@ -1852,7 +1847,7 @@ checksum = "4c8717927f9b79515e565a64fe46c38b8cd0427e64c40680b14a7365ab09ac8d" dependencies = [ "once_cell", "pest", - "sha1 0.10.4", + "sha1", ] [[package]] @@ -1921,12 +1916,6 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" -[[package]] -name = "proc-macro-hack" -version = "0.5.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" - [[package]] name = "proc-macro2" version = "1.0.56" @@ -2098,7 +2087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cf22754c49613d2b3b119f0e5d46e34a2c628a937e3024b8762de4e7d8c710b" dependencies = [ "byteorder", - "digest 0.10.6", + "digest", "num-bigint-dig", "num-integer", "num-iter", @@ -2121,22 +2110,13 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] - [[package]] name = "rustc_version" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" dependencies = [ - "semver 1.0.17", + "semver", ] [[package]] @@ -2283,27 +2263,12 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "serde" version = "1.0.160" @@ -2349,11 +2314,20 @@ version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" dependencies = [ - "itoa 1.0.3", + "itoa", "ryu", "serde", ] +[[package]] +name = "serde_plain" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6018081315db179d0ce57b1fe4b62a12a0028c9cf9bbef868c9cf477b3c34ae" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.1" @@ -2370,24 +2344,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.3", + "itoa", "ryu", "serde", ] -[[package]] -name = "sha-1" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha-1" version = "0.10.1" @@ -2396,15 +2357,9 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.6", + "digest", ] -[[package]] -name = "sha1" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d" - [[package]] name = "sha1" version = "0.10.4" @@ -2413,7 +2368,7 @@ checksum = "006769ba83e921b3085caa8334186b00cf92b4cb1a6cf4632fbccc8eff5c7549" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.6", + "digest", ] [[package]] @@ -2424,7 +2379,7 @@ checksum = "cf9db03534dff993187064c4e0c05a5708d2a9728ace9a8959b77bedf415dac5" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.6", + "digest", ] [[package]] @@ -2522,14 +2477,14 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa8241483a83a3f33aa5fff7e7d9def398ff9990b2752b6c6112b83c6d246029" dependencies = [ - "ahash", + "ahash 0.7.6", "atoi", "bitflags", "byteorder", "bytes", "crc", "crossbeam-queue", - "digest 0.10.6", + "digest", "dotenvy", "either", "event-listener", @@ -2543,7 +2498,7 @@ dependencies = [ "hashlink", "hex", "indexmap", - "itoa 1.0.3", + "itoa", "libc", "libsqlite3-sys", "log", @@ -2554,7 +2509,7 @@ dependencies = [ "percent-encoding", "rand", "rsa", - "sha1 0.10.4", + "sha1", "sha2", "smallvec", "sqlformat", @@ -2597,64 +2552,6 @@ dependencies = [ "tokio-native-tls", ] -[[package]] -name = "standback" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" -dependencies = [ - "version_check", -] - -[[package]] -name = "stdweb" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" -dependencies = [ - "discard", - "rustc_version 0.2.3", - "stdweb-derive", - "stdweb-internal-macros", - "stdweb-internal-runtime", - "wasm-bindgen", -] - -[[package]] -name = "stdweb-derive" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_derive", - "syn 1.0.109", -] - -[[package]] -name = "stdweb-internal-macros" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" -dependencies = [ - "base-x", - "proc-macro2", - "quote", - "serde", - "serde_derive", - "serde_json", - "sha1 0.6.0", - "syn 1.0.109", -] - -[[package]] -name = "stdweb-internal-runtime" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" - [[package]] name = "stringprep" version = "0.1.2" @@ -2665,6 +2562,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.4.1" @@ -2754,41 +2657,16 @@ dependencies = [ "winapi", ] -[[package]] -name = "time" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" -dependencies = [ - "const_fn", - "libc", - "standback", - "stdweb", - "time-macros 0.1.1", - "version_check", - "winapi", -] - [[package]] name = "time" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" dependencies = [ - "itoa 1.0.3", + "itoa", "libc", "num_threads", - "time-macros 0.2.4", -] - -[[package]] -name = "time-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" -dependencies = [ - "proc-macro-hack", - "time-macros-impl", + "time-macros", ] [[package]] @@ -2797,19 +2675,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" -[[package]] -name = "time-macros-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" -dependencies = [ - "proc-macro-hack", - "proc-macro2", - "quote", - "standback", - "syn 1.0.109", -] - [[package]] name = "tinyvec" version = "1.2.0" @@ -2834,7 +2699,7 @@ dependencies = [ "autocfg", "bytes", "libc", - "mio 0.8.6", + "mio", "num_cpus", "parking_lot 0.12.1", "pin-project-lite", @@ -2886,20 +2751,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-util" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1caa0b0c8d94a049db56b5acf8cba99dc0623aab1b26d5b5f5e2d945846b3592" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "log", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.8" @@ -2985,7 +2836,7 @@ dependencies = [ "serde_bytes", "serde_derive", "serde_json", - "sha-1 0.10.1", + "sha-1", "sqlx", "tempfile", "text-colorizer", @@ -3004,22 +2855,23 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" [[package]] name = "tracing" -version = "0.1.26" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d" +checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" dependencies = [ "cfg-if", + "log", "pin-project-lite", "tracing-core", ] [[package]] name = "tracing-core" -version = "0.1.18" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ff14f98b1a4b289c6248a023c1c2fa1491062964e9fed67ab29c4e4da4a052" +checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" dependencies = [ - "lazy_static", + "once_cell", ] [[package]] @@ -3028,16 +2880,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" -[[package]] -name = "twoway" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47" -dependencies = [ - "memchr", - "unchecked-index", -] - [[package]] name = "typenum" version = "1.15.0" @@ -3050,12 +2892,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" -[[package]] -name = "unchecked-index" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" - [[package]] name = "unicase" version = "2.6.0" @@ -3162,12 +2998,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -3477,18 +3307,18 @@ checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" [[package]] name = "zstd" -version = "0.7.0+zstd.1.4.9" +version = "0.12.3+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9428752481d8372e15b1bf779ea518a179ad6c771cca2d2c60e4fbff3cc2cd52" +checksum = "76eea132fb024e0e13fd9c2f5d5d595d8a967aa72382ac2f9d39fcc95afd0806" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "3.1.0+zstd.1.4.9" +version = "6.0.5+zstd.1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa1926623ad7fe406e090555387daf73db555b948134b4d73eac5eb08fb666d" +checksum = "d56d9e60b4b1758206c238a10165fbcae3ca37b01744e394c463463f6529d23b" dependencies = [ "libc", "zstd-sys", @@ -3496,10 +3326,11 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "1.5.0+zstd.1.4.9" +version = "2.0.8+zstd.1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6c094340240369025fc6b731b054ee2a834328fa584310ac96aa4baebdc465" +checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" dependencies = [ "cc", "libc", + "pkg-config", ] diff --git a/Cargo.toml b/Cargo.toml index 692a79c7..ddcfdd63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,9 @@ opt-level = 3 e2e-tests = [] [dependencies] -actix-web = "4.0.0-beta.8" -actix-multipart = "0.4.0-beta.5" -actix-cors = "0.6.0-beta.2" +actix-web = "4.3" +actix-multipart = "0.6" +actix-cors = "0.6" async-trait = "0.1" futures = "0.3" sqlx = { version = "0.6", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index c58c5599..51dfe2dd 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -340,10 +340,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let data = field.next().await; if data.is_none() { @@ -352,7 +349,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result title = parsed_data.to_string(), "description" => description = parsed_data.to_string(), "category" => category = parsed_data.to_string(), @@ -360,7 +357,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { - if *field.content_type() != "application/x-bittorrent" { + if *field.content_type().unwrap() != "application/x-bittorrent" { return Err(ServiceError::InvalidFileType); } From b77de22ef30c9ec365c6c81d849783e2534d5897 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 11:49:06 +0100 Subject: [PATCH 108/357] chore(deps): [#122] cargo udpate --- Cargo.lock | 612 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 10 +- 2 files changed, 302 insertions(+), 320 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2af8483..9aa635dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,9 +164,9 @@ dependencies = [ [[package]] name = "actix-service" -version = "2.0.0" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77f5f9d66a8730d0fae62c26f3424f5751e5518086628a40b7ab6fca4a705034" +checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" dependencies = [ "futures-core", "paste", @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "actix-utils" -version = "3.0.0" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e491cbaac2e7fc788dfff99ff48ef317e23b3cf63dbaf7aaab6418f40f92aa94" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" dependencies = [ "local-waker", "pin-project-lite", @@ -220,7 +220,7 @@ dependencies = [ "serde_urlencoded", "smallvec", "socket2", - "time 0.3.14", + "time 0.3.20", "url", ] @@ -348,9 +348,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base64" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" @@ -360,9 +360,9 @@ checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" [[package]] name = "base64ct" -version = "1.0.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a32fd6af2b5827bce66c29053ba0e7c42b9dcab01835835058558c10851a46b" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bitflags" @@ -381,9 +381,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.10.2" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] @@ -411,9 +411,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.7.0" +version = "3.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" +checksum = "9b1ce199063694f33ffb7dd4e0ee620741495c32833cde5aa08f02a0bf96f0c8" [[package]] name = "byteorder" @@ -423,24 +423,24 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.2.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" [[package]] name = "bytestring" -version = "1.0.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90706ba19e97b90786e19dc0d5e2abd80008d99d4c0c5d1ad0b5e72cec7c494d" +checksum = "238e4886760d98c4f899360c834fa93e62cf7f721ac3c2da375cbdf4b8679aae" dependencies = [ "bytes", ] [[package]] name = "cc" -version = "1.0.68" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" dependencies = [ "jobserver", ] @@ -461,7 +461,7 @@ dependencies = [ "js-sys", "num-integer", "num-traits", - "time 0.1.43", + "time 0.1.45", "wasm-bindgen", "winapi", ] @@ -502,7 +502,7 @@ dependencies = [ "rust-ini", "serde", "serde_json", - "toml 0.5.8", + "toml 0.5.11", "yaml-rust", ] @@ -525,15 +525,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ "percent-encoding", - "time 0.3.14", + "time 0.3.20", "version_check", ] [[package]] name = "core-foundation" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a89e2ae426ea83155dccf10c0fa6b1463ef6d5fcb44cee0b224a408fa640a62" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" dependencies = [ "core-foundation-sys", "libc", @@ -547,42 +547,42 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" -version = "0.2.1" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" dependencies = [ "libc", ] [[package]] name = "crc" -version = "3.0.0" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff" +checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" [[package]] name = "crc32fast" -version = "1.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-queue" -version = "0.3.2" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b10ddc024425c88c2ad148c1b0fd53f4c6d38db9697c9f1588381212fa657c9" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" dependencies = [ "cfg-if", "crossbeam-utils", @@ -590,12 +590,11 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.11" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc" +checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b" dependencies = [ "cfg-if", - "once_cell", ] [[package]] @@ -732,26 +731,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - [[package]] name = "dlv-list" version = "0.3.0" @@ -760,18 +739,15 @@ checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" [[package]] name = "dotenvy" -version = "0.15.3" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3db6fcad7c1fc4abdd99bf5276a4db30d6a819127903a709ed41e5ff016e84" -dependencies = [ - "dirs", -] +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.6.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" [[package]] name = "email-encoding" @@ -791,9 +767,9 @@ checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" [[package]] name = "encoding_rs" -version = "0.8.28" +version = "0.8.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80df024fbc5ac80f87dfef0d9f5209a252f2a497f7f42944cff24d8253cac065" +checksum = "071a31f4ee85403370b58aca746f01041ede6f0da2730960ad001edc2b71b394" dependencies = [ "cfg-if", ] @@ -857,13 +833,11 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.20" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3aec53de10fe96d7d8c565eb17f2c687bb5518a2ec453b5b1252964526abe0" +checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" dependencies = [ - "cfg-if", "crc32fast", - "libc", "miniz_oxide", ] @@ -876,7 +850,7 @@ dependencies = [ "futures-core", "futures-sink", "pin-project", - "spin 0.9.4", + "spin 0.9.8", ] [[package]] @@ -902,19 +876,18 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" dependencies = [ - "matches", "percent-encoding", ] [[package]] name = "futures" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f21eda599937fba36daeb58a22e8f5cee2d14c4a17b5b7739c7c8e5e3b8230c" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" dependencies = [ "futures-channel", "futures-core", @@ -927,9 +900,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bdd20c28fadd505d0fd6712cdfcb0d4b5648baf45faef7f852afb2399bb050" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" dependencies = [ "futures-core", "futures-sink", @@ -937,15 +910,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" [[package]] name = "futures-executor" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" dependencies = [ "futures-core", "futures-task", @@ -954,49 +927,49 @@ dependencies = [ [[package]] name = "futures-intrusive" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e" +checksum = "a604f7a68fbf8103337523b1fadc8ade7361ee3f112f7c680ad179651616aed5" dependencies = [ "futures-core", "lock_api", - "parking_lot 0.11.1", + "parking_lot 0.11.2", ] [[package]] name = "futures-io" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" [[package]] name = "futures-macro" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.15", ] [[package]] name = "futures-sink" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b20ba5a92e727ba30e72834706623d94ac93a725410b6a6b6fbc1b07f7ba56" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" [[package]] name = "futures-task" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" [[package]] name = "futures-util" -version = "0.3.24" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" dependencies = [ "futures-channel", "futures-core", @@ -1012,9 +985,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.4" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1028,7 +1001,7 @@ checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -1061,18 +1034,18 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" dependencies = [ "hashbrown", ] [[package]] name = "heck" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" dependencies = [ "unicode-segmentation", ] @@ -1086,6 +1059,15 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.3.1" @@ -1140,9 +1122,9 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399c583b2979440c60be0821a6199eca73bc3c8dcd9d070d75ac726e2c6186e5" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" dependencies = [ "bytes", "http", @@ -1157,9 +1139,9 @@ checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" [[package]] name = "httpdate" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "hyper" @@ -1228,17 +1210,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" -dependencies = [ - "matches", - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "0.3.0" @@ -1251,9 +1222,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown", @@ -1261,9 +1232,9 @@ dependencies = [ [[package]] name = "instant" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ "cfg-if", ] @@ -1281,24 +1252,24 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.3.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" +checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" [[package]] name = "itertools" -version = "0.10.3" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.3" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" [[package]] name = "itoap" @@ -1308,18 +1279,18 @@ checksum = "9028f49264629065d057f340a86acb84867925865f73bbf8d47b4d149a7e88b8" [[package]] name = "jobserver" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" dependencies = [ "libc", ] [[package]] name = "js-sys" -version = "0.3.53" +version = "0.3.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4bf49d50e2961077d9c99f4b7997d770a1114f087c3c2e0069b36c13fc2979d" +checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" dependencies = [ "wasm-bindgen", ] @@ -1379,7 +1350,7 @@ dependencies = [ "futures-util", "hostname", "httpdate", - "idna 0.3.0", + "idna", "mime", "native-tls", "nom", @@ -1402,9 +1373,9 @@ checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "libm" -version = "0.2.2" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33a33a362ce288760ec6a508b94caaec573ae7d3bbbd91b87aa0bad4456839db" +checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" [[package]] name = "libsqlite3-sys" @@ -1428,21 +1399,21 @@ dependencies = [ [[package]] name = "linked-hash-map" -version = "0.5.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eb31c1778188ae1e64398743890d0877fef36d11521ac60406b42016e8c2cf" +checksum = "2e8776872cdc2f073ccaab02e336fa321328c1e02646ebcb9d2108d0baab480d" [[package]] name = "local-channel" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6246c68cf195087205a0512559c97e15eaf95198bf0e206d662092cdcb03fe9f" +checksum = "7f303ec0e94c6c54447f84f3b0ef7af769858a9c4ef56ef2a986d3dcd4c3fc9c" dependencies = [ "futures-core", "futures-sink", @@ -1452,9 +1423,9 @@ dependencies = [ [[package]] name = "local-waker" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84f9a2d3e27ce99ce2c3aad0b09b1a7b916293ea9b2bf624c13fe646fadd8da4" +checksum = "e34f76eb3611940e0e7d53a9aaa4e6a3151f69541a282fd0dad5571420c53ff1" [[package]] name = "lock_api" @@ -1481,12 +1452,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" -[[package]] -name = "matches" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" - [[package]] name = "memchr" version = "2.5.0" @@ -1495,9 +1460,9 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "mime" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" @@ -1511,18 +1476,17 @@ dependencies = [ [[package]] name = "minimal-lexical" -version = "0.1.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6595bb28ed34f43c3fe088e48f6cfb2e033cab45f25a5384d5fdf564fbc8c4b2" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.4.4" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" dependencies = [ "adler", - "autocfg", ] [[package]] @@ -1533,7 +1497,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.45.0", ] @@ -1557,13 +1521,12 @@ dependencies = [ [[package]] name = "nom" -version = "7.0.0" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffd9d26838a953b4af82cbeb9f1592c6798916983959be223a7124e992742c1" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", - "version_check", ] [[package]] @@ -1579,9 +1542,9 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "566d173b2f9406afbc5510a90925d5a2cd80cae4605631f1212303df265de011" +checksum = "2399c9463abc5f909349d8aa9ba080e0b88b3ce2885389b60b993f39b1a56905" dependencies = [ "byteorder", "lazy_static", @@ -1596,9 +1559,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.44" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" dependencies = [ "autocfg", "num-traits", @@ -1617,9 +1580,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" dependencies = [ "autocfg", "libm", @@ -1627,56 +1590,58 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" -dependencies = [ - "hermit-abi 0.1.19", - "libc", -] - -[[package]] -name = "num_threads" -version = "0.1.6" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" dependencies = [ + "hermit-abi 0.2.6", "libc", ] [[package]] name = "once_cell" -version = "1.14.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0" +checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "openssl" -version = "0.10.36" +version = "0.10.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9facdb76fec0b73c406f125d44d86fdad818d66fef0531eec9233ca425ff4a" +checksum = "01b8574602df80f7b85fdfc5392fa884a4e3b3f4f35402c070ab34c3d3f78d56" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", "once_cell", + "openssl-macros", "openssl-sys", ] +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "openssl-probe" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.66" +version = "0.9.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1996d2d305e561b70d1ee0c53f1542833f4e1ac6ce9a6708b6ff2738ca67dc82" +checksum = "8e17f59264b2809d77ae94f0e1ebabc434773f370d6ca667bd223ea10e06cc7e" dependencies = [ - "autocfg", "cc", "libc", "pkg-config", @@ -1695,13 +1660,13 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core 0.8.3", + "parking_lot_core 0.8.6", ] [[package]] @@ -1716,9 +1681,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" dependencies = [ "cfg-if", "instant", @@ -1760,9 +1725,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.9" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1de2e551fb905ac83f73f7aedf2f0cb4a0da7e35efa24a202a936269f1f18e1" +checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" [[package]] name = "pathdiff" @@ -1784,11 +1749,11 @@ dependencies = [ [[package]] name = "pem" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c64931a1a212348ec4f3b4362585eca7159d0d09cbdf4a7f74f02173596fd4" +checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" dependencies = [ - "base64 0.13.0", + "base64 0.13.1", ] [[package]] @@ -1802,15 +1767,15 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" [[package]] name = "pest" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc7bc69c062e492337d74d59b120c274fd3d261b6bf6d3207d499b4b379c41a" +checksum = "e68e84bfb01f0507134eac1e9b410a12ba379d064eab48c50ba4ce329a527b70" dependencies = [ "thiserror", "ucd-trie", @@ -1818,9 +1783,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b75706b9642ebcb34dab3bc7750f811609a0eb1dd8b88c2d15bf628c1c65b2" +checksum = "6b79d4c71c865a25a4322296122e3924d30bc8ee0834c8bfc8b95f7f054afbfb" dependencies = [ "pest", "pest_generator", @@ -1828,42 +1793,42 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f9272122f5979a6511a749af9db9bfc810393f63119970d7085fed1c4ea0db" +checksum = "6c435bf1076437b851ebc8edc3a18442796b30f1728ffea6262d59bbe28b077e" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.15", ] [[package]] name = "pest_meta" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8717927f9b79515e565a64fe46c38b8cd0427e64c40680b14a7365ab09ac8d" +checksum = "745a452f8eb71e39ffd8ee32b3c5f51d03845f99786fa9b68db6ff509c505411" dependencies = [ "once_cell", "pest", - "sha1", + "sha2", ] [[package]] name = "pin-project" -version = "1.0.7" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4" +checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.0.7" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" +checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" dependencies = [ "proc-macro2", "quote", @@ -1872,9 +1837,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.7" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" [[package]] name = "pin-utils" @@ -1906,15 +1871,15 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.19" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" [[package]] name = "ppv-lite86" -version = "0.2.10" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" @@ -1988,17 +1953,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" -dependencies = [ - "getrandom", - "redox_syscall 0.2.16", - "thiserror", -] - [[package]] name = "regex" version = "1.8.1" @@ -2075,7 +2029,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" dependencies = [ - "base64 0.13.0", + "base64 0.13.1", "bitflags", "serde", ] @@ -2210,12 +2164,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.19" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" +checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" dependencies = [ - "lazy_static", - "winapi", + "windows-sys 0.42.0", ] [[package]] @@ -2242,9 +2195,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.3.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a2ac85147a3a11d77ecf1bc7166ec0b92febfa4461c37944e180f319ece467" +checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254" dependencies = [ "bitflags", "core-foundation", @@ -2255,9 +2208,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.4.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9dd14d83160b528b7bfd66439110573efcfbe281b17fc2ca9f39f550d619c7e" +checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4" dependencies = [ "core-foundation-sys", "libc", @@ -2362,9 +2315,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "006769ba83e921b3085caa8334186b00cf92b4cb1a6cf4632fbccc8eff5c7549" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ "cfg-if", "cpufeatures", @@ -2373,9 +2326,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9db03534dff993187064c4e0c05a5708d2a9728ace9a8959b77bedf415dac5" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" dependencies = [ "cfg-if", "cpufeatures", @@ -2384,9 +2337,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" dependencies = [ "libc", ] @@ -2400,20 +2353,23 @@ dependencies = [ "num-bigint", "num-traits", "thiserror", - "time 0.3.14", + "time 0.3.20", ] [[package]] name = "slab" -version = "0.4.3" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] [[package]] name = "smallvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "socket2" @@ -2433,9 +2389,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" -version = "0.9.4" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6002a767bff9e83f8eeecf883ecb8011875a21ae8da43bffb817a57e78cc09" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] @@ -2516,7 +2472,7 @@ dependencies = [ "sqlx-rt", "stringprep", "thiserror", - "time 0.3.14", + "time 0.3.20", "tokio-stream", "url", ] @@ -2629,66 +2585,76 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.34" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c1b05ca9d106ba7d2e31a9dab4a64e7be2cce415321966ea3132c49a656e252" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.34" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8f2591983642de85c921015f3f070c665a197ed69e417af436115e3a1407487" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.15", ] [[package]] name = "time" -version = "0.1.43" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" dependencies = [ "libc", + "wasi 0.10.0+wasi-snapshot-preview1", "winapi", ] [[package]] name = "time" -version = "0.3.14" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" +checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" dependencies = [ "itoa", - "libc", - "num_threads", + "serde", + "time-core", "time-macros", ] +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + [[package]] name = "time-macros" -version = "0.2.4" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" +checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36" +dependencies = [ + "time-core", +] [[package]] name = "tinyvec" -version = "1.2.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" @@ -2722,9 +2688,9 @@ dependencies = [ [[package]] name = "tokio-native-tls" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", "tokio", @@ -2742,9 +2708,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" dependencies = [ "futures-core", "pin-project-lite", @@ -2767,9 +2733,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.5.8" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" dependencies = [ "serde", ] @@ -2849,17 +2815,16 @@ dependencies = [ [[package]] name = "tower-service" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.35" +version = "0.1.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" +checksum = "cf9cf6a813d3f40c88b0b6b6f29a5c95c6cdbf97c1f9cc53fb820200f5ad814d" dependencies = [ - "cfg-if", "log", "pin-project-lite", "tracing-core", @@ -2876,15 +2841,15 @@ dependencies = [ [[package]] name = "try-lock" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" [[package]] name = "typenum" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" [[package]] name = "ucd-trie" @@ -2903,12 +2868,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.5" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" -dependencies = [ - "matches", -] +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" [[package]] name = "unicode-ident" @@ -2918,18 +2880,18 @@ checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" [[package]] name = "unicode-normalization" -version = "0.1.19" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" [[package]] name = "unicode-width" @@ -2951,13 +2913,12 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "url" -version = "2.2.2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" dependencies = [ "form_urlencoded", - "idna 0.2.3", - "matches", + "idna", "percent-encoding", ] @@ -2998,6 +2959,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -3006,9 +2973,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.76" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce9b1b516211d33767048e5d47fa2a381ed8b76fc48d2ce4aa39877f9f183e0" +checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3016,13 +2983,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.76" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe8dc78e2326ba5f845f4b5bf548401604fa20b1dd1d365fb73b6c1d6364041" +checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" dependencies = [ "bumpalo", - "lazy_static", "log", + "once_cell", "proc-macro2", "quote", "syn 1.0.109", @@ -3031,9 +2998,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.26" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95fded345a6559c2cfee778d562300c581f7d4ff3edb9b0d230d69800d213972" +checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454" dependencies = [ "cfg-if", "js-sys", @@ -3043,9 +3010,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.76" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44468aa53335841d9d6b6c023eaab07c0cd4bddbcfdee3e2bb1e8d2cb8069fef" +checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3053,9 +3020,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.76" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0195807922713af1e67dc66132c7328206ed9766af3858164fb583eedc25fbad" +checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" dependencies = [ "proc-macro2", "quote", @@ -3066,15 +3033,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.76" +version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdb075a845574a1fa5f09fd77e43f7747599301ea3417a9fbffdeedfc1f4a29" +checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" [[package]] name = "web-sys" -version = "0.3.53" +version = "0.3.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224b2f6b67919060055ef1a67807367c2066ed520c3862cc013d26cf893a783c" +checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" dependencies = [ "js-sys", "wasm-bindgen", @@ -3140,6 +3107,21 @@ dependencies = [ "windows-targets 0.48.0", ] +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -3301,9 +3283,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index ddcfdd63..7b0eed5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,11 +39,11 @@ sailfish = "0.6" regex = "1.8" pbkdf2 = { version = "0.12", features = ["simple"] } text-colorizer = "1.0.0" -log = "0.4.17" -fern = "0.6.2" +log = "0.4" +fern = "0.6" [dev-dependencies] -rand = "0.8.5" -tempfile = "3.5.0" +rand = "0.8" +tempfile = "3.5" uuid = { version = "1.3", features = [ "v4"] } -which = "4.4.0" +which = "4.4" From 1aee35611fc4d2b449bd76cd0c7ac10885cdcdde Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 12:41:33 +0100 Subject: [PATCH 109/357] fix: [#125] dependabot alert GHSA-wcg3-cvx6-7396 --- Cargo.lock | 32 ++++++-------------------------- Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9aa635dc..6caf1c77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ "serde_urlencoded", "smallvec", "socket2", - "time 0.3.20", + "time", "url", ] @@ -458,11 +458,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ "iana-time-zone", - "js-sys", "num-integer", "num-traits", - "time 0.1.45", - "wasm-bindgen", "winapi", ] @@ -525,7 +522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ "percent-encoding", - "time 0.3.20", + "time", "version_check", ] @@ -1001,7 +998,7 @@ checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1497,7 +1494,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.45.0", ] @@ -2353,7 +2350,7 @@ dependencies = [ "num-bigint", "num-traits", "thiserror", - "time 0.3.20", + "time", ] [[package]] @@ -2472,7 +2469,7 @@ dependencies = [ "sqlx-rt", "stringprep", "thiserror", - "time 0.3.20", + "time", "tokio-stream", "url", ] @@ -2603,17 +2600,6 @@ dependencies = [ "syn 2.0.15", ] -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - [[package]] name = "time" version = "0.3.20" @@ -2959,12 +2945,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 7b0eed5d..60b40985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ serde_bytes = "0.11" urlencoding = "2.1" argon2 = "0.5" rand_core = { version = "0.6", features = ["std"] } -chrono = "0.4" +chrono = { version = "0.4", default-features = false, features = ["clock"] } jsonwebtoken = "8.3" sha-1 = "0.10" reqwest = { version = "0.11", features = [ "json", "multipart" ] } From 489061e101ffb75233b7e628537a035616fad5ec Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Apr 2023 16:37:57 +0100 Subject: [PATCH 110/357] refactor: extract logic from e2e tests to reuse it for integrations tests E2E tests run an independent application process with docker, and then they are executed against that public service. It's only one instance. Integrations tests will use an independent process for each test, running the API on a different port. The instance SUT is launched by the tests. We need to refactor the application bootstrapping to be able lo launch an app instance from tests. This refactor allows us to reuse the code from E2E tests in future integration tests. --- tests/{e2e => common}/asserts.rs | 4 +- tests/{e2e => common}/client.rs | 16 +- tests/{e2e => common}/connection_info.rs | 0 tests/common/contexts/about/mod.rs | 1 + tests/common/contexts/category/fixtures.rs | 18 ++ tests/common/contexts/category/forms.rs | 9 + tests/common/contexts/category/mod.rs | 3 + tests/common/contexts/category/responses.rs | 18 ++ tests/common/contexts/mod.rs | 6 + tests/common/contexts/root/mod.rs | 1 + tests/common/contexts/settings/form.rs | 3 + tests/common/contexts/settings/mod.rs | 59 +++++++ tests/common/contexts/settings/responses.rs | 26 +++ .../contexts/torrent/asserts.rs | 0 .../{e2e => common}/contexts/torrent/file.rs | 0 .../contexts/torrent/fixtures.rs | 28 +--- .../contexts/torrent/forms.rs} | 15 +- tests/common/contexts/torrent/mod.rs | 6 + tests/common/contexts/torrent/requests.rs | 1 + .../contexts/torrent/responses.rs | 0 tests/common/contexts/user/fixtures.rs | 18 ++ tests/common/contexts/user/forms.rs | 37 +++++ tests/common/contexts/user/mod.rs | 3 + tests/common/contexts/user/responses.rs | 35 ++++ tests/{e2e => common}/http.rs | 0 tests/common/mod.rs | 6 + tests/{e2e => common}/responses.rs | 0 .../contexts/{about.rs => about/contract.rs} | 3 +- tests/e2e/contexts/about/mod.rs | 1 + .../{category.rs => category/contract.rs} | 92 ++-------- tests/e2e/contexts/category/mod.rs | 2 + tests/e2e/contexts/category/steps.rs | 16 ++ .../contexts/{root.rs => root/contract.rs} | 3 +- tests/e2e/contexts/root/mod.rs | 1 + .../{settings.rs => settings/contract.rs} | 91 +--------- tests/e2e/contexts/settings/mod.rs | 1 + tests/e2e/contexts/torrent/contract.rs | 40 ++--- tests/e2e/contexts/torrent/mod.rs | 6 +- tests/e2e/contexts/torrent/steps.rs | 25 +++ .../contexts/{user.rs => user/contract.rs} | 157 ++---------------- tests/e2e/contexts/user/mod.rs | 2 + tests/e2e/contexts/user/steps.rs | 51 ++++++ tests/e2e/environment.rs | 4 +- tests/e2e/mod.rs | 7 +- tests/integration/contexts/about.rs | 5 + tests/integration/contexts/mod.rs | 1 + tests/integration/mod.rs | 1 + tests/mod.rs | 4 +- 48 files changed, 436 insertions(+), 390 deletions(-) rename tests/{e2e => common}/asserts.rs (95%) rename tests/{e2e => common}/client.rs (94%) rename tests/{e2e => common}/connection_info.rs (100%) create mode 100644 tests/common/contexts/about/mod.rs create mode 100644 tests/common/contexts/category/fixtures.rs create mode 100644 tests/common/contexts/category/forms.rs create mode 100644 tests/common/contexts/category/mod.rs create mode 100644 tests/common/contexts/category/responses.rs create mode 100644 tests/common/contexts/mod.rs create mode 100644 tests/common/contexts/root/mod.rs create mode 100644 tests/common/contexts/settings/form.rs create mode 100644 tests/common/contexts/settings/mod.rs create mode 100644 tests/common/contexts/settings/responses.rs rename tests/{e2e => common}/contexts/torrent/asserts.rs (100%) rename tests/{e2e => common}/contexts/torrent/file.rs (100%) rename tests/{e2e => common}/contexts/torrent/fixtures.rs (74%) rename tests/{e2e/contexts/torrent/requests.rs => common/contexts/torrent/forms.rs} (97%) create mode 100644 tests/common/contexts/torrent/mod.rs create mode 100644 tests/common/contexts/torrent/requests.rs rename tests/{e2e => common}/contexts/torrent/responses.rs (100%) create mode 100644 tests/common/contexts/user/fixtures.rs create mode 100644 tests/common/contexts/user/forms.rs create mode 100644 tests/common/contexts/user/mod.rs create mode 100644 tests/common/contexts/user/responses.rs rename tests/{e2e => common}/http.rs (100%) create mode 100644 tests/common/mod.rs rename tests/{e2e => common}/responses.rs (100%) rename tests/e2e/contexts/{about.rs => about/contract.rs} (86%) create mode 100644 tests/e2e/contexts/about/mod.rs rename tests/e2e/contexts/{category.rs => category/contract.rs} (75%) create mode 100644 tests/e2e/contexts/category/mod.rs create mode 100644 tests/e2e/contexts/category/steps.rs rename tests/e2e/contexts/{root.rs => root/contract.rs} (76%) create mode 100644 tests/e2e/contexts/root/mod.rs rename tests/e2e/contexts/{settings.rs => settings/contract.rs} (78%) create mode 100644 tests/e2e/contexts/settings/mod.rs create mode 100644 tests/e2e/contexts/torrent/steps.rs rename tests/e2e/contexts/{user.rs => user/contract.rs} (60%) create mode 100644 tests/e2e/contexts/user/mod.rs create mode 100644 tests/e2e/contexts/user/steps.rs create mode 100644 tests/integration/contexts/about.rs create mode 100644 tests/integration/contexts/mod.rs create mode 100644 tests/integration/mod.rs diff --git a/tests/e2e/asserts.rs b/tests/common/asserts.rs similarity index 95% rename from tests/e2e/asserts.rs rename to tests/common/asserts.rs index f2968904..60df0956 100644 --- a/tests/e2e/asserts.rs +++ b/tests/common/asserts.rs @@ -1,7 +1,7 @@ -use crate::e2e::responses::TextResponse; - // Text responses +use super::responses::TextResponse; + pub fn assert_response_title(response: &TextResponse, title: &str) { let title_element = format!("{title}"); diff --git a/tests/e2e/client.rs b/tests/common/client.rs similarity index 94% rename from tests/e2e/client.rs rename to tests/common/client.rs index 727299aa..cf35cdab 100644 --- a/tests/e2e/client.rs +++ b/tests/common/client.rs @@ -1,14 +1,14 @@ use reqwest::multipart; use serde::Serialize; -use super::contexts::category::{AddCategoryForm, DeleteCategoryForm}; -use super::contexts::settings::UpdateSettingsForm; -use super::contexts::torrent::requests::{TorrentId, UpdateTorrentFrom}; -use super::contexts::user::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; -use super::responses::{self, BinaryResponse}; -use crate::e2e::connection_info::ConnectionInfo; -use crate::e2e::http::{Query, ReqwestQuery}; -use crate::e2e::responses::TextResponse; +use super::connection_info::ConnectionInfo; +use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; +use super::contexts::settings::form::UpdateSettingsForm; +use super::contexts::torrent::forms::UpdateTorrentFrom; +use super::contexts::torrent::requests::TorrentId; +use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; +use super::http::{Query, ReqwestQuery}; +use super::responses::{self, BinaryResponse, TextResponse}; /// API Client pub struct Client { diff --git a/tests/e2e/connection_info.rs b/tests/common/connection_info.rs similarity index 100% rename from tests/e2e/connection_info.rs rename to tests/common/connection_info.rs diff --git a/tests/common/contexts/about/mod.rs b/tests/common/contexts/about/mod.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/common/contexts/about/mod.rs @@ -0,0 +1 @@ + diff --git a/tests/common/contexts/category/fixtures.rs b/tests/common/contexts/category/fixtures.rs new file mode 100644 index 00000000..18e62288 --- /dev/null +++ b/tests/common/contexts/category/fixtures.rs @@ -0,0 +1,18 @@ +use rand::Rng; + +pub fn software_predefined_category_name() -> String { + "software".to_string() +} + +pub fn software_predefined_category_id() -> i64 { + 5 +} + +pub fn random_category_name() -> String { + format!("category name {}", random_id()) +} + +fn random_id() -> u64 { + let mut rng = rand::thread_rng(); + rng.gen_range(0..1_000_000) +} diff --git a/tests/common/contexts/category/forms.rs b/tests/common/contexts/category/forms.rs new file mode 100644 index 00000000..ea9cf429 --- /dev/null +++ b/tests/common/contexts/category/forms.rs @@ -0,0 +1,9 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct AddCategoryForm { + pub name: String, + pub icon: Option, +} + +pub type DeleteCategoryForm = AddCategoryForm; diff --git a/tests/common/contexts/category/mod.rs b/tests/common/contexts/category/mod.rs new file mode 100644 index 00000000..6f27f51d --- /dev/null +++ b/tests/common/contexts/category/mod.rs @@ -0,0 +1,3 @@ +pub mod fixtures; +pub mod forms; +pub mod responses; diff --git a/tests/common/contexts/category/responses.rs b/tests/common/contexts/category/responses.rs new file mode 100644 index 00000000..a345d523 --- /dev/null +++ b/tests/common/contexts/category/responses.rs @@ -0,0 +1,18 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct AddedCategoryResponse { + pub data: String, +} + +#[derive(Deserialize, Debug)] +pub struct ListResponse { + pub data: Vec, +} + +#[derive(Deserialize, Debug, PartialEq)] +pub struct ListItem { + pub category_id: i64, + pub name: String, + pub num_torrents: i64, +} diff --git a/tests/common/contexts/mod.rs b/tests/common/contexts/mod.rs new file mode 100644 index 00000000..a6f14141 --- /dev/null +++ b/tests/common/contexts/mod.rs @@ -0,0 +1,6 @@ +pub mod about; +pub mod category; +pub mod root; +pub mod settings; +pub mod torrent; +pub mod user; diff --git a/tests/common/contexts/root/mod.rs b/tests/common/contexts/root/mod.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/common/contexts/root/mod.rs @@ -0,0 +1 @@ + diff --git a/tests/common/contexts/settings/form.rs b/tests/common/contexts/settings/form.rs new file mode 100644 index 00000000..f2f21086 --- /dev/null +++ b/tests/common/contexts/settings/form.rs @@ -0,0 +1,3 @@ +use super::Settings; + +pub type UpdateSettingsForm = Settings; diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs new file mode 100644 index 00000000..a046c859 --- /dev/null +++ b/tests/common/contexts/settings/mod.rs @@ -0,0 +1,59 @@ +pub mod form; +pub mod responses; + +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Settings { + pub website: Website, + pub tracker: Tracker, + pub net: Net, + pub auth: Auth, + pub database: Database, + pub mail: Mail, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Website { + pub name: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Tracker { + pub url: String, + pub mode: String, + pub api_url: String, + pub token: String, + pub token_valid_seconds: u64, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Net { + pub port: u64, + pub base_url: Option, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Auth { + pub email_on_signup: String, + pub min_password_length: u64, + pub max_password_length: u64, + pub secret_key: String, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Database { + pub connect_url: String, + pub torrent_info_update_interval: u64, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct Mail { + pub email_verification_enabled: bool, + pub from: String, + pub reply_to: String, + pub username: String, + pub password: String, + pub server: String, + pub port: u64, +} diff --git a/tests/common/contexts/settings/responses.rs b/tests/common/contexts/settings/responses.rs new file mode 100644 index 00000000..096ef1f4 --- /dev/null +++ b/tests/common/contexts/settings/responses.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +use super::Settings; + +#[derive(Deserialize)] +pub struct AllSettingsResponse { + pub data: Settings, +} + +#[derive(Deserialize)] +pub struct PublicSettingsResponse { + pub data: Public, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Public { + pub website_name: String, + pub tracker_url: String, + pub tracker_mode: String, + pub email_on_signup: String, +} + +#[derive(Deserialize)] +pub struct SiteNameResponse { + pub data: String, +} diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/common/contexts/torrent/asserts.rs similarity index 100% rename from tests/e2e/contexts/torrent/asserts.rs rename to tests/common/contexts/torrent/asserts.rs diff --git a/tests/e2e/contexts/torrent/file.rs b/tests/common/contexts/torrent/file.rs similarity index 100% rename from tests/e2e/contexts/torrent/file.rs rename to tests/common/contexts/torrent/file.rs diff --git a/tests/e2e/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs similarity index 74% rename from tests/e2e/contexts/torrent/fixtures.rs rename to tests/common/contexts/torrent/fixtures.rs index 96d69d9a..f2a46748 100644 --- a/tests/e2e/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -6,11 +6,9 @@ use tempfile::{tempdir, TempDir}; use uuid::Uuid; use super::file::{create_torrent, parse_torrent, TorrentFileInfo}; -use super::requests::{BinaryFile, UploadTorrentMultipartForm}; -use super::responses::{Id, UploadedTorrentResponse}; -use crate::e2e::contexts::category::fixtures::software_predefined_category_name; -use crate::e2e::contexts::user::LoggedInUserData; -use crate::e2e::environment::TestEnv; +use super::forms::{BinaryFile, UploadTorrentMultipartForm}; +use super::responses::Id; +use crate::common::contexts::category::fixtures::software_predefined_category_name; /// Information about a torrent that is going to added to the index. #[derive(Clone)] @@ -53,26 +51,6 @@ impl TorrentListedInIndex { } } -/// Add a new random torrent to the index -pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (TestTorrent, TorrentListedInIndex) { - let random_torrent = random_torrent(); - let indexed_torrent = upload_torrent(uploader, &random_torrent.index_info).await; - (random_torrent, indexed_torrent) -} - -/// Upload a torrent to the index -pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo) -> TorrentListedInIndex { - let client = TestEnv::default().authenticated_client(&uploader.token); - - let form: UploadTorrentMultipartForm = torrent.clone().into(); - - let response = client.upload_torrent(form.into()).await; - - let res: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - TorrentListedInIndex::from(torrent.clone(), res.data.torrent_id) -} - #[derive(Clone)] pub struct TestTorrent { /// Parsed info from torrent file. diff --git a/tests/e2e/contexts/torrent/requests.rs b/tests/common/contexts/torrent/forms.rs similarity index 97% rename from tests/e2e/contexts/torrent/requests.rs rename to tests/common/contexts/torrent/forms.rs index fb1ec578..0c4499f1 100644 --- a/tests/e2e/contexts/torrent/requests.rs +++ b/tests/common/contexts/torrent/forms.rs @@ -1,10 +1,15 @@ use std::fs; use std::path::Path; -use reqwest::multipart::Form; use serde::{Deserialize, Serialize}; -pub type TorrentId = i64; +#[derive(Deserialize, Serialize)] +pub struct UpdateTorrentFrom { + pub title: Option, + pub description: Option, +} + +use reqwest::multipart::Form; pub struct UploadTorrentMultipartForm { pub title: String, @@ -43,9 +48,3 @@ impl From for Form { ) } } - -#[derive(Deserialize, Serialize)] -pub struct UpdateTorrentFrom { - pub title: Option, - pub description: Option, -} diff --git a/tests/common/contexts/torrent/mod.rs b/tests/common/contexts/torrent/mod.rs new file mode 100644 index 00000000..efe732e6 --- /dev/null +++ b/tests/common/contexts/torrent/mod.rs @@ -0,0 +1,6 @@ +pub mod asserts; +pub mod file; +pub mod fixtures; +pub mod forms; +pub mod requests; +pub mod responses; diff --git a/tests/common/contexts/torrent/requests.rs b/tests/common/contexts/torrent/requests.rs new file mode 100644 index 00000000..259a2dae --- /dev/null +++ b/tests/common/contexts/torrent/requests.rs @@ -0,0 +1 @@ +pub type TorrentId = i64; diff --git a/tests/e2e/contexts/torrent/responses.rs b/tests/common/contexts/torrent/responses.rs similarity index 100% rename from tests/e2e/contexts/torrent/responses.rs rename to tests/common/contexts/torrent/responses.rs diff --git a/tests/common/contexts/user/fixtures.rs b/tests/common/contexts/user/fixtures.rs new file mode 100644 index 00000000..3eda8502 --- /dev/null +++ b/tests/common/contexts/user/fixtures.rs @@ -0,0 +1,18 @@ +use rand::Rng; + +use crate::common::contexts::user::forms::RegistrationForm; + +pub fn random_user_registration() -> RegistrationForm { + let user_id = random_user_id(); + RegistrationForm { + username: format!("username_{user_id}"), + email: Some(format!("email_{user_id}@email.com")), + password: "password".to_string(), + confirm_password: "password".to_string(), + } +} + +fn random_user_id() -> u64 { + let mut rng = rand::thread_rng(); + rng.gen_range(0..1_000_000) +} diff --git a/tests/common/contexts/user/forms.rs b/tests/common/contexts/user/forms.rs new file mode 100644 index 00000000..359252a8 --- /dev/null +++ b/tests/common/contexts/user/forms.rs @@ -0,0 +1,37 @@ +use serde::Serialize; + +#[derive(Clone, Serialize)] +pub struct RegistrationForm { + pub username: String, + pub email: Option, + pub password: String, + pub confirm_password: String, +} + +pub type RegisteredUser = RegistrationForm; + +#[derive(Serialize)] +pub struct LoginForm { + pub login: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct TokenVerificationForm { + pub token: String, +} + +#[derive(Serialize)] +pub struct TokenRenewalForm { + pub token: String, +} + +pub struct Username { + pub value: String, +} + +impl Username { + pub fn new(value: String) -> Self { + Self { value } + } +} diff --git a/tests/common/contexts/user/mod.rs b/tests/common/contexts/user/mod.rs new file mode 100644 index 00000000..6f27f51d --- /dev/null +++ b/tests/common/contexts/user/mod.rs @@ -0,0 +1,3 @@ +pub mod fixtures; +pub mod forms; +pub mod responses; diff --git a/tests/common/contexts/user/responses.rs b/tests/common/contexts/user/responses.rs new file mode 100644 index 00000000..428b0b96 --- /dev/null +++ b/tests/common/contexts/user/responses.rs @@ -0,0 +1,35 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct SuccessfulLoginResponse { + pub data: LoggedInUserData, +} + +#[derive(Deserialize, Debug)] +pub struct LoggedInUserData { + pub token: String, + pub username: String, + pub admin: bool, +} + +#[derive(Deserialize)] +pub struct TokenVerifiedResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct BannedUserResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct TokenRenewalResponse { + pub data: TokenRenewalData, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TokenRenewalData { + pub token: String, + pub username: String, + pub admin: bool, +} diff --git a/tests/e2e/http.rs b/tests/common/http.rs similarity index 100% rename from tests/e2e/http.rs rename to tests/common/http.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 00000000..33956c17 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,6 @@ +pub mod asserts; +pub mod client; +pub mod connection_info; +pub mod contexts; +pub mod http; +pub mod responses; diff --git a/tests/e2e/responses.rs b/tests/common/responses.rs similarity index 100% rename from tests/e2e/responses.rs rename to tests/common/responses.rs diff --git a/tests/e2e/contexts/about.rs b/tests/e2e/contexts/about/contract.rs similarity index 86% rename from tests/e2e/contexts/about.rs rename to tests/e2e/contexts/about/contract.rs index 49bf6c41..8c97831a 100644 --- a/tests/e2e/contexts/about.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -1,4 +1,5 @@ -use crate::e2e::asserts::{assert_response_title, assert_text_ok}; +//! API contract for `about` context. +use crate::common::asserts::{assert_response_title, assert_text_ok}; use crate::e2e::environment::TestEnv; #[tokio::test] diff --git a/tests/e2e/contexts/about/mod.rs b/tests/e2e/contexts/about/mod.rs new file mode 100644 index 00000000..2943dbb5 --- /dev/null +++ b/tests/e2e/contexts/about/mod.rs @@ -0,0 +1 @@ +pub mod contract; diff --git a/tests/e2e/contexts/category.rs b/tests/e2e/contexts/category/contract.rs similarity index 75% rename from tests/e2e/contexts/category.rs rename to tests/e2e/contexts/category/contract.rs index ca6ed51a..75df92ef 100644 --- a/tests/e2e/contexts/category.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -1,38 +1,18 @@ -use serde::{Deserialize, Serialize}; - -use crate::e2e::asserts::assert_json_ok; -use crate::e2e::contexts::category::fixtures::{add_category, random_category_name}; -use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user}; +//! API contract for `category` context. +use crate::common::asserts::assert_json_ok; +use crate::common::contexts::category::fixtures::random_category_name; +use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; +use crate::common::contexts::category::responses::{AddedCategoryResponse, ListResponse}; +use crate::e2e::contexts::category::steps::add_category; +use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user}; use crate::e2e::environment::TestEnv; -// Request data - -#[derive(Serialize)] -pub struct AddCategoryForm { - pub name: String, - pub icon: Option, -} - -pub type DeleteCategoryForm = AddCategoryForm; - -// Response data - -#[derive(Deserialize)] -pub struct AddedCategoryResponse { - pub data: String, -} - -#[derive(Deserialize, Debug)] -pub struct ListResponse { - pub data: Vec, -} - -#[derive(Deserialize, Debug, PartialEq)] -pub struct ListItem { - pub category_id: i64, - pub name: String, - pub num_torrents: i64, -} +/* todo: + - it should allow adding a new category to authenticated clients + - it should not allow adding a new category with an empty name + - it should allow adding a new category with an optional icon + - ... +*/ #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] @@ -202,49 +182,3 @@ async fn it_should_not_allow_guests_to_delete_categories() { assert_eq!(response.status, 401); } - -/* todo: - - it should allow adding a new category to authenticated clients - - it should not allow adding a new category with an empty name - - it should allow adding a new category with an optional icon - - ... -*/ - -pub mod fixtures { - - use rand::Rng; - - use super::AddCategoryForm; - use crate::e2e::contexts::user::fixtures::logged_in_admin; - use crate::e2e::environment::TestEnv; - use crate::e2e::responses::TextResponse; - - pub fn software_predefined_category_name() -> String { - "software".to_string() - } - - pub fn software_predefined_category_id() -> i64 { - 5 - } - - pub async fn add_category(category_name: &str) -> TextResponse { - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); - - client - .add_category(AddCategoryForm { - name: category_name.to_string(), - icon: None, - }) - .await - } - - pub fn random_category_name() -> String { - format!("category name {}", random_id()) - } - - fn random_id() -> u64 { - let mut rng = rand::thread_rng(); - rng.gen_range(0..1_000_000) - } -} diff --git a/tests/e2e/contexts/category/mod.rs b/tests/e2e/contexts/category/mod.rs new file mode 100644 index 00000000..2001efb8 --- /dev/null +++ b/tests/e2e/contexts/category/mod.rs @@ -0,0 +1,2 @@ +pub mod contract; +pub mod steps; diff --git a/tests/e2e/contexts/category/steps.rs b/tests/e2e/contexts/category/steps.rs new file mode 100644 index 00000000..58794a96 --- /dev/null +++ b/tests/e2e/contexts/category/steps.rs @@ -0,0 +1,16 @@ +use crate::common::contexts::category::forms::AddCategoryForm; +use crate::common::responses::TextResponse; +use crate::e2e::contexts::user::steps::logged_in_admin; +use crate::e2e::environment::TestEnv; + +pub async fn add_category(category_name: &str) -> TextResponse { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await +} diff --git a/tests/e2e/contexts/root.rs b/tests/e2e/contexts/root/contract.rs similarity index 76% rename from tests/e2e/contexts/root.rs rename to tests/e2e/contexts/root/contract.rs index d7ea0e03..c82d8a8d 100644 --- a/tests/e2e/contexts/root.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -1,4 +1,5 @@ -use crate::e2e::asserts::{assert_response_title, assert_text_ok}; +//! API contract for `root` context. +use crate::common::asserts::{assert_response_title, assert_text_ok}; use crate::e2e::environment::TestEnv; #[tokio::test] diff --git a/tests/e2e/contexts/root/mod.rs b/tests/e2e/contexts/root/mod.rs new file mode 100644 index 00000000..2943dbb5 --- /dev/null +++ b/tests/e2e/contexts/root/mod.rs @@ -0,0 +1 @@ +pub mod contract; diff --git a/tests/e2e/contexts/settings.rs b/tests/e2e/contexts/settings/contract.rs similarity index 78% rename from tests/e2e/contexts/settings.rs rename to tests/e2e/contexts/settings/contract.rs index 08eb0a10..11dd88ad 100644 --- a/tests/e2e/contexts/settings.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -1,92 +1,9 @@ -use serde::{Deserialize, Serialize}; - -use crate::e2e::contexts::user::fixtures::logged_in_admin; +use crate::common::contexts::settings::form::UpdateSettingsForm; +use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; +use crate::common::contexts::settings::{Auth, Database, Mail, Net, Settings, Tracker, Website}; +use crate::e2e::contexts::user::steps::logged_in_admin; use crate::e2e::environment::TestEnv; -// Request data - -pub type UpdateSettingsForm = Settings; - -// Response data - -#[derive(Deserialize)] -pub struct AllSettingsResponse { - pub data: Settings, -} - -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Settings { - pub website: Website, - pub tracker: Tracker, - pub net: Net, - pub auth: Auth, - pub database: Database, - pub mail: Mail, -} - -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Website { - pub name: String, -} - -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Tracker { - pub url: String, - pub mode: String, - pub api_url: String, - pub token: String, - pub token_valid_seconds: u64, -} - -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Net { - pub port: u64, - pub base_url: Option, -} - -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Auth { - pub email_on_signup: String, - pub min_password_length: u64, - pub max_password_length: u64, - pub secret_key: String, -} - -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Database { - pub connect_url: String, - pub torrent_info_update_interval: u64, -} - -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Mail { - pub email_verification_enabled: bool, - pub from: String, - pub reply_to: String, - pub username: String, - pub password: String, - pub server: String, - pub port: u64, -} - -#[derive(Deserialize)] -pub struct PublicSettingsResponse { - pub data: Public, -} - -#[derive(Deserialize, PartialEq, Debug)] -pub struct Public { - pub website_name: String, - pub tracker_url: String, - pub tracker_mode: String, - pub email_on_signup: String, -} - -#[derive(Deserialize)] -pub struct SiteNameResponse { - pub data: String, -} - #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_get_the_public_settings() { diff --git a/tests/e2e/contexts/settings/mod.rs b/tests/e2e/contexts/settings/mod.rs new file mode 100644 index 00000000..2943dbb5 --- /dev/null +++ b/tests/e2e/contexts/settings/mod.rs @@ -0,0 +1 @@ +pub mod contract; diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index e7521de8..591396b1 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -24,11 +24,13 @@ Get torrent info: mod for_guests { use torrust_index_backend::utils::parse_torrent::decode_torrent; - use crate::e2e::contexts::category::fixtures::software_predefined_category_id; - use crate::e2e::contexts::torrent::asserts::assert_expected_torrent_details; - use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; - use crate::e2e::contexts::torrent::responses::{Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse}; - use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::common::contexts::category::fixtures::software_predefined_category_id; + use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::common::contexts::torrent::responses::{ + Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, + }; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::logged_in_user; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -140,10 +142,10 @@ mod for_guests { mod for_authenticated_users { - use crate::e2e::contexts::torrent::fixtures::random_torrent; - use crate::e2e::contexts::torrent::requests::UploadTorrentMultipartForm; - use crate::e2e::contexts::torrent::responses::UploadedTorrentResponse; - use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::common::contexts::torrent::fixtures::random_torrent; + use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; + use crate::common::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::contexts::user::steps::logged_in_user; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -231,8 +233,8 @@ mod for_authenticated_users { } mod and_non_admins { - use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; - use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::logged_in_user; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -250,10 +252,10 @@ mod for_authenticated_users { } mod and_torrent_owners { - use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; - use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom; - use crate::e2e::contexts::torrent::responses::UpdatedTorrentResponse; - use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::logged_in_user; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -288,10 +290,10 @@ mod for_authenticated_users { } mod and_admins { - use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; - use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom; - use crate::e2e::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; - use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user}; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user}; use crate::e2e::environment::TestEnv; #[tokio::test] diff --git a/tests/e2e/contexts/torrent/mod.rs b/tests/e2e/contexts/torrent/mod.rs index 4f3882e6..2001efb8 100644 --- a/tests/e2e/contexts/torrent/mod.rs +++ b/tests/e2e/contexts/torrent/mod.rs @@ -1,6 +1,2 @@ -pub mod asserts; pub mod contract; -pub mod file; -pub mod fixtures; -pub mod requests; -pub mod responses; +pub mod steps; diff --git a/tests/e2e/contexts/torrent/steps.rs b/tests/e2e/contexts/torrent/steps.rs new file mode 100644 index 00000000..1d34e493 --- /dev/null +++ b/tests/e2e/contexts/torrent/steps.rs @@ -0,0 +1,25 @@ +use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent, TorrentIndexInfo, TorrentListedInIndex}; +use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; +use crate::common::contexts::torrent::responses::UploadedTorrentResponse; +use crate::common::contexts::user::responses::LoggedInUserData; +use crate::e2e::environment::TestEnv; + +/// Add a new random torrent to the index +pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (TestTorrent, TorrentListedInIndex) { + let random_torrent = random_torrent(); + let indexed_torrent = upload_torrent(uploader, &random_torrent.index_info).await; + (random_torrent, indexed_torrent) +} + +/// Upload a torrent to the index +pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo) -> TorrentListedInIndex { + let client = TestEnv::default().authenticated_client(&uploader.token); + + let form: UploadTorrentMultipartForm = torrent.clone().into(); + + let response = client.upload_torrent(form.into()).await; + + let res: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + TorrentListedInIndex::from(torrent.clone(), res.data.torrent_id) +} diff --git a/tests/e2e/contexts/user.rs b/tests/e2e/contexts/user/contract.rs similarity index 60% rename from tests/e2e/contexts/user.rs rename to tests/e2e/contexts/user/contract.rs index 1d0c456f..dd3c333e 100644 --- a/tests/e2e/contexts/user.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -1,12 +1,16 @@ -use serde::{Deserialize, Serialize}; - -use crate::e2e::contexts::user::fixtures::{logged_in_user, random_user_registration, registered_user}; +//! API contract for `user` context. +use crate::common::contexts::user::fixtures::random_user_registration; +use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; +use crate::common::contexts::user::responses::{ + SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, +}; +use crate::e2e::contexts::user::steps::{logged_in_user, registered_user}; use crate::e2e::environment::TestEnv; /* This test suite is not complete. It's just a starting point to show how to -write E2E tests. ANyway, the goal is not to fully cover all the app features +write E2E tests. Anyway, the goal is not to fully cover all the app features with E2E tests. The goal is to cover the most important features and to demonstrate how to write E2E tests. Some important pending tests could be: @@ -29,80 +33,8 @@ the mailcatcher API. */ -// Request data - -#[derive(Clone, Serialize)] -pub struct RegistrationForm { - pub username: String, - pub email: Option, - pub password: String, - pub confirm_password: String, -} - -type RegisteredUser = RegistrationForm; - -#[derive(Serialize)] -pub struct LoginForm { - pub login: String, - pub password: String, -} - -#[derive(Serialize)] -pub struct TokenVerificationForm { - pub token: String, -} - -#[derive(Serialize)] -pub struct TokenRenewalForm { - pub token: String, -} - -pub struct Username { - pub value: String, -} - -impl Username { - pub fn new(value: String) -> Self { - Self { value } - } -} - // Responses data -#[derive(Deserialize)] -pub struct SuccessfulLoginResponse { - pub data: LoggedInUserData, -} - -#[derive(Deserialize, Debug)] -pub struct LoggedInUserData { - pub token: String, - pub username: String, - pub admin: bool, -} - -#[derive(Deserialize)] -pub struct TokenVerifiedResponse { - pub data: String, -} - -#[derive(Deserialize)] -pub struct BannedUserResponse { - pub data: String, -} - -#[derive(Deserialize)] -pub struct TokenRenewalResponse { - pub data: TokenRenewalData, -} - -#[derive(Deserialize, PartialEq, Debug)] -pub struct TokenRenewalData { - pub token: String, - pub username: String, - pub admin: bool, -} - #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_a_guess_user_to_register() { @@ -194,8 +126,9 @@ async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_w } mod banned_user_list { - use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user, registered_user}; - use crate::e2e::contexts::user::{BannedUserResponse, Username}; + use crate::common::contexts::user::forms::Username; + use crate::common::contexts::user::responses::BannedUserResponse; + use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user, registered_user}; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -242,71 +175,3 @@ mod banned_user_list { assert_eq!(response.status, 401); } } - -pub mod fixtures { - use std::sync::Arc; - - use rand::Rng; - use torrust_index_backend::databases::database::connect_database; - - use super::{LoggedInUserData, LoginForm, RegisteredUser, RegistrationForm, SuccessfulLoginResponse}; - use crate::e2e::environment::TestEnv; - - pub async fn logged_in_admin() -> LoggedInUserData { - let user = logged_in_user().await; - - // todo: get from E2E config file `config-idx-back.toml.local` - let connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc"; - - let database = Arc::new(connect_database(connect_url).await.expect("Database error.")); - - let user_profile = database.get_user_profile_from_username(&user.username).await.unwrap(); - - database.grant_admin_role(user_profile.user_id).await.unwrap(); - - user - } - - pub async fn logged_in_user() -> LoggedInUserData { - let client = TestEnv::default().unauthenticated_client(); - - let registered_user = registered_user().await; - - let response = client - .login_user(LoginForm { - login: registered_user.username.clone(), - password: registered_user.password.clone(), - }) - .await; - - let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); - res.data - } - - pub async fn registered_user() -> RegisteredUser { - let client = TestEnv::default().unauthenticated_client(); - - let form = random_user_registration(); - - let registered_user = form.clone(); - - let _response = client.register_user(form).await; - - registered_user - } - - pub fn random_user_registration() -> RegistrationForm { - let user_id = random_user_id(); - RegistrationForm { - username: format!("username_{user_id}"), - email: Some(format!("email_{user_id}@email.com")), - password: "password".to_string(), - confirm_password: "password".to_string(), - } - } - - fn random_user_id() -> u64 { - let mut rng = rand::thread_rng(); - rng.gen_range(0..1_000_000) - } -} diff --git a/tests/e2e/contexts/user/mod.rs b/tests/e2e/contexts/user/mod.rs new file mode 100644 index 00000000..2001efb8 --- /dev/null +++ b/tests/e2e/contexts/user/mod.rs @@ -0,0 +1,2 @@ +pub mod contract; +pub mod steps; diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/contexts/user/steps.rs new file mode 100644 index 00000000..a94a6e49 --- /dev/null +++ b/tests/e2e/contexts/user/steps.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use torrust_index_backend::databases::database::connect_database; + +use crate::common::contexts::user::fixtures::random_user_registration; +use crate::common::contexts::user::forms::{LoginForm, RegisteredUser}; +use crate::common::contexts::user::responses::{LoggedInUserData, SuccessfulLoginResponse}; +use crate::e2e::environment::TestEnv; + +pub async fn logged_in_admin() -> LoggedInUserData { + let user = logged_in_user().await; + + // todo: get from E2E config file `config-idx-back.toml.local` + let connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc"; + + let database = Arc::new(connect_database(connect_url).await.expect("Database error.")); + + let user_profile = database.get_user_profile_from_username(&user.username).await.unwrap(); + + database.grant_admin_role(user_profile.user_id).await.unwrap(); + + user +} + +pub async fn logged_in_user() -> LoggedInUserData { + let client = TestEnv::default().unauthenticated_client(); + + let registered_user = registered_user().await; + + let response = client + .login_user(LoginForm { + login: registered_user.username.clone(), + password: registered_user.password.clone(), + }) + .await; + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + res.data +} + +pub async fn registered_user() -> RegisteredUser { + let client = TestEnv::default().unauthenticated_client(); + + let form = random_user_registration(); + + let registered_user = form.clone(); + + let _response = client.register_user(form).await; + + registered_user +} diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index f69e2024..186c5d2d 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -1,5 +1,5 @@ -use super::connection_info::{anonymous_connection, authenticated_connection}; -use crate::e2e::client::Client; +use crate::common::client::Client; +use crate::common::connection_info::{anonymous_connection, authenticated_connection}; pub struct TestEnv { pub authority: String, diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index ce9ef1d1..d80b0505 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -29,10 +29,5 @@ //! clean database, delete the files before running the tests. //! //! See the [docker documentation](https://github.com/torrust/torrust-index-backend/tree/develop/docker) for more information on how to run the API. -mod asserts; -mod client; -mod connection_info; mod contexts; -mod environment; -mod http; -mod responses; +pub mod environment; diff --git a/tests/integration/contexts/about.rs b/tests/integration/contexts/about.rs new file mode 100644 index 00000000..a22b8538 --- /dev/null +++ b/tests/integration/contexts/about.rs @@ -0,0 +1,5 @@ +#[tokio::test] +#[ignore] +async fn it_should_load_the_about_page_with_information_about_the_api() { + // todo: launch isolated API server for this test +} diff --git a/tests/integration/contexts/mod.rs b/tests/integration/contexts/mod.rs new file mode 100644 index 00000000..0fc29088 --- /dev/null +++ b/tests/integration/contexts/mod.rs @@ -0,0 +1 @@ +mod about; diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs new file mode 100644 index 00000000..22207461 --- /dev/null +++ b/tests/integration/mod.rs @@ -0,0 +1 @@ +mod contexts; diff --git a/tests/mod.rs b/tests/mod.rs index f90fa4f2..d99c2c97 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,3 +1,5 @@ +mod common; mod databases; mod e2e; -pub mod upgrades; +mod integration; +mod upgrades; From 89e544ecdf7ac17eba4d2fd11e4b5b40abfa78ce Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Sat, 29 Apr 2023 08:18:15 +0100 Subject: [PATCH 111/357] feat: server port assigned by OS with port 0 This allow the user to set the por to 0 and the OS will assign a free port to the server. This will be used by iuntegration tests to run each test with a different app instance running on a different port. --- project-words.txt | 1 + src/bin/main.rs | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/project-words.txt b/project-words.txt index 220cba8b..ab196407 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1,4 +1,5 @@ actix +addrs AUTOINCREMENT bencode bencoded diff --git a/src/bin/main.rs b/src/bin/main.rs index f95ee91d..09046fb6 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -13,7 +13,7 @@ use torrust_index_backend::routes; use torrust_index_backend::tracker::TrackerService; #[actix_web::main] -async fn main() -> std::io::Result<()> { +async fn main() -> Result<(), std::io::Error> { let configuration = init_configuration().await; logging::setup(); @@ -57,22 +57,27 @@ async fn main() -> std::io::Result<()> { } }); + // todo: get IP from settings + let ip = "0.0.0.0".to_string(); let port = settings.net.port; drop(settings); - println!("Listening on http://0.0.0.0:{}", port); - - HttpServer::new(move || { + let server = HttpServer::new(move || { App::new() .wrap(Cors::permissive()) .app_data(web::Data::new(app_data.clone())) .wrap(middleware::Logger::default()) .configure(routes::init_routes) }) - .bind(("0.0.0.0", port))? - .run() - .await + .bind((ip.clone(), port)) + .expect("can't bind server to socket address"); + + let server_port = server.addrs()[0].port(); + + println!("Listening on http://{}:{}", ip, server_port); + + server.run().await } async fn init_configuration() -> Configuration { From 22118713d33b805b5c1b5f35b4a115b586cfdb38 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Sat, 29 Apr 2023 09:20:12 +0100 Subject: [PATCH 112/357] refactor: extract app from main To be able to run it from tests. --- src/app.rs | 104 ++++++++++++++++++ src/bin/main.rs | 95 +--------------- src/bootstrap/config.rs | 28 +++++ src/bootstrap/logging.rs | 2 +- src/bootstrap/mod.rs | 1 + src/config.rs | 21 ++-- .../commands/import_tracker_statistics.rs | 14 +-- src/lib.rs | 1 + src/routes/settings.rs | 3 +- src/tracker.rs | 4 +- 10 files changed, 160 insertions(+), 113 deletions(-) create mode 100644 src/app.rs create mode 100644 src/bootstrap/config.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 00000000..16c6a34f --- /dev/null +++ b/src/app.rs @@ -0,0 +1,104 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use actix_cors::Cors; +use actix_web::dev::Server; +use actix_web::{middleware, web, App, HttpServer}; +use log::info; + +use crate::auth::AuthorizationService; +use crate::bootstrap::logging; +use crate::common::AppData; +use crate::config::Configuration; +use crate::databases::database::connect_database; +use crate::mailer::MailerService; +use crate::routes; +use crate::tracker::TrackerService; + +pub struct Running { + pub api_server: Server, + pub socket_address: SocketAddr, + pub tracker_data_importer_handle: tokio::task::JoinHandle<()>, +} + +pub async fn run(configuration: Configuration) -> Running { + logging::setup(); + + let cfg = Arc::new(configuration); + + // Get configuration settings needed to build the app dependencies and + // services: main API server and tracker torrents importer. + + let settings = cfg.settings.read().await; + + let database_connect_url = settings.database.connect_url.clone(); + let database_torrent_info_update_interval = settings.database.torrent_info_update_interval; + let net_port = settings.net.port; + + // IMPORTANT: drop settings before starting server to avoid read locks that + // leads to requests hanging. + drop(settings); + + // Build app dependencies + + let database = Arc::new(connect_database(&database_connect_url).await.expect("Database error.")); + let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); + let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); + let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); + + // Build app container + + let app_data = Arc::new(AppData::new( + cfg.clone(), + database.clone(), + auth.clone(), + tracker_service.clone(), + mailer_service, + )); + + // Start repeating task to import tracker torrent data and updating + // seeders and leechers info. + + let weak_tracker_service = Arc::downgrade(&tracker_service); + + let tracker_data_importer_handle = tokio::spawn(async move { + let interval = std::time::Duration::from_secs(database_torrent_info_update_interval); + let mut interval = tokio::time::interval(interval); + interval.tick().await; // first tick is immediate... + loop { + interval.tick().await; + if let Some(tracker) = weak_tracker_service.upgrade() { + let _ = tracker.update_torrents().await; + } else { + break; + } + } + }); + + // Start main API server + + // todo: get IP from settings + let ip = "0.0.0.0".to_string(); + + let server = HttpServer::new(move || { + App::new() + .wrap(Cors::permissive()) + .app_data(web::Data::new(app_data.clone())) + .wrap(middleware::Logger::default()) + .configure(routes::init_routes) + }) + .bind((ip, net_port)) + .expect("can't bind server to socket address"); + + let socket_address = server.addrs()[0]; + + let running_server = server.run(); + + info!("Listening on http://{}", socket_address); + + Running { + api_server: running_server, + socket_address, + tracker_data_importer_handle, + } +} diff --git a/src/bin/main.rs b/src/bin/main.rs index 09046fb6..706c74e3 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,98 +1,11 @@ -use std::env; -use std::sync::Arc; - -use actix_cors::Cors; -use actix_web::{middleware, web, App, HttpServer}; -use torrust_index_backend::auth::AuthorizationService; -use torrust_index_backend::bootstrap::logging; -use torrust_index_backend::common::AppData; -use torrust_index_backend::config::{Configuration, CONFIG_ENV_VAR_NAME, CONFIG_PATH}; -use torrust_index_backend::databases::database::connect_database; -use torrust_index_backend::mailer::MailerService; -use torrust_index_backend::routes; -use torrust_index_backend::tracker::TrackerService; +use torrust_index_backend::app; +use torrust_index_backend::bootstrap::config::init_configuration; #[actix_web::main] async fn main() -> Result<(), std::io::Error> { let configuration = init_configuration().await; - logging::setup(); - - let cfg = Arc::new(configuration); - - let settings = cfg.settings.read().await; - - let database = Arc::new( - connect_database(&settings.database.connect_url) - .await - .expect("Database error."), - ); - - let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); - let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); - let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); - let app_data = Arc::new(AppData::new( - cfg.clone(), - database.clone(), - auth.clone(), - tracker_service.clone(), - mailer_service.clone(), - )); - - let interval = settings.database.torrent_info_update_interval; - let weak_tracker_service = Arc::downgrade(&tracker_service); - - // repeating task, update all seeders and leechers info - tokio::spawn(async move { - let interval = std::time::Duration::from_secs(interval); - let mut interval = tokio::time::interval(interval); - interval.tick().await; // first tick is immediate... - loop { - interval.tick().await; - if let Some(tracker) = weak_tracker_service.upgrade() { - let _ = tracker.update_torrents().await; - } else { - break; - } - } - }); - - // todo: get IP from settings - let ip = "0.0.0.0".to_string(); - let port = settings.net.port; - - drop(settings); - - let server = HttpServer::new(move || { - App::new() - .wrap(Cors::permissive()) - .app_data(web::Data::new(app_data.clone())) - .wrap(middleware::Logger::default()) - .configure(routes::init_routes) - }) - .bind((ip.clone(), port)) - .expect("can't bind server to socket address"); - - let server_port = server.addrs()[0].port(); - - println!("Listening on http://{}:{}", ip, server_port); - - server.run().await -} - -async fn init_configuration() -> Configuration { - if env::var(CONFIG_ENV_VAR_NAME).is_ok() { - println!("Loading configuration from env var `{}`", CONFIG_ENV_VAR_NAME); - - Configuration::load_from_env_var(CONFIG_ENV_VAR_NAME).unwrap() - } else { - println!("Loading configuration from config file `{}`", CONFIG_PATH); + let app = app::run(configuration).await; - match Configuration::load_from_file().await { - Ok(config) => config, - Err(error) => { - panic!("{}", error) - } - } - } + app.api_server.await } diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs new file mode 100644 index 00000000..1f130344 --- /dev/null +++ b/src/bootstrap/config.rs @@ -0,0 +1,28 @@ +use std::env; + +pub const CONFIG_PATH: &str = "./config.toml"; +pub const CONFIG_ENV_VAR_NAME: &str = "TORRUST_IDX_BACK_CONFIG"; + +use crate::config::Configuration; + +/// Initialize configuration from file or env var. +/// +/// # Panics +/// +/// Will panic if configuration is not found or cannot be parsed +pub async fn init_configuration() -> Configuration { + if env::var(CONFIG_ENV_VAR_NAME).is_ok() { + println!("Loading configuration from env var `{}`", CONFIG_ENV_VAR_NAME); + + Configuration::load_from_env_var(CONFIG_ENV_VAR_NAME).unwrap() + } else { + println!("Loading configuration from config file `{}`", CONFIG_PATH); + + match Configuration::load_from_file(CONFIG_PATH).await { + Ok(config) => config, + Err(error) => { + panic!("{}", error) + } + } + } +} diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs index a1441827..303c5775 100644 --- a/src/bootstrap/logging.rs +++ b/src/bootstrap/logging.rs @@ -29,7 +29,7 @@ pub fn setup() { fn config_level_or_default(log_level: &Option) -> LevelFilter { match log_level { - None => log::LevelFilter::Warn, + None => log::LevelFilter::Info, Some(level) => LevelFilter::from_str(level).unwrap(), } } diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 31348d2f..8b0e66a6 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1 +1,2 @@ +pub mod config; pub mod logging; diff --git a/src/config.rs b/src/config.rs index 65f1a25e..627808ed 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,9 +6,6 @@ use log::warn; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -pub const CONFIG_PATH: &str = "./config.toml"; -pub const CONFIG_ENV_VAR_NAME: &str = "TORRUST_IDX_BACK_CONFIG"; - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Website { pub name: String, @@ -96,7 +93,7 @@ impl Configuration { mode: TrackerMode::Public, api_url: "http://localhost:1212".to_string(), token: "MyAccessToken".to_string(), - token_valid_seconds: 7257600, + token_valid_seconds: 7_257_600, }, net: Network { port: 3000, @@ -129,19 +126,19 @@ impl Configuration { } /// Loads the configuration from the configuration file. - pub async fn load_from_file() -> Result { + pub async fn load_from_file(config_path: &str) -> Result { let config_builder = Config::builder(); #[allow(unused_assignments)] let mut config = Config::default(); - if Path::new(CONFIG_PATH).exists() { - config = config_builder.add_source(File::with_name(CONFIG_PATH)).build()?; + if Path::new(config_path).exists() { + config = config_builder.add_source(File::with_name(config_path)).build()?; } else { warn!("No config file found."); warn!("Creating config file.."); let config = Configuration::default(); - let _ = config.save_to_file().await; + let _ = config.save_to_file(config_path).await; return Err(ConfigError::Message( "Please edit the config.TOML in the root folder and restart the tracker.".to_string(), )); @@ -183,24 +180,24 @@ impl Configuration { } } - pub async fn save_to_file(&self) -> Result<(), ()> { + pub async fn save_to_file(&self, config_path: &str) -> Result<(), ()> { let settings = self.settings.read().await; let toml_string = toml::to_string(&*settings).expect("Could not encode TOML value"); drop(settings); - fs::write(CONFIG_PATH, toml_string).expect("Could not write to file!"); + fs::write(config_path, toml_string).expect("Could not write to file!"); Ok(()) } - pub async fn update_settings(&self, new_settings: TorrustConfig) -> Result<(), ()> { + pub async fn update_settings(&self, new_settings: TorrustConfig, config_path: &str) -> Result<(), ()> { let mut settings = self.settings.write().await; *settings = new_settings; drop(settings); - let _ = self.save_to_file().await; + let _ = self.save_to_file(config_path).await; Ok(()) } diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 9d60fcf2..66f9f49c 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use derive_more::{Display, Error}; use text_colorizer::*; -use crate::config::Configuration; +use crate::bootstrap::config::init_configuration; +use crate::bootstrap::logging; use crate::databases::database::connect_database; use crate::tracker::TrackerService; @@ -57,12 +58,11 @@ pub async fn run_importer() { pub async fn import(_args: &Arguments) { println!("Importing statistics from linked tracker ..."); - let cfg = match Configuration::load_from_file().await { - Ok(config) => Arc::new(config), - Err(error) => { - panic!("{}", error) - } - }; + let configuration = init_configuration().await; + + logging::setup(); + + let cfg = Arc::new(configuration); let settings = cfg.settings.read().await; diff --git a/src/lib.rs b/src/lib.rs index 7958c644..89776482 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod app; pub mod auth; pub mod bootstrap; pub mod common; diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 414ebefb..08a1d821 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -1,5 +1,6 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use crate::bootstrap::config::CONFIG_PATH; use crate::common::WebAppData; use crate::config::TorrustConfig; use crate::errors::{ServiceError, ServiceResult}; @@ -59,7 +60,7 @@ pub async fn update_settings( return Err(ServiceError::Unauthorized); } - let _ = app_data.cfg.update_settings(payload.into_inner()).await; + let _ = app_data.cfg.update_settings(payload.into_inner(), CONFIG_PATH).await; let settings = app_data.cfg.settings.read().await; diff --git a/src/tracker.rs b/src/tracker.rs index 984ee451..62b61109 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use log::info; use serde::{Deserialize, Serialize}; use crate::config::Configuration; @@ -179,10 +180,11 @@ impl TrackerService { } pub async fn update_torrents(&self) -> Result<(), ServiceError> { - println!("Updating torrents ..."); + info!("Updating torrents ..."); let torrents = self.database.get_all_torrents_compact().await?; for torrent in torrents { + info!("Updating torrent {} ...", torrent.torrent_id); let _ = self .update_torrent_tracker_stats(torrent.torrent_id, &torrent.info_hash) .await; From 6d5e0025e32eb801097dc59dce0641a003a2caba Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Sat, 29 Apr 2023 14:26:33 +0100 Subject: [PATCH 113/357] test: isolated environments for integration testing It allows integration test to run a custom env (with a custom configuration) and totally isolated from other tests. The test env used a socket address assigned from the OS (free port). You can do that by setting the port number to 0 in the config.toml file: ``` [net] port = 0 ``` --- project-words.txt | 1 + src/app.rs | 5 +- src/bootstrap/logging.rs | 2 +- src/config.rs | 27 ++++--- src/tracker.rs | 10 ++- tests/common/mod.rs | 1 + tests/common/random.rs | 10 +++ tests/integration/app_starter.rs | 111 ++++++++++++++++++++++++++++ tests/integration/contexts/about.rs | 23 +++++- tests/integration/environment.rs | 81 ++++++++++++++++++++ tests/integration/mod.rs | 2 + 11 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 tests/common/random.rs create mode 100644 tests/integration/app_starter.rs create mode 100644 tests/integration/environment.rs diff --git a/project-words.txt b/project-words.txt index ab196407..3b609bb7 100644 --- a/project-words.txt +++ b/project-words.txt @@ -32,6 +32,7 @@ NCCA nilm nocapture Oberhachingerstr +oneshot ppassword reqwest Roadmap diff --git a/src/app.rs b/src/app.rs index 16c6a34f..c371936a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -94,7 +94,10 @@ pub async fn run(configuration: Configuration) -> Running { let running_server = server.run(); - info!("Listening on http://{}", socket_address); + let starting_message = format!("Listening on http://{}", socket_address); + info!("{}", starting_message); + // Logging could be disabled or redirected to file. So print to stdout too. + println!("{}", starting_message); Running { api_server: running_server, diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs index 303c5775..a1441827 100644 --- a/src/bootstrap/logging.rs +++ b/src/bootstrap/logging.rs @@ -29,7 +29,7 @@ pub fn setup() { fn config_level_or_default(log_level: &Option) -> LevelFilter { match log_level { - None => log::LevelFilter::Info, + None => log::LevelFilter::Warn, Some(level) => LevelFilter::from_str(level).unwrap(), } } diff --git a/src/config.rs b/src/config.rs index 627808ed..ea11f554 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,9 @@ pub struct Tracker { pub token_valid_seconds: u64, } +/// Port 0 means that the OS will choose a random free port. +pub const FREE_PORT: u16 = 0; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Network { pub port: u16, @@ -77,14 +80,9 @@ pub struct TorrustConfig { pub mail: Mail, } -#[derive(Debug)] -pub struct Configuration { - pub settings: RwLock, -} - -impl Configuration { - pub fn default() -> Configuration { - let torrust_config = TorrustConfig { +impl TorrustConfig { + pub fn default() -> Self { + Self { website: Website { name: "Torrust".to_string(), }, @@ -118,10 +116,19 @@ impl Configuration { server: "".to_string(), port: 25, }, - }; + } + } +} +#[derive(Debug)] +pub struct Configuration { + pub settings: RwLock, +} + +impl Configuration { + pub fn default() -> Configuration { Configuration { - settings: RwLock::new(torrust_config), + settings: RwLock::new(TorrustConfig::default()), } } diff --git a/src/tracker.rs b/src/tracker.rs index 62b61109..cb69bab7 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use log::info; +use log::{error, info}; use serde::{Deserialize, Serialize}; use crate::config::Configuration; @@ -185,9 +185,15 @@ impl TrackerService { for torrent in torrents { info!("Updating torrent {} ...", torrent.torrent_id); - let _ = self + let ret = self .update_torrent_tracker_stats(torrent.torrent_id, &torrent.info_hash) .await; + if let Some(err) = ret.err() { + error!( + "Error updating torrent tracker stats for torrent {}: {:?}", + torrent.torrent_id, err + ); + } } Ok(()) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 33956c17..cbf6de10 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -3,4 +3,5 @@ pub mod client; pub mod connection_info; pub mod contexts; pub mod http; +pub mod random; pub mod responses; diff --git a/tests/common/random.rs b/tests/common/random.rs new file mode 100644 index 00000000..2133dcd2 --- /dev/null +++ b/tests/common/random.rs @@ -0,0 +1,10 @@ +//! Random data generators for testing. +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; + +/// Returns a random alphanumeric string of a certain size. +/// +/// It is useful for generating random names, IDs, etc for testing. +pub fn string(size: usize) -> String { + thread_rng().sample_iter(&Alphanumeric).take(size).map(char::from).collect() +} diff --git a/tests/integration/app_starter.rs b/tests/integration/app_starter.rs new file mode 100644 index 00000000..1e378c4d --- /dev/null +++ b/tests/integration/app_starter.rs @@ -0,0 +1,111 @@ +use std::net::SocketAddr; + +use log::info; +use tokio::sync::{oneshot, RwLock}; +use torrust_index_backend::app; +use torrust_index_backend::config::{Configuration, TorrustConfig}; + +/// It launches the app and provides a way to stop it. +pub struct AppStarter { + configuration: TorrustConfig, + /// The application binary state (started or not): + /// - `None`: if the app is not started, + /// - `RunningState`: if the app was started. + running_state: Option, +} + +impl AppStarter { + pub fn with_custom_configuration(configuration: TorrustConfig) -> Self { + Self { + configuration, + running_state: None, + } + } + + pub async fn start(&mut self) { + let configuration = Configuration { + settings: RwLock::new(self.configuration.clone()), + }; + + // Open a channel to communicate back with this function + let (tx, rx) = oneshot::channel::(); + + // Launch the app in a separate task + let app_handle = tokio::spawn(async move { + let app = app::run(configuration).await; + + // Send the socket address back to the main thread + tx.send(AppStarted { + socket_addr: app.socket_address, + }) + .expect("the app should not be dropped"); + + app.api_server.await + }); + + // Wait until the app is started + let socket_addr = match rx.await { + Ok(msg) => msg.socket_addr, + Err(e) => panic!("the app was dropped: {e}"), + }; + + let running_state = RunningState { app_handle, socket_addr }; + + info!("Test environment started. Listening on {}", running_state.socket_addr); + + // Update the app state + self.running_state = Some(running_state); + } + + pub fn stop(&mut self) { + match &self.running_state { + Some(running_state) => { + running_state.app_handle.abort(); + self.running_state = None; + } + None => {} + } + } + + pub fn server_socket_addr(&self) -> Option { + self.running_state.as_ref().map(|running_state| running_state.socket_addr) + } +} + +#[derive(Debug)] +pub struct AppStarted { + pub socket_addr: SocketAddr, +} + +/// Stores the app state when it is running. +pub struct RunningState { + app_handle: tokio::task::JoinHandle>, + pub socket_addr: SocketAddr, +} + +impl Drop for AppStarter { + /// Child threads spawned with `tokio::spawn()` and tasks spawned with + /// `async { }` blocks will not be automatically killed when the owner of + /// the struct that spawns them goes out of scope. + /// + /// The `tokio::spawn()` function and `async { }` blocks create an + /// independent task that runs on a separate thread or the same thread, + /// respectively. The task will continue to run until it completes, even if + /// the owner of the struct that spawned it goes out of scope. + /// + /// However, it's important to note that dropping the owner of the struct + /// may cause the task to be orphaned, which means that the task is no + /// longer associated with any parent task or thread. Orphaned tasks can + /// continue running in the background, consuming system resources, and may + /// eventually cause issues if left unchecked. + /// + /// To avoid orphaned tasks, we ensure that the app ois stopped when the + /// owner of the struct goes out of scope. + /// + /// This avoids having to call `TestEnv::stop()` explicitly at the end of + /// each test. + fn drop(&mut self) { + // Stop the app when the owner of the struct goes out of scope + self.stop(); + } +} diff --git a/tests/integration/contexts/about.rs b/tests/integration/contexts/about.rs index a22b8538..8a167835 100644 --- a/tests/integration/contexts/about.rs +++ b/tests/integration/contexts/about.rs @@ -1,5 +1,24 @@ +use crate::common::asserts::{assert_response_title, assert_text_ok}; +use crate::integration::environment::TestEnv; + #[tokio::test] -#[ignore] async fn it_should_load_the_about_page_with_information_about_the_api() { - // todo: launch isolated API server for this test + let env = TestEnv::running().await; + let client = env.unauthenticated_client(); + + let response = client.about().await; + + assert_text_ok(&response); + assert_response_title(&response, "About"); +} + +#[tokio::test] +async fn it_should_load_the_license_page_at_the_api_entrypoint() { + let env = TestEnv::running().await; + let client = env.unauthenticated_client(); + + let response = client.license().await; + + assert_text_ok(&response); + assert_response_title(&response, "Licensing"); } diff --git a/tests/integration/environment.rs b/tests/integration/environment.rs new file mode 100644 index 00000000..4cf65582 --- /dev/null +++ b/tests/integration/environment.rs @@ -0,0 +1,81 @@ +use tempfile::TempDir; +use torrust_index_backend::config::{TorrustConfig, FREE_PORT}; + +use super::app_starter::AppStarter; +use crate::common::client::Client; +use crate::common::connection_info::{anonymous_connection, authenticated_connection}; +use crate::common::random; + +pub struct TestEnv { + pub app_starter: AppStarter, + pub temp_dir: TempDir, +} + +impl TestEnv { + /// Provides a running app instance for integration tests. + pub async fn running() -> Self { + let mut env = TestEnv::with_test_configuration(); + env.start().await; + env + } + + /// Provides a test environment with a default configuration for testing + /// application. + pub fn with_test_configuration() -> Self { + let temp_dir = TempDir::new().expect("failed to create a temporary directory"); + + let configuration = ephemeral(&temp_dir); + + let app_starter = AppStarter::with_custom_configuration(configuration); + + Self { app_starter, temp_dir } + } + + /// Starts the app. + pub async fn start(&mut self) { + self.app_starter.start().await; + } + + /// Provides an unauthenticated client for integration tests. + pub fn unauthenticated_client(&self) -> Client { + Client::new(anonymous_connection( + &self + .server_socket_addr() + .expect("app should be started to get the server socket address"), + )) + } + + /// Provides an authenticated client for integration tests. + pub fn _authenticated_client(&self, token: &str) -> Client { + Client::new(authenticated_connection( + &self + .server_socket_addr() + .expect("app should be started to get the server socket address"), + token, + )) + } + + /// Provides the API server socket address. + fn server_socket_addr(&self) -> Option { + self.app_starter.server_socket_addr().map(|addr| addr.to_string()) + } +} + +/// Provides a configuration with ephemeral data for testing. +fn ephemeral(temp_dir: &TempDir) -> TorrustConfig { + let mut configuration = TorrustConfig::default(); + + // Ephemeral API port + configuration.net.port = FREE_PORT; + + // Ephemeral SQLite database + configuration.database.connect_url = format!("sqlite://{}?mode=rwc", random_database_file_path_in(temp_dir)); + + configuration +} + +fn random_database_file_path_in(temp_dir: &TempDir) -> String { + let random_db_id = random::string(16); + let db_file_name = format!("data_{random_db_id}.db"); + temp_dir.path().join(db_file_name).to_string_lossy().to_string() +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 22207461..343f39d8 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1 +1,3 @@ +pub mod app_starter; mod contexts; +pub mod environment; From bce946fbab02ad3f550ba90b031565b7c8ffb38c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 May 2023 13:14:34 +0100 Subject: [PATCH 114/357] refactor: removed unneeded intermediary var --- tests/integration/contexts/about.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/integration/contexts/about.rs b/tests/integration/contexts/about.rs index 8a167835..8efe9d19 100644 --- a/tests/integration/contexts/about.rs +++ b/tests/integration/contexts/about.rs @@ -3,8 +3,7 @@ use crate::integration::environment::TestEnv; #[tokio::test] async fn it_should_load_the_about_page_with_information_about_the_api() { - let env = TestEnv::running().await; - let client = env.unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.about().await; @@ -14,8 +13,7 @@ async fn it_should_load_the_about_page_with_information_about_the_api() { #[tokio::test] async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let env = TestEnv::running().await; - let client = env.unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.license().await; From 36f17f1b1d720304da206798c43ad89a1932669b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 May 2023 14:10:24 +0100 Subject: [PATCH 115/357] tests: panic when E2E server env is not running It shows a more user-friendly message when you run E2E tests and the server is not responding on the default location http://localhost:3000 --- tests/common/client.rs | 15 +++++++++++++++ tests/e2e/contexts/about/contract.rs | 4 ++-- tests/e2e/contexts/category/contract.rs | 16 ++++++++-------- tests/e2e/contexts/category/steps.rs | 2 +- tests/e2e/contexts/root/contract.rs | 2 +- tests/e2e/contexts/settings/contract.rs | 8 ++++---- tests/e2e/contexts/torrent/contract.rs | 24 ++++++++++++------------ tests/e2e/contexts/torrent/steps.rs | 2 +- tests/e2e/contexts/user/contract.rs | 14 +++++++------- tests/e2e/contexts/user/steps.rs | 4 ++-- tests/e2e/environment.rs | 11 +++++++++++ 11 files changed, 64 insertions(+), 38 deletions(-) diff --git a/tests/common/client.rs b/tests/common/client.rs index cf35cdab..86295d6f 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -22,6 +22,12 @@ impl Client { } } + /// It checks if the server is running. + pub async fn server_is_running(&self) -> bool { + let response = self.http_client.inner_get("").await; + response.is_ok() + } + // Context: about pub async fn about(&self) -> TextResponse { @@ -181,6 +187,15 @@ impl Http { BinaryResponse::from(response).await } + pub async fn inner_get(&self, path: &str) -> Result { + reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path).clone()) + .send() + .await + } + pub async fn post(&self, path: &str, form: &T) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::new() diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs index 8c97831a..4378ba1a 100644 --- a/tests/e2e/contexts/about/contract.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -5,7 +5,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_with_information_about_the_api() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.about().await; @@ -16,7 +16,7 @@ async fn it_should_load_the_about_page_with_information_about_the_api() { #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.license().await; diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index 75df92ef..4c952b81 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -17,7 +17,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.get_categories().await; @@ -32,7 +32,7 @@ async fn it_should_return_a_category_list() { let response = add_category(&category_name).await; assert_eq!(response.status, 200); - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.get_categories().await; @@ -50,7 +50,7 @@ async fn it_should_return_a_category_list() { #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client .add_category(AddCategoryForm { @@ -66,7 +66,7 @@ async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_adding_a_new_category_to_non_admins() { let logged_non_admin = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&logged_non_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_non_admin.token); let response = client .add_category(AddCategoryForm { @@ -82,7 +82,7 @@ async fn it_should_not_allow_adding_a_new_category_to_non_admins() { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_add_new_categories() { let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); let category_name = random_category_name(); @@ -119,7 +119,7 @@ async fn it_should_not_allow_adding_duplicated_categories() { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_delete_categories() { let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); // Add a category let category_name = random_category_name(); @@ -151,7 +151,7 @@ async fn it_should_not_allow_non_admins_to_delete_categories() { assert_eq!(response.status, 200); let logged_in_non_admin = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&logged_in_non_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_non_admin.token); let response = client .delete_category(DeleteCategoryForm { @@ -171,7 +171,7 @@ async fn it_should_not_allow_guests_to_delete_categories() { let response = add_category(&category_name).await; assert_eq!(response.status, 200); - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client .delete_category(DeleteCategoryForm { diff --git a/tests/e2e/contexts/category/steps.rs b/tests/e2e/contexts/category/steps.rs index 58794a96..4b418760 100644 --- a/tests/e2e/contexts/category/steps.rs +++ b/tests/e2e/contexts/category/steps.rs @@ -5,7 +5,7 @@ use crate::e2e::environment::TestEnv; pub async fn add_category(category_name: &str) -> TextResponse { let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); client .add_category(AddCategoryForm { diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs index c82d8a8d..77feaa84 100644 --- a/tests/e2e/contexts/root/contract.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -5,7 +5,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_at_the_api_entrypoint() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.root().await; diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 11dd88ad..50834c37 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -7,7 +7,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_get_the_public_settings() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.get_public_settings().await; @@ -31,7 +31,7 @@ async fn it_should_allow_guests_to_get_the_public_settings() { #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_get_the_site_name() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.get_site_name().await; @@ -48,7 +48,7 @@ async fn it_should_allow_guests_to_get_the_site_name() { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_get_all_the_settings() { let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); let response = client.get_settings().await; @@ -102,7 +102,7 @@ async fn it_should_allow_admins_to_get_all_the_settings() { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_update_all_the_settings() { let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); // todo: we can't actually change the settings because it would affect other E2E tests. // Location for the `config.toml` file is hardcoded. We could use a ENV variable to change it. diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 591396b1..68dc91f9 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -39,7 +39,7 @@ mod for_guests { let uploader = logged_in_user().await; let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader).await; - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.get_torrents().await; @@ -56,7 +56,7 @@ mod for_guests { let uploader = logged_in_user().await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.get_torrent(uploaded_torrent.torrent_id).await; @@ -103,7 +103,7 @@ mod for_guests { let uploader = logged_in_user().await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.download_torrent(uploaded_torrent.torrent_id).await; @@ -132,7 +132,7 @@ mod for_guests { let uploader = logged_in_user().await; let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let response = client.delete_torrent(uploaded_torrent.torrent_id).await; @@ -152,7 +152,7 @@ mod for_authenticated_users { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_authenticated_users_to_upload_new_torrents() { let uploader = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&uploader.token); + let client = TestEnv::running().await.authenticated_client(&uploader.token); let test_torrent = random_torrent(); @@ -175,7 +175,7 @@ mod for_authenticated_users { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { let uploader = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&uploader.token); + let client = TestEnv::running().await.authenticated_client(&uploader.token); let mut test_torrent = random_torrent(); @@ -192,7 +192,7 @@ mod for_authenticated_users { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { let uploader = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&uploader.token); + let client = TestEnv::running().await.authenticated_client(&uploader.token); // Upload the first torrent let first_torrent = random_torrent(); @@ -213,7 +213,7 @@ mod for_authenticated_users { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_uploading_a_torrent_with_a_infohash_that_already_exists() { let uploader = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&uploader.token); + let client = TestEnv::running().await.authenticated_client(&uploader.token); // Upload the first torrent let first_torrent = random_torrent(); @@ -243,7 +243,7 @@ mod for_authenticated_users { let uploader = logged_in_user().await; let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; - let client = TestEnv::default().authenticated_client(&uploader.token); + let client = TestEnv::running().await.authenticated_client(&uploader.token); let response = client.delete_torrent(uploaded_torrent.torrent_id).await; @@ -264,7 +264,7 @@ mod for_authenticated_users { let uploader = logged_in_user().await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; - let client = TestEnv::default().authenticated_client(&uploader.token); + let client = TestEnv::running().await.authenticated_client(&uploader.token); let new_title = format!("{}-new-title", test_torrent.index_info.title); let new_description = format!("{}-new-description", test_torrent.index_info.description); @@ -303,7 +303,7 @@ mod for_authenticated_users { let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; let admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&admin.token); + let client = TestEnv::running().await.authenticated_client(&admin.token); let response = client.delete_torrent(uploaded_torrent.torrent_id).await; @@ -320,7 +320,7 @@ mod for_authenticated_users { let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); let new_title = format!("{}-new-title", test_torrent.index_info.title); let new_description = format!("{}-new-description", test_torrent.index_info.description); diff --git a/tests/e2e/contexts/torrent/steps.rs b/tests/e2e/contexts/torrent/steps.rs index 1d34e493..d1325d03 100644 --- a/tests/e2e/contexts/torrent/steps.rs +++ b/tests/e2e/contexts/torrent/steps.rs @@ -13,7 +13,7 @@ pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (Tes /// Upload a torrent to the index pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo) -> TorrentListedInIndex { - let client = TestEnv::default().authenticated_client(&uploader.token); + let client = TestEnv::running().await.authenticated_client(&uploader.token); let form: UploadTorrentMultipartForm = torrent.clone().into(); diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index dd3c333e..e5423aa0 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -38,7 +38,7 @@ the mailcatcher API. #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_a_guess_user_to_register() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let form = random_user_registration(); @@ -54,7 +54,7 @@ async fn it_should_allow_a_guess_user_to_register() { #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_a_registered_user_to_login() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let registered_user = registered_user().await; @@ -78,7 +78,7 @@ async fn it_should_allow_a_registered_user_to_login() { #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let logged_in_user = logged_in_user().await; @@ -101,7 +101,7 @@ async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week() { let logged_in_user = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&logged_in_user.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_user.token); let response = client .renew_token(TokenRenewalForm { @@ -135,7 +135,7 @@ mod banned_user_list { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_an_admin_to_ban_a_user() { let logged_in_admin = logged_in_admin().await; - let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); let registered_user = registered_user().await; @@ -154,7 +154,7 @@ mod banned_user_list { #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_a_non_admin_to_ban_a_user() { let logged_non_admin = logged_in_user().await; - let client = TestEnv::default().authenticated_client(&logged_non_admin.token); + let client = TestEnv::running().await.authenticated_client(&logged_non_admin.token); let registered_user = registered_user().await; @@ -166,7 +166,7 @@ mod banned_user_list { #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_guess_to_ban_a_user() { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let registered_user = registered_user().await; diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/contexts/user/steps.rs index a94a6e49..896153c2 100644 --- a/tests/e2e/contexts/user/steps.rs +++ b/tests/e2e/contexts/user/steps.rs @@ -23,7 +23,7 @@ pub async fn logged_in_admin() -> LoggedInUserData { } pub async fn logged_in_user() -> LoggedInUserData { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let registered_user = registered_user().await; @@ -39,7 +39,7 @@ pub async fn logged_in_user() -> LoggedInUserData { } pub async fn registered_user() -> RegisteredUser { - let client = TestEnv::default().unauthenticated_client(); + let client = TestEnv::running().await.unauthenticated_client(); let form = random_user_registration(); diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 186c5d2d..676253fe 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -6,6 +6,17 @@ pub struct TestEnv { } impl TestEnv { + pub async fn running() -> Self { + let env = Self::default(); + let client = env.unauthenticated_client(); + assert!( + client.server_is_running().await, + "Test server is not running on {}", + env.authority + ); + env + } + pub fn unauthenticated_client(&self) -> Client { Client::new(anonymous_connection(&self.authority)) } From 1df870b2c3aa4892789f0859e3cb4d39e5d012b9 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 May 2023 15:40:20 +0100 Subject: [PATCH 116/357] refactor: extract logic for setting up test envs E2E tests can be executed with an external shared running app (that must be started before running all the tests) or with an isolated env per test (that can be customized and it lasts only until the end of the test). --- tests/e2e/contexts/about/contract.rs | 2 +- tests/e2e/contexts/category/contract.rs | 2 +- tests/e2e/contexts/category/steps.rs | 2 +- tests/e2e/contexts/root/contract.rs | 2 +- tests/e2e/contexts/settings/contract.rs | 2 +- tests/e2e/contexts/torrent/contract.rs | 10 +++++----- tests/e2e/contexts/torrent/steps.rs | 2 +- tests/e2e/contexts/user/contract.rs | 4 ++-- tests/e2e/contexts/user/steps.rs | 2 +- tests/e2e/mod.rs | 3 +-- .../app_starter.rs | 0 .../environment.rs => environments/isolated.rs} | 6 ++++++ tests/environments/mod.rs | 3 +++ .../environment.rs => environments/shared.rs} | 17 ++++++++++++----- tests/integration/contexts/about.rs | 2 +- tests/integration/mod.rs | 4 +--- tests/mod.rs | 1 + 17 files changed, 39 insertions(+), 25 deletions(-) rename tests/{integration => environments}/app_starter.rs (100%) rename tests/{integration/environment.rs => environments/isolated.rs} (91%) create mode 100644 tests/environments/mod.rs rename tests/{e2e/environment.rs => environments/shared.rs} (59%) diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs index 4378ba1a..e9ec2a22 100644 --- a/tests/e2e/contexts/about/contract.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -1,6 +1,6 @@ //! API contract for `about` context. use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index 4c952b81..f3cc31f0 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -5,7 +5,7 @@ use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryFo use crate::common::contexts::category::responses::{AddedCategoryResponse, ListResponse}; use crate::e2e::contexts::category::steps::add_category; use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user}; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; /* todo: - it should allow adding a new category to authenticated clients diff --git a/tests/e2e/contexts/category/steps.rs b/tests/e2e/contexts/category/steps.rs index 4b418760..62bb6924 100644 --- a/tests/e2e/contexts/category/steps.rs +++ b/tests/e2e/contexts/category/steps.rs @@ -1,7 +1,7 @@ use crate::common::contexts::category::forms::AddCategoryForm; use crate::common::responses::TextResponse; use crate::e2e::contexts::user::steps::logged_in_admin; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; pub async fn add_category(category_name: &str) -> TextResponse { let logged_in_admin = logged_in_admin().await; diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs index 77feaa84..2d13132a 100644 --- a/tests/e2e/contexts/root/contract.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -1,6 +1,6 @@ //! API contract for `root` context. use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 50834c37..256db721 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -2,7 +2,7 @@ use crate::common::contexts::settings::form::UpdateSettingsForm; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; use crate::common::contexts::settings::{Auth, Database, Mail, Net, Settings, Tracker, Website}; use crate::e2e::contexts::user::steps::logged_in_admin; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 68dc91f9..ae6c1f1a 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -31,7 +31,7 @@ mod for_guests { }; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::logged_in_user; - use crate::e2e::environment::TestEnv; + use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] @@ -146,7 +146,7 @@ mod for_authenticated_users { use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; use crate::e2e::contexts::user::steps::logged_in_user; - use crate::e2e::environment::TestEnv; + use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] @@ -235,7 +235,7 @@ mod for_authenticated_users { mod and_non_admins { use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::logged_in_user; - use crate::e2e::environment::TestEnv; + use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] @@ -256,7 +256,7 @@ mod for_authenticated_users { use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::logged_in_user; - use crate::e2e::environment::TestEnv; + use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] @@ -294,7 +294,7 @@ mod for_authenticated_users { use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user}; - use crate::e2e::environment::TestEnv; + use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/contexts/torrent/steps.rs b/tests/e2e/contexts/torrent/steps.rs index d1325d03..db1ca016 100644 --- a/tests/e2e/contexts/torrent/steps.rs +++ b/tests/e2e/contexts/torrent/steps.rs @@ -2,7 +2,7 @@ use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent, To use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; use crate::common::contexts::user::responses::LoggedInUserData; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; /// Add a new random torrent to the index pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (TestTorrent, TorrentListedInIndex) { diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index e5423aa0..4322d40c 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -5,7 +5,7 @@ use crate::common::contexts::user::responses::{ SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, }; use crate::e2e::contexts::user::steps::{logged_in_user, registered_user}; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; /* @@ -129,7 +129,7 @@ mod banned_user_list { use crate::common::contexts::user::forms::Username; use crate::common::contexts::user::responses::BannedUserResponse; use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user, registered_user}; - use crate::e2e::environment::TestEnv; + use crate::environments::shared::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/contexts/user/steps.rs index 896153c2..843dae78 100644 --- a/tests/e2e/contexts/user/steps.rs +++ b/tests/e2e/contexts/user/steps.rs @@ -5,7 +5,7 @@ use torrust_index_backend::databases::database::connect_database; use crate::common::contexts::user::fixtures::random_user_registration; use crate::common::contexts::user::forms::{LoginForm, RegisteredUser}; use crate::common::contexts::user::responses::{LoggedInUserData, SuccessfulLoginResponse}; -use crate::e2e::environment::TestEnv; +use crate::environments::shared::TestEnv; pub async fn logged_in_admin() -> LoggedInUserData { let user = logged_in_user().await; diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index d80b0505..1ad6beb7 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -29,5 +29,4 @@ //! clean database, delete the files before running the tests. //! //! See the [docker documentation](https://github.com/torrust/torrust-index-backend/tree/develop/docker) for more information on how to run the API. -mod contexts; -pub mod environment; +pub mod contexts; diff --git a/tests/integration/app_starter.rs b/tests/environments/app_starter.rs similarity index 100% rename from tests/integration/app_starter.rs rename to tests/environments/app_starter.rs diff --git a/tests/integration/environment.rs b/tests/environments/isolated.rs similarity index 91% rename from tests/integration/environment.rs rename to tests/environments/isolated.rs index 4cf65582..a10354bb 100644 --- a/tests/integration/environment.rs +++ b/tests/environments/isolated.rs @@ -6,6 +6,9 @@ use crate::common::client::Client; use crate::common::connection_info::{anonymous_connection, authenticated_connection}; use crate::common::random; +/// Provides an isolated test environment for testing. The environment is +/// launched with a temporary directory and a default ephemeral configuration +/// before running the test. pub struct TestEnv { pub app_starter: AppStarter, pub temp_dir: TempDir, @@ -21,6 +24,7 @@ impl TestEnv { /// Provides a test environment with a default configuration for testing /// application. + #[must_use] pub fn with_test_configuration() -> Self { let temp_dir = TempDir::new().expect("failed to create a temporary directory"); @@ -37,6 +41,7 @@ impl TestEnv { } /// Provides an unauthenticated client for integration tests. + #[must_use] pub fn unauthenticated_client(&self) -> Client { Client::new(anonymous_connection( &self @@ -46,6 +51,7 @@ impl TestEnv { } /// Provides an authenticated client for integration tests. + #[must_use] pub fn _authenticated_client(&self, token: &str) -> Client { Client::new(authenticated_connection( &self diff --git a/tests/environments/mod.rs b/tests/environments/mod.rs new file mode 100644 index 00000000..abbdbd41 --- /dev/null +++ b/tests/environments/mod.rs @@ -0,0 +1,3 @@ +pub mod app_starter; +pub mod isolated; +pub mod shared; diff --git a/tests/e2e/environment.rs b/tests/environments/shared.rs similarity index 59% rename from tests/e2e/environment.rs rename to tests/environments/shared.rs index 676253fe..5c1df844 100644 --- a/tests/e2e/environment.rs +++ b/tests/environments/shared.rs @@ -1,26 +1,33 @@ use crate::common::client::Client; use crate::common::connection_info::{anonymous_connection, authenticated_connection}; +/// Provides a shared test environment for testing. All tests shared the same +/// application instance. pub struct TestEnv { pub authority: String, } impl TestEnv { + /// Provides a wrapper for an external running app instance. + /// + /// # Panics + /// + /// Will panic if the app is not running. This function requires the app to + /// be running to provide a valid environment. pub async fn running() -> Self { let env = Self::default(); let client = env.unauthenticated_client(); - assert!( - client.server_is_running().await, - "Test server is not running on {}", - env.authority - ); + let is_running = client.server_is_running().await; + assert!(is_running, "Test server is not running on {}", env.authority); env } + #[must_use] pub fn unauthenticated_client(&self) -> Client { Client::new(anonymous_connection(&self.authority)) } + #[must_use] pub fn authenticated_client(&self, token: &str) -> Client { Client::new(authenticated_connection(&self.authority, token)) } diff --git a/tests/integration/contexts/about.rs b/tests/integration/contexts/about.rs index 8efe9d19..88794909 100644 --- a/tests/integration/contexts/about.rs +++ b/tests/integration/contexts/about.rs @@ -1,5 +1,5 @@ use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::integration::environment::TestEnv; +use crate::environments::isolated::TestEnv; #[tokio::test] async fn it_should_load_the_about_page_with_information_about_the_api() { diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 343f39d8..0f9779b8 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1,3 +1 @@ -pub mod app_starter; -mod contexts; -pub mod environment; +pub mod contexts; diff --git a/tests/mod.rs b/tests/mod.rs index d99c2c97..519729e7 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,5 +1,6 @@ mod common; mod databases; mod e2e; +pub mod environments; mod integration; mod upgrades; From 14d0acba10fe10feebffc5cb787014f77f457a6b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 May 2023 17:24:08 +0100 Subject: [PATCH 117/357] refactor: run e2e tests with independent isolated servers too. E2E tests can be executed with a shared app instance with: ``` TORRUST_IDX_BACK_E2E_SHARED=true cargo test ``` or with an isolated instance per test: ``` cargo test ``` In the first case, you have to start the env manbually before running the tests, and manually stop it when tests are finished. In the second test, the test will launch a new app on a different socket using a different database. Shared env: - Requires docker - It's slower - It comes with a real traker Isolated env: - Doesn't require docker - It's faster - You have to mock the TrackerService (not implemented yet) --- .cargo/config.toml | 5 +- .github/workflows/develop.yml | 2 + Cargo.toml | 3 - docker/bin/run-e2e-tests.sh | 4 +- src/routes/torrent.rs | 2 + tests/common/client.rs | 8 + tests/common/connection_info.rs | 8 - tests/common/contexts/user/responses.rs | 2 +- tests/e2e/contexts/about/contract.rs | 13 +- tests/e2e/contexts/category/contract.rs | 78 ++++---- tests/e2e/contexts/category/steps.rs | 11 +- tests/e2e/contexts/root/contract.rs | 8 +- tests/e2e/contexts/settings/contract.rs | 72 +++----- tests/e2e/contexts/torrent/contract.rs | 228 ++++++++++++++++++------ tests/e2e/contexts/torrent/steps.rs | 19 +- tests/e2e/contexts/user/contract.rs | 64 ++++--- tests/e2e/contexts/user/steps.rs | 47 +++-- tests/e2e/environment.rs | 176 ++++++++++++++++++ tests/e2e/mod.rs | 47 ++--- tests/environments/app_starter.rs | 12 +- tests/environments/isolated.rs | 33 ++-- tests/environments/shared.rs | 14 +- tests/integration/contexts/about.rs | 22 --- tests/integration/contexts/mod.rs | 1 - tests/integration/mod.rs | 1 - tests/mod.rs | 1 - 26 files changed, 590 insertions(+), 291 deletions(-) create mode 100644 tests/e2e/environment.rs delete mode 100644 tests/integration/contexts/about.rs delete mode 100644 tests/integration/contexts/mod.rs delete mode 100644 tests/integration/mod.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index e67234cb..9e078085 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,7 +1,8 @@ [alias] cov = "llvm-cov" -cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" cov-html = "llvm-cov --html" +cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" time = "build --timings --all-targets" -e2e = "test --features e2e-tests" + + diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 59e05a71..f1a0247c 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -19,6 +19,8 @@ jobs: run: cargo check --all-targets - name: Clippy run: cargo clippy --all-targets + - name: Install torrent edition tool (needed for testing) + run: cargo install imdl - name: Unit and integration tests run: cargo test --all-targets - uses: taiki-e/install-action@cargo-llvm-cov diff --git a/Cargo.toml b/Cargo.toml index 60b40985..99486eba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,6 @@ default-run = "main" [profile.dev.package.sqlx-macros] opt-level = 3 -[features] -e2e-tests = [] - [dependencies] actix-web = "4.3" actix-multipart = "0.6" diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh index cc3b5351..211912df 100755 --- a/docker/bin/run-e2e-tests.sh +++ b/docker/bin/run-e2e-tests.sh @@ -53,8 +53,8 @@ sleep 20s # Just to make sure that everything is up and running docker ps -# Run E2E tests -cargo test --features e2e-tests +# Run E2E tests with shared app instance +TORRUST_IDX_BACK_E2E_SHARED=true cargo test # Stop E2E testing environment ./docker/bin/e2e-env-down.sh diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 51dfe2dd..141be82f 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -103,6 +103,8 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA .await; // whitelist info hash on tracker + // code-review: why do we always try to whitelist the torrent on the tracker? + // shouldn't we only do this if the torrent is in "Listed" mode? if let Err(e) = app_data .tracker .whitelist_info_hash(torrent_request.torrent.info_hash()) diff --git a/tests/common/client.rs b/tests/common/client.rs index 86295d6f..ed899741 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -16,6 +16,14 @@ pub struct Client { } impl Client { + pub fn unauthenticated(bind_address: &str) -> Self { + Self::new(ConnectionInfo::anonymous(bind_address)) + } + + pub fn authenticated(bind_address: &str, token: &str) -> Self { + Self::new(ConnectionInfo::new(bind_address, token)) + } + pub fn new(connection_info: ConnectionInfo) -> Self { Self { http_client: Http::new(connection_info), diff --git a/tests/common/connection_info.rs b/tests/common/connection_info.rs index e6c96cf9..0b08f026 100644 --- a/tests/common/connection_info.rs +++ b/tests/common/connection_info.rs @@ -1,11 +1,3 @@ -pub fn anonymous_connection(bind_address: &str) -> ConnectionInfo { - ConnectionInfo::anonymous(bind_address) -} - -pub fn authenticated_connection(bind_address: &str, token: &str) -> ConnectionInfo { - ConnectionInfo::new(bind_address, token) -} - #[derive(Clone)] pub struct ConnectionInfo { pub bind_address: String, diff --git a/tests/common/contexts/user/responses.rs b/tests/common/contexts/user/responses.rs index 428b0b96..8f6e84b2 100644 --- a/tests/common/contexts/user/responses.rs +++ b/tests/common/contexts/user/responses.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] pub struct SuccessfulLoginResponse { pub data: LoggedInUserData, } diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs index e9ec2a22..7907c761 100644 --- a/tests/e2e/contexts/about/contract.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -1,11 +1,13 @@ //! API contract for `about` context. use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::environments::shared::TestEnv; +use crate::common::client::Client; +use crate::e2e::environment::TestEnv; #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_with_information_about_the_api() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.about().await; @@ -14,9 +16,10 @@ async fn it_should_load_the_about_page_with_information_about_the_api() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.license().await; diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index f3cc31f0..5d12290b 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -1,11 +1,12 @@ //! API contract for `category` context. use crate::common::asserts::assert_json_ok; +use crate::common::client::Client; use crate::common::contexts::category::fixtures::random_category_name; use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use crate::common::contexts::category::responses::{AddedCategoryResponse, ListResponse}; use crate::e2e::contexts::category::steps::add_category; -use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user}; -use crate::environments::shared::TestEnv; +use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; +use crate::e2e::environment::TestEnv; /* todo: - it should allow adding a new category to authenticated clients @@ -15,9 +16,10 @@ use crate::environments::shared::TestEnv; */ #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_categories().await; @@ -25,15 +27,16 @@ async fn it_should_return_an_empty_category_list_when_there_are_no_categories() } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_return_a_category_list() { + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + // Add a category let category_name = random_category_name(); - let response = add_category(&category_name).await; + let response = add_category(&category_name, &env).await; assert_eq!(response.status, 200); - let client = TestEnv::running().await.unauthenticated_client(); - let response = client.get_categories().await; let res: ListResponse = serde_json::from_str(&response.body).unwrap(); @@ -48,9 +51,10 @@ async fn it_should_return_a_category_list() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client .add_category(AddCategoryForm { @@ -63,10 +67,13 @@ async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_adding_a_new_category_to_non_admins() { - let logged_non_admin = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&logged_non_admin.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); let response = client .add_category(AddCategoryForm { @@ -79,10 +86,12 @@ async fn it_should_not_allow_adding_a_new_category_to_non_admins() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_add_new_categories() { - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); let category_name = random_category_name(); @@ -103,27 +112,31 @@ async fn it_should_allow_admins_to_add_new_categories() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_adding_duplicated_categories() { + let mut env = TestEnv::new(); + env.start().await; + // Add a category let random_category_name = random_category_name(); - let response = add_category(&random_category_name).await; + let response = add_category(&random_category_name, &env).await; assert_eq!(response.status, 200); // Try to add the same category again - let response = add_category(&random_category_name).await; + let response = add_category(&random_category_name, &env).await; assert_eq!(response.status, 400); } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_delete_categories() { - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); // Add a category let category_name = random_category_name(); - let response = add_category(&category_name).await; + let response = add_category(&category_name, &env).await; assert_eq!(response.status, 200); let response = client @@ -143,15 +156,17 @@ async fn it_should_allow_admins_to_delete_categories() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_non_admins_to_delete_categories() { + let mut env = TestEnv::new(); + env.start().await; + // Add a category let category_name = random_category_name(); - let response = add_category(&category_name).await; + let response = add_category(&category_name, &env).await; assert_eq!(response.status, 200); - let logged_in_non_admin = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_non_admin.token); + let logged_in_non_admin = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); let response = client .delete_category(DeleteCategoryForm { @@ -164,15 +179,16 @@ async fn it_should_not_allow_non_admins_to_delete_categories() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_guests_to_delete_categories() { + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + // Add a category let category_name = random_category_name(); - let response = add_category(&category_name).await; + let response = add_category(&category_name, &env).await; assert_eq!(response.status, 200); - let client = TestEnv::running().await.unauthenticated_client(); - let response = client .delete_category(DeleteCategoryForm { name: category_name.to_string(), diff --git a/tests/e2e/contexts/category/steps.rs b/tests/e2e/contexts/category/steps.rs index 62bb6924..2150a7a8 100644 --- a/tests/e2e/contexts/category/steps.rs +++ b/tests/e2e/contexts/category/steps.rs @@ -1,11 +1,12 @@ +use crate::common::client::Client; use crate::common::contexts::category::forms::AddCategoryForm; use crate::common::responses::TextResponse; -use crate::e2e::contexts::user::steps::logged_in_admin; -use crate::environments::shared::TestEnv; +use crate::e2e::contexts::user::steps::new_logged_in_admin; +use crate::e2e::environment::TestEnv; -pub async fn add_category(category_name: &str) -> TextResponse { - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); +pub async fn add_category(category_name: &str, env: &TestEnv) -> TextResponse { + let logged_in_admin = new_logged_in_admin(env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); client .add_category(AddCategoryForm { diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs index 2d13132a..84c1fc45 100644 --- a/tests/e2e/contexts/root/contract.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -1,11 +1,13 @@ //! API contract for `root` context. use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::environments::shared::TestEnv; +use crate::common::client::Client; +use crate::e2e::environment::TestEnv; #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_load_the_about_page_at_the_api_entrypoint() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.root().await; diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 256db721..b266d86a 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -1,13 +1,15 @@ +use crate::common::client::Client; use crate::common::contexts::settings::form::UpdateSettingsForm; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; use crate::common::contexts::settings::{Auth, Database, Mail, Net, Settings, Tracker, Website}; -use crate::e2e::contexts::user::steps::logged_in_admin; -use crate::environments::shared::TestEnv; +use crate::e2e::contexts::user::steps::new_logged_in_admin; +use crate::e2e::environment::TestEnv; #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_get_the_public_settings() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_public_settings().await; @@ -17,7 +19,7 @@ async fn it_should_allow_guests_to_get_the_public_settings() { res.data, Public { website_name: "Torrust".to_string(), - tracker_url: "udp://tracker:6969".to_string(), + tracker_url: env.tracker_url(), tracker_mode: "Public".to_string(), email_on_signup: "Optional".to_string(), } @@ -29,9 +31,10 @@ async fn it_should_allow_guests_to_get_the_public_settings() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_get_the_site_name() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_site_name().await; @@ -45,53 +48,18 @@ async fn it_should_allow_guests_to_get_the_site_name() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_get_all_the_settings() { - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); let response = client.get_settings().await; let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - assert_eq!( - res.data, - Settings { - website: Website { - name: "Torrust".to_string(), - }, - tracker: Tracker { - url: "udp://tracker:6969".to_string(), - mode: "Public".to_string(), - api_url: "http://tracker:1212".to_string(), - token: "MyAccessToken".to_string(), - token_valid_seconds: 7_257_600, - }, - net: Net { - port: 3000, - base_url: None, - }, - auth: Auth { - email_on_signup: "Optional".to_string(), - min_password_length: 6, - max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string(), - }, - database: Database { - connect_url: "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string(), - torrent_info_update_interval: 3600, - }, - mail: Mail { - email_verification_enabled: false, - from: "example@email.com".to_string(), - reply_to: "noreply@email.com".to_string(), - username: String::new(), - password: String::new(), - server: "mailcatcher".to_string(), - port: 1025, - } - } - ); + assert_eq!(res.data, env.server_settings().unwrap()); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "application/json"); } @@ -99,10 +67,12 @@ async fn it_should_allow_admins_to_get_all_the_settings() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_update_all_the_settings() { - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); // todo: we can't actually change the settings because it would affect other E2E tests. // Location for the `config.toml` file is hardcoded. We could use a ENV variable to change it. diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index ae6c1f1a..0f18f05b 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -24,22 +24,33 @@ Get torrent info: mod for_guests { use torrust_index_backend::utils::parse_torrent::decode_torrent; + use crate::common::client::Client; use crate::common::contexts::category::fixtures::software_predefined_category_id; use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; use crate::common::contexts::torrent::responses::{ Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, }; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::logged_in_user; - use crate::environments::shared::TestEnv; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_get_torrents() { - let uploader = logged_in_user().await; - let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); - let client = TestEnv::running().await.unauthenticated_client(); + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let response = client.get_torrents().await; @@ -51,12 +62,22 @@ mod for_guests { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_get_torrent_details_searching_by_id() { - let uploader = logged_in_user().await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } - let client = TestEnv::running().await.unauthenticated_client(); + env.start().await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let response = client.get_torrent(uploaded_torrent.torrent_id).await; @@ -98,12 +119,22 @@ mod for_guests { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_id() { - let uploader = logged_in_user().await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; - let client = TestEnv::running().await.unauthenticated_client(); + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let response = client.download_torrent(uploaded_torrent.torrent_id).await; @@ -127,12 +158,22 @@ mod for_guests { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_guests_to_delete_torrents() { - let uploader = logged_in_user().await; - let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let client = TestEnv::running().await.unauthenticated_client(); + let uploader = new_logged_in_user(&env).await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let response = client.delete_torrent(uploaded_torrent.torrent_id).await; @@ -142,17 +183,28 @@ mod for_guests { mod for_authenticated_users { + use crate::common::client::Client; use crate::common::contexts::torrent::fixtures::random_torrent; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; - use crate::e2e::contexts::user::steps::logged_in_user; - use crate::environments::shared::TestEnv; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_authenticated_users_to_upload_new_torrents() { - let uploader = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&uploader.token); + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let test_torrent = random_torrent(); @@ -172,10 +224,12 @@ mod for_authenticated_users { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { - let uploader = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&uploader.token); + let mut env = TestEnv::new(); + env.start().await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let mut test_torrent = random_torrent(); @@ -189,10 +243,18 @@ mod for_authenticated_users { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { - let uploader = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&uploader.token); + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running + return; + } + + env.start().await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); // Upload the first torrent let first_torrent = random_torrent(); @@ -206,14 +268,25 @@ mod for_authenticated_users { let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); let response = client.upload_torrent(form.into()).await; + assert_eq!(response.body, ""); assert_eq!(response.status, 400); } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_uploading_a_torrent_with_a_infohash_that_already_exists() { - let uploader = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&uploader.token); + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); // Upload the first torrent let first_torrent = random_torrent(); @@ -233,17 +306,28 @@ mod for_authenticated_users { } mod and_non_admins { + use crate::common::client::Client; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::logged_in_user; - use crate::environments::shared::TestEnv; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_non_admins_to_delete_torrents() { - let uploader = logged_in_user().await; - let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; - let client = TestEnv::running().await.authenticated_client(&uploader.token); + let uploader = new_logged_in_user(&env).await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let response = client.delete_torrent(uploaded_torrent.torrent_id).await; @@ -252,19 +336,30 @@ mod for_authenticated_users { } mod and_torrent_owners { + use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::logged_in_user; - use crate::environments::shared::TestEnv; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_torrent_owners_to_update_their_torrents() { - let uploader = logged_in_user().await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; - let client = TestEnv::running().await.authenticated_client(&uploader.token); + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let new_title = format!("{}-new-title", test_torrent.index_info.title); let new_description = format!("{}-new-description", test_torrent.index_info.description); @@ -290,20 +385,31 @@ mod for_authenticated_users { } mod and_admins { + use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user}; - use crate::environments::shared::TestEnv; + use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; + use crate::e2e::environment::TestEnv; #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_delete_torrents_searching_by_id() { - let uploader = logged_in_user().await; - let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } - let admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&admin.token); + env.start().await; + + let uploader = new_logged_in_user(&env).await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); let response = client.delete_torrent(uploaded_torrent.torrent_id).await; @@ -314,13 +420,23 @@ mod for_authenticated_users { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_admins_to_update_someone_elses_torrents() { - let uploader = logged_in_user().await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + let mut env = TestEnv::new(); + + if !env.provides_a_tracker() { + // This test requires the tracker to be running, + // because when you upload a torrent, it's added to the tracker + // whitelist. + return; + } + + env.start().await; + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); let new_title = format!("{}-new-title", test_torrent.index_info.title); let new_description = format!("{}-new-description", test_torrent.index_info.description); diff --git a/tests/e2e/contexts/torrent/steps.rs b/tests/e2e/contexts/torrent/steps.rs index db1ca016..a88d4aef 100644 --- a/tests/e2e/contexts/torrent/steps.rs +++ b/tests/e2e/contexts/torrent/steps.rs @@ -1,25 +1,30 @@ +use crate::common::client::Client; use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent, TorrentIndexInfo, TorrentListedInIndex}; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; use crate::common::contexts::user::responses::LoggedInUserData; -use crate::environments::shared::TestEnv; +use crate::e2e::environment::TestEnv; /// Add a new random torrent to the index -pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (TestTorrent, TorrentListedInIndex) { +pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData, env: &TestEnv) -> (TestTorrent, TorrentListedInIndex) { let random_torrent = random_torrent(); - let indexed_torrent = upload_torrent(uploader, &random_torrent.index_info).await; + let indexed_torrent = upload_torrent(uploader, &random_torrent.index_info, env).await; (random_torrent, indexed_torrent) } /// Upload a torrent to the index -pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo) -> TorrentListedInIndex { - let client = TestEnv::running().await.authenticated_client(&uploader.token); +pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo, env: &TestEnv) -> TorrentListedInIndex { + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let form: UploadTorrentMultipartForm = torrent.clone().into(); let response = client.upload_torrent(form.into()).await; - let res: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + let res = serde_json::from_str::(&response.body); - TorrentListedInIndex::from(torrent.clone(), res.data.torrent_id) + if res.is_err() { + println!("Error deserializing response: {:?}", res); + } + + TorrentListedInIndex::from(torrent.clone(), res.unwrap().data.torrent_id) } diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 4322d40c..ef82c55c 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -1,11 +1,12 @@ //! API contract for `user` context. +use crate::common::client::Client; use crate::common::contexts::user::fixtures::random_user_registration; use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; use crate::common::contexts::user::responses::{ SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, }; -use crate::e2e::contexts::user::steps::{logged_in_user, registered_user}; -use crate::environments::shared::TestEnv; +use crate::e2e::contexts::user::steps::{new_logged_in_user, new_registered_user}; +use crate::e2e::environment::TestEnv; /* @@ -36,9 +37,10 @@ the mailcatcher API. // Responses data #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_a_guess_user_to_register() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let form = random_user_registration(); @@ -52,11 +54,12 @@ async fn it_should_allow_a_guess_user_to_register() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_a_registered_user_to_login() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let registered_user = registered_user().await; + let registered_user = new_registered_user(&env).await; let response = client .login_user(LoginForm { @@ -76,11 +79,12 @@ async fn it_should_allow_a_registered_user_to_login() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let logged_in_user = logged_in_user().await; + let logged_in_user = new_logged_in_user(&env).await; let response = client .verify_token(TokenVerificationForm { @@ -98,10 +102,12 @@ async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { } #[tokio::test] -#[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week() { - let logged_in_user = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_user.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_in_user = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); let response = client .renew_token(TokenRenewalForm { @@ -126,18 +132,21 @@ async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_w } mod banned_user_list { + use crate::common::client::Client; use crate::common::contexts::user::forms::Username; use crate::common::contexts::user::responses::BannedUserResponse; - use crate::e2e::contexts::user::steps::{logged_in_admin, logged_in_user, registered_user}; - use crate::environments::shared::TestEnv; + use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; + use crate::e2e::environment::TestEnv; #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_allow_an_admin_to_ban_a_user() { - let logged_in_admin = logged_in_admin().await; - let client = TestEnv::running().await.authenticated_client(&logged_in_admin.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - let registered_user = registered_user().await; + let registered_user = new_registered_user(&env).await; let response = client.ban_user(Username::new(registered_user.username.clone())).await; @@ -151,12 +160,14 @@ mod banned_user_list { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_a_non_admin_to_ban_a_user() { - let logged_non_admin = logged_in_user().await; - let client = TestEnv::running().await.authenticated_client(&logged_non_admin.token); + let mut env = TestEnv::new(); + env.start().await; + + let logged_non_admin = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); - let registered_user = registered_user().await; + let registered_user = new_registered_user(&env).await; let response = client.ban_user(Username::new(registered_user.username.clone())).await; @@ -164,11 +175,12 @@ mod banned_user_list { } #[tokio::test] - #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_not_allow_guess_to_ban_a_user() { - let client = TestEnv::running().await.unauthenticated_client(); + let mut env = TestEnv::new(); + env.start().await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let registered_user = registered_user().await; + let registered_user = new_registered_user(&env).await; let response = client.ban_user(Username::new(registered_user.username.clone())).await; diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/contexts/user/steps.rs index 843dae78..96627340 100644 --- a/tests/e2e/contexts/user/steps.rs +++ b/tests/e2e/contexts/user/steps.rs @@ -2,18 +2,20 @@ use std::sync::Arc; use torrust_index_backend::databases::database::connect_database; +use crate::common::client::Client; use crate::common::contexts::user::fixtures::random_user_registration; use crate::common::contexts::user::forms::{LoginForm, RegisteredUser}; use crate::common::contexts::user::responses::{LoggedInUserData, SuccessfulLoginResponse}; -use crate::environments::shared::TestEnv; +use crate::e2e::environment::TestEnv; -pub async fn logged_in_admin() -> LoggedInUserData { - let user = logged_in_user().await; +pub async fn new_logged_in_admin(env: &TestEnv) -> LoggedInUserData { + let user = new_logged_in_user(env).await; - // todo: get from E2E config file `config-idx-back.toml.local` - let connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc"; - - let database = Arc::new(connect_database(connect_url).await.expect("Database error.")); + let database = Arc::new( + connect_database(&env.database_connect_url().unwrap()) + .await + .expect("Database error."), + ); let user_profile = database.get_user_profile_from_username(&user.username).await.unwrap(); @@ -22,10 +24,10 @@ pub async fn logged_in_admin() -> LoggedInUserData { user } -pub async fn logged_in_user() -> LoggedInUserData { - let client = TestEnv::running().await.unauthenticated_client(); +pub async fn new_logged_in_user(env: &TestEnv) -> LoggedInUserData { + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let registered_user = registered_user().await; + let registered_user = new_registered_user(env).await; let response = client .login_user(LoginForm { @@ -35,11 +37,32 @@ pub async fn logged_in_user() -> LoggedInUserData { .await; let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + + let user = res.data; + + if !user.admin { + return user; + } + + // The first registered user is always an admin, so we need to register + // a second user to ge a non admin user. + + let second_registered_user = new_registered_user(env).await; + + let response = client + .login_user(LoginForm { + login: second_registered_user.username.clone(), + password: second_registered_user.password.clone(), + }) + .await; + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + res.data } -pub async fn registered_user() -> RegisteredUser { - let client = TestEnv::running().await.unauthenticated_client(); +pub async fn new_registered_user(env: &TestEnv) -> RegisteredUser { + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let form = random_user_registration(); diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs new file mode 100644 index 00000000..352d84e1 --- /dev/null +++ b/tests/e2e/environment.rs @@ -0,0 +1,176 @@ +use std::env; + +use crate::common::contexts::settings::{Auth, Database, Mail, Net, Settings, Tracker, Website}; +use crate::environments::{self, isolated, shared}; + +enum State { + Stopped, + RunningShared, + RunningIsolated, +} + +pub struct TestEnv { + mode: State, + shared: Option, + isolated: Option, +} + +impl TestEnv { + // todo: this class needs a big refactor: + // - It should load the `server_settings` rom both shared or isolated env. + // And `tracker_url`, `server_socket_addr`, `database_connect_url` methods + // should get the values from `server_settings`. + // - We should consider extracting a trait for test environments, so we can + // only one attribute like `AppStarter`. + + pub fn new() -> Self { + Self::default() + } + + pub async fn start(&mut self) { + let e2e_shared = "TORRUST_IDX_BACK_E2E_SHARED"; // bool + + if let Ok(_val) = env::var(e2e_shared) { + let env = shared::TestEnv::running().await; + self.mode = State::RunningShared; + self.shared = Some(env); + } + + let isolated_env = isolated::TestEnv::running().await; + self.mode = State::RunningIsolated; + self.isolated = Some(isolated_env); + } + + pub fn tracker_url(&self) -> String { + // todo: get from `server_settings` + match self.mode { + // todo: for shared instance, get it from env var + // `TORRUST_IDX_BACK_CONFIG` or `TORRUST_IDX_BACK_CONFIG_PATH` + State::RunningShared => "udp://tracker:6969".to_string(), + // todo + State::RunningIsolated => "udp://localhost:6969".to_string(), + State::Stopped => panic!("TestEnv is not running"), + } + } + + /// Some test requires the real tracker to be running, so they can only + /// be run in shared mode. + pub fn provides_a_tracker(&self) -> bool { + matches!(self.mode, State::RunningShared) + } + + pub fn server_socket_addr(&self) -> Option { + // todo: get from `server_settings` + match self.mode { + // todo: for shared instance, get it from env var + // `TORRUST_IDX_BACK_CONFIG` or `TORRUST_IDX_BACK_CONFIG_PATH` + State::RunningShared => match &self.shared { + Some(env) => env.server_socket_addr(), + None => panic!("TestEnv is not running"), + }, + State::RunningIsolated => match &self.isolated { + Some(env) => env.server_socket_addr(), + None => panic!("TestEnv is not running"), + }, + State::Stopped => panic!("TestEnv is not running"), + } + } + + pub fn database_connect_url(&self) -> Option { + // todo: get from `server_settings` + match self.mode { + State::Stopped => None, + State::RunningShared => Some("sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string()), + State::RunningIsolated => self + .isolated + .as_ref() + .map(environments::isolated::TestEnv::database_connect_url), + } + } + + pub fn server_settings(&self) -> Option { + // todo: + // - For shared instance, get it from env var: `TORRUST_IDX_BACK_CONFIG` or `TORRUST_IDX_BACK_CONFIG_PATH`. + // - For isolated instance, get it from the isolated env configuration (`TorrustConfig`). + match self.mode { + State::Stopped => None, + State::RunningShared => Some(Settings { + website: Website { + name: "Torrust".to_string(), + }, + tracker: Tracker { + url: self.tracker_url(), + mode: "Public".to_string(), + api_url: "http://tracker:1212".to_string(), + token: "MyAccessToken".to_string(), + token_valid_seconds: 7_257_600, + }, + net: Net { + port: 3000, + base_url: None, + }, + auth: Auth { + email_on_signup: "Optional".to_string(), + min_password_length: 6, + max_password_length: 64, + secret_key: "MaxVerstappenWC2021".to_string(), + }, + database: Database { + connect_url: self.database_connect_url().unwrap(), + torrent_info_update_interval: 3600, + }, + mail: Mail { + email_verification_enabled: false, + from: "example@email.com".to_string(), + reply_to: "noreply@email.com".to_string(), + username: String::new(), + password: String::new(), + server: "mailcatcher".to_string(), + port: 1025, + }, + }), + State::RunningIsolated => Some(Settings { + website: Website { + name: "Torrust".to_string(), + }, + tracker: Tracker { + url: self.tracker_url(), + mode: "Public".to_string(), + api_url: "http://localhost:1212".to_string(), + token: "MyAccessToken".to_string(), + token_valid_seconds: 7_257_600, + }, + net: Net { port: 0, base_url: None }, + auth: Auth { + email_on_signup: "Optional".to_string(), + min_password_length: 6, + max_password_length: 64, + secret_key: "MaxVerstappenWC2021".to_string(), + }, + database: Database { + connect_url: self.database_connect_url().unwrap(), + torrent_info_update_interval: 3600, + }, + mail: Mail { + email_verification_enabled: false, + from: "example@email.com".to_string(), + reply_to: "noreply@email.com".to_string(), + username: String::new(), + password: String::new(), + server: String::new(), + port: 25, + }, + }), + } + } +} + +impl Default for TestEnv { + fn default() -> Self { + Self { + mode: State::Stopped, + shared: None, + isolated: None, + } + } +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 1ad6beb7..e9613a90 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -1,32 +1,39 @@ -//! End-to-end tests. +//! End-to-end tests //! -//! Execute E2E tests with: +//! These test can be executed against an out-of-process server (shared) or +//! against an in-process server (isolated). //! -//! ``` -//! cargo test --features e2e-tests -//! cargo test --features e2e-tests -- --nocapture -//! ``` +//! If you want to run the tests against an out-of-process server, you need to +//! set the environment variable `TORRUST_IDX_BACK_E2E_SHARED` to `true`. //! -//! or the Cargo alias: +//! > **NOTICE**: The server must be running before running the tests. The +//! server url is hardcoded to `http://localhost:3000` for now. We are planning +//! to make it configurable in the future via a environment variable. //! +//! ```text +//! TORRUST_IDX_BACK_E2E_SHARED=true cargo test //! ``` -//! cargo e2e +//! +//! If you want to run the tests against an isolated server, you need to execute +//! the following command: +//! +//! ```text +//! cargo test //! ``` //! -//! > **NOTICE**: E2E tests are not executed by default, because they require -//! a running instance of the API. +//! > **NOTICE**: Some tests require the real tracker to be running, so they +//! can only be run in shared mode until we implement a mock for the +//! `torrust_index_backend::tracker::TrackerService`. //! -//! You can also run only one test with: +//! You may have errors like `Too many open files (os error 24)`. If so, you +//! need to increase the limit of open files for the current user. You can do +//! it by executing the following command (on Ubuntu): //! +//! ```text +//! ulimit -n 4096 //! ``` -//! cargo test --features e2e-tests TEST_NAME -- --nocapture -//! cargo test --features e2e-tests it_should_register_a_new_user -- --nocapture -//! ``` -//! -//! > **NOTICE**: E2E tests always use the same databases -//! `storage/database/torrust_index_backend_e2e_testing.db` and -//! `./storage/database/torrust_tracker_e2e_testing.db`. If you want to use a -//! clean database, delete the files before running the tests. //! -//! See the [docker documentation](https://github.com/torrust/torrust-index-backend/tree/develop/docker) for more information on how to run the API. +//! You can also make that change permanent, please refer to your OS +//! documentation. pub mod contexts; +pub mod environment; diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index 1e378c4d..4adef42c 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -15,6 +15,7 @@ pub struct AppStarter { } impl AppStarter { + #[must_use] pub fn with_custom_configuration(configuration: TorrustConfig) -> Self { Self { configuration, @@ -22,6 +23,9 @@ impl AppStarter { } } + /// # Panics + /// + /// Will panic if the app was dropped after spawning it. pub async fn start(&mut self) { let configuration = Configuration { settings: RwLock::new(self.configuration.clone()), @@ -38,7 +42,7 @@ impl AppStarter { tx.send(AppStarted { socket_addr: app.socket_address, }) - .expect("the app should not be dropped"); + .expect("the app starter should not be dropped"); app.api_server.await }); @@ -67,9 +71,15 @@ impl AppStarter { } } + #[must_use] pub fn server_socket_addr(&self) -> Option { self.running_state.as_ref().map(|running_state| running_state.socket_addr) } + + #[must_use] + pub fn database_connect_url(&self) -> String { + self.configuration.database.connect_url.clone() + } } #[derive(Debug)] diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index a10354bb..40fa74a9 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -2,8 +2,6 @@ use tempfile::TempDir; use torrust_index_backend::config::{TorrustConfig, FREE_PORT}; use super::app_starter::AppStarter; -use crate::common::client::Client; -use crate::common::connection_info::{anonymous_connection, authenticated_connection}; use crate::common::random; /// Provides an isolated test environment for testing. The environment is @@ -17,7 +15,7 @@ pub struct TestEnv { impl TestEnv { /// Provides a running app instance for integration tests. pub async fn running() -> Self { - let mut env = TestEnv::with_test_configuration(); + let mut env = Self::default(); env.start().await; env } @@ -40,30 +38,19 @@ impl TestEnv { self.app_starter.start().await; } - /// Provides an unauthenticated client for integration tests. - #[must_use] - pub fn unauthenticated_client(&self) -> Client { - Client::new(anonymous_connection( - &self - .server_socket_addr() - .expect("app should be started to get the server socket address"), - )) + /// Provides the API server socket address. + pub fn server_socket_addr(&self) -> Option { + self.app_starter.server_socket_addr().map(|addr| addr.to_string()) } - /// Provides an authenticated client for integration tests. - #[must_use] - pub fn _authenticated_client(&self, token: &str) -> Client { - Client::new(authenticated_connection( - &self - .server_socket_addr() - .expect("app should be started to get the server socket address"), - token, - )) + pub fn database_connect_url(&self) -> String { + self.app_starter.database_connect_url() } +} - /// Provides the API server socket address. - fn server_socket_addr(&self) -> Option { - self.app_starter.server_socket_addr().map(|addr| addr.to_string()) +impl Default for TestEnv { + fn default() -> Self { + Self::with_test_configuration() } } diff --git a/tests/environments/shared.rs b/tests/environments/shared.rs index 5c1df844..9478ce02 100644 --- a/tests/environments/shared.rs +++ b/tests/environments/shared.rs @@ -1,5 +1,4 @@ use crate::common::client::Client; -use crate::common::connection_info::{anonymous_connection, authenticated_connection}; /// Provides a shared test environment for testing. All tests shared the same /// application instance. @@ -16,20 +15,15 @@ impl TestEnv { /// be running to provide a valid environment. pub async fn running() -> Self { let env = Self::default(); - let client = env.unauthenticated_client(); + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let is_running = client.server_is_running().await; assert!(is_running, "Test server is not running on {}", env.authority); env } - #[must_use] - pub fn unauthenticated_client(&self) -> Client { - Client::new(anonymous_connection(&self.authority)) - } - - #[must_use] - pub fn authenticated_client(&self, token: &str) -> Client { - Client::new(authenticated_connection(&self.authority, token)) + /// Provides the API server socket address. + pub fn server_socket_addr(&self) -> Option { + Some(self.authority.clone()) } } diff --git a/tests/integration/contexts/about.rs b/tests/integration/contexts/about.rs deleted file mode 100644 index 88794909..00000000 --- a/tests/integration/contexts/about.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::environments::isolated::TestEnv; - -#[tokio::test] -async fn it_should_load_the_about_page_with_information_about_the_api() { - let client = TestEnv::running().await.unauthenticated_client(); - - let response = client.about().await; - - assert_text_ok(&response); - assert_response_title(&response, "About"); -} - -#[tokio::test] -async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let client = TestEnv::running().await.unauthenticated_client(); - - let response = client.license().await; - - assert_text_ok(&response); - assert_response_title(&response, "Licensing"); -} diff --git a/tests/integration/contexts/mod.rs b/tests/integration/contexts/mod.rs deleted file mode 100644 index 0fc29088..00000000 --- a/tests/integration/contexts/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod about; diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs deleted file mode 100644 index 0f9779b8..00000000 --- a/tests/integration/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod contexts; diff --git a/tests/mod.rs b/tests/mod.rs index 519729e7..6180292f 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -2,5 +2,4 @@ mod common; mod databases; mod e2e; pub mod environments; -mod integration; mod upgrades; From 005817f5328777fa39fb2a8b92c067e6650271ea Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 3 May 2023 16:09:36 +0200 Subject: [PATCH 118/357] feat: [#91] added image proxy with cache --- .gitignore | 2 +- Cargo.lock | 341 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 + src/app.rs | 3 + src/cache/cache.rs | 197 +++++++++++++++++++++ src/cache/image/manager.rs | 200 ++++++++++++++++++++++ src/cache/image/mod.rs | 1 + src/cache/mod.rs | 2 + src/common.rs | 4 + src/config.rs | 18 ++ src/errors.rs | 5 + src/lib.rs | 1 + src/routes/mod.rs | 2 + src/routes/proxy.rs | 85 +++++++++ src/tracker.rs | 2 +- 15 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 src/cache/cache.rs create mode 100644 src/cache/image/manager.rs create mode 100644 src/cache/image/mod.rs create mode 100644 src/cache/mod.rs create mode 100644 src/routes/proxy.rs diff --git a/.gitignore b/.gitignore index eb90c276..c7d2c3f0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ /storage/ /target /uploads/ - +/.idea/ diff --git a/Cargo.lock b/Cargo.lock index 6caf1c77..fb3ebdd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,24 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "arrayvec" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" + [[package]] name = "async-trait" version = "0.1.68" @@ -415,6 +433,12 @@ version = "3.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b1ce199063694f33ffb7dd4e0ee620741495c32833cde5aa08f02a0bf96f0c8" +[[package]] +name = "bytemuck" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" + [[package]] name = "byteorder" version = "1.4.3" @@ -693,6 +717,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + [[package]] name = "der" version = "0.5.1" @@ -704,6 +737,17 @@ dependencies = [ "pem-rfc7468", ] +[[package]] +name = "derive-new" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -807,6 +851,15 @@ dependencies = [ "instant", ] +[[package]] +name = "fdeflate" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d329bdeac514ee06249dabc27877490f17f5d371ec693360768b838e19f3ae10" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fern" version = "0.6.2" @@ -835,9 +888,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.6.2", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "flume" version = "0.10.14" @@ -856,6 +915,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fontdb" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b07f5c05414a0d8caba4c17eef8dc8b5c8955fc7c68d324191c7a56d3f3449" +dependencies = [ + "log", + "memmap2", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -1283,6 +1353,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" + [[package]] name = "js-sys" version = "0.3.61" @@ -1317,6 +1393,15 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kurbo" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a53776d271cfb873b17c618af0298445c88afc52837f3e948fa3fafd131f449" +dependencies = [ + "arrayvec 0.7.2", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -1449,12 +1534,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "memchr" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -1486,6 +1586,16 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.6" @@ -1812,6 +1922,12 @@ dependencies = [ "sha2", ] +[[package]] +name = "pico-args" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468" + [[package]] name = "pin-project" version = "1.0.12" @@ -1872,6 +1988,19 @@ version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +[[package]] +name = "png" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeebc51f9e7d2c150d3f3bfeb667f2aa985db5ef1e3d212847bdedb488beeaa" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.7.1", +] + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1932,6 +2061,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rctree" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae028b272a6e99d9f8260ceefa3caa09300a8d6c8d2b2001316474bc52122e9" + [[package]] name = "redox_syscall" version = "0.2.16" @@ -2005,6 +2140,30 @@ dependencies = [ "winreg", ] +[[package]] +name = "resvg" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "256cc9203115db152290219f35f3362e729301b59e2a391fb2721fe3fa155352" +dependencies = [ + "jpeg-decoder", + "log", + "pico-args", + "png", + "rgb", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ec2d3e3fc7a92ced357df9cebd5a10b6fb2aa1ee797bf7e9ce2f17dffc8f59" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.16.20" @@ -2031,6 +2190,15 @@ dependencies = [ "serde", ] +[[package]] +name = "roxmltree" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b" +dependencies = [ + "xmlparser", +] + [[package]] name = "rsa" version = "0.6.1" @@ -2115,12 +2283,37 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustybuzz" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44561062e583c4873162861261f16fd1d85fe927c4904d71329a4fe43dc355ef" +dependencies = [ + "bitflags", + "bytemuck", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-general-category", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +[[package]] +name = "safe_arch" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ff3d6d9696af502cc3110dacce942840fb06ff4514cad92236ecc455f2ce05" +dependencies = [ + "bytemuck", +] + [[package]] name = "sailfish" version = "0.6.1" @@ -2341,6 +2534,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238abfbb77c1915110ad968465608b68e869e0772622c9656714e73e5a1a522f" + [[package]] name = "simple_asn1" version = "0.6.2" @@ -2353,6 +2552,21 @@ dependencies = [ "time", ] +[[package]] +name = "simplecss" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a11be7c62927d9427e9f40f3444d5499d868648e2edbc4e2116de69e7ec0e89d" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + [[package]] name = "slab" version = "0.4.8" @@ -2527,6 +2741,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +[[package]] +name = "svgtypes" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22975e8a2bac6a76bb54f898a6b18764633b00e780330f0b689f65afb3975564" +dependencies = [ + "siphasher", +] + [[package]] name = "syn" version = "1.0.109" @@ -2580,6 +2803,24 @@ dependencies = [ "colored", ] +[[package]] +name = "text-to-png" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77c9daf0c55b10ef445266dbf0d58705c80496526de2c00643459958d956663" +dependencies = [ + "derive-new", + "fontdb", + "lazy_static", + "png", + "resvg", + "siphasher", + "thiserror", + "tiny-skia", + "usvg", + "xml-rs", +] + [[package]] name = "thiserror" version = "1.0.40" @@ -2627,6 +2868,20 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d049bfef0eaa2521e75d9ffb5ce86ad54480932ae19b85f78bec6f52c4d30d78" +dependencies = [ + "arrayref", + "arrayvec 0.5.2", + "bytemuck", + "cfg-if", + "png", + "safe_arch", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2769,11 +3024,13 @@ dependencies = [ "actix-web", "argon2", "async-trait", + "bytes", "chrono", "config", "derive_more", "fern", "futures", + "indexmap", "jsonwebtoken", "lettre", "log", @@ -2792,6 +3049,7 @@ dependencies = [ "sqlx", "tempfile", "text-colorizer", + "text-to-png", "tokio", "toml 0.7.3", "urlencoding", @@ -2831,6 +3089,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +[[package]] +name = "ttf-parser" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ae2f58a822f08abdaf668897e96a5656fe72f5a9ce66422423e8849384872e6" + [[package]] name = "typenum" version = "1.16.0" @@ -2858,6 +3122,24 @@ version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694" + +[[package]] +name = "unicode-ccc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1" + +[[package]] +name = "unicode-general-category" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07547e3ee45e28326cc23faac56d44f58f16ab23e413db526debce3b0bfd2742" + [[package]] name = "unicode-ident" version = "1.0.8" @@ -2873,12 +3155,24 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-script" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d817255e1bed6dfd4ca47258685d14d2bdcfbc64fdc9e3819bd5848057b8ecc" + [[package]] name = "unicode-segmentation" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.1.10" @@ -2914,6 +3208,33 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +[[package]] +name = "usvg" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f472f6f5d41d3eaef059bc893dcd2382eefcdda3e04ebe0b2860c56b538e491e" +dependencies = [ + "base64 0.13.1", + "data-url", + "flate2", + "float-cmp", + "fontdb", + "kurbo", + "log", + "pico-args", + "rctree", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "svgtypes", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "uuid" version = "1.3.1" @@ -3252,6 +3573,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "xml-rs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" + +[[package]] +name = "xmlparser" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index 99486eba..276efc77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,9 @@ pbkdf2 = { version = "0.12", features = ["simple"] } text-colorizer = "1.0.0" log = "0.4" fern = "0.6" +bytes = "1.4.0" +text-to-png = "0.2.0" +indexmap = "1.9.3" [dev-dependencies] rand = "0.8" diff --git a/src/app.rs b/src/app.rs index c371936a..270d0589 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use log::info; use crate::auth::AuthorizationService; use crate::bootstrap::logging; +use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; use crate::config::Configuration; use crate::databases::database::connect_database; @@ -45,6 +46,7 @@ pub async fn run(configuration: Configuration) -> Running { let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); + let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); // Build app container @@ -54,6 +56,7 @@ pub async fn run(configuration: Configuration) -> Running { auth.clone(), tracker_service.clone(), mailer_service, + image_cache_service )); // Start repeating task to import tracker torrent data and updating diff --git a/src/cache/cache.rs b/src/cache/cache.rs new file mode 100644 index 00000000..50f31a77 --- /dev/null +++ b/src/cache/cache.rs @@ -0,0 +1,197 @@ +use bytes::Bytes; +use indexmap::IndexMap; + +#[derive(Debug)] +pub enum Error { + EntrySizeLimitExceedsTotalCapacity, + BytesExceedEntrySizeLimit, + CacheCapacityIsTooSmall, +} + +#[derive(Debug, Clone)] +pub struct BytesCacheEntry { + pub bytes: Bytes, +} + +// Individual entry destined for the byte cache. +impl BytesCacheEntry { + pub fn new(bytes: Bytes) -> Self { + Self { bytes } + } +} + +pub struct BytesCache { + bytes_table: IndexMap, + total_capacity: usize, + entry_size_limit: usize, +} + +impl BytesCache { + pub fn new() -> Self { + Self { + bytes_table: IndexMap::new(), + total_capacity: 0, + entry_size_limit: 0, + } + } + + // With a total capacity in bytes. + pub fn with_capacity(capacity: usize) -> Self { + let mut new = Self::new(); + + new.total_capacity = capacity; + + new + } + + // With a limit for individual entry sizes. + pub fn with_entry_size_limit(entry_size_limit: usize) -> Self { + let mut new = Self::new(); + + new.entry_size_limit = entry_size_limit; + + new + } + + // With both a total capacity limit and an individual entry size limit. + pub fn with_capacity_and_entry_size_limit(capacity: usize, entry_size_limit: usize) -> Result { + if entry_size_limit > capacity { + return Err(Error::EntrySizeLimitExceedsTotalCapacity); + } + + let mut new = Self::new(); + + new.total_capacity = capacity; + new.entry_size_limit = entry_size_limit; + + Ok(new) + } + + pub async fn get(&self, key: &str) -> Option { + self.bytes_table.get(key).cloned() + } + + // Return the amount of entries in the map. + pub async fn len(&self) -> usize { + self.bytes_table.len() + } + + // Size of all the entry bytes combined. + pub fn total_size(&self) -> usize { + let mut size: usize = 0; + + for (_, entry) in self.bytes_table.iter() { + size += entry.bytes.len(); + } + + size + } + + // Insert bytes using key. + // TODO: Freed space might need to be reserved. Hold and pass write lock between functions? + // For TO DO above: semaphore: Arc, might be a solution. + pub async fn set(&mut self, key: String, bytes: Bytes) -> Result, Error> { + if bytes.len() > self.entry_size_limit { + return Err(Error::BytesExceedEntrySizeLimit); + } + + // Remove the old entry so that a new entry will be added as last in the queue. + let _ = self.bytes_table.shift_remove(&key); + + let bytes_cache_entry = BytesCacheEntry::new(bytes); + + self.free_size(bytes_cache_entry.bytes.len())?; + + Ok(self.bytes_table.insert(key, bytes_cache_entry)) + } + + // Free space. Size amount in bytes. + fn free_size(&mut self, size: usize) -> Result<(), Error> { + // Size may not exceed the total capacity of the bytes cache. + if size > self.total_capacity { + return Err(Error::CacheCapacityIsTooSmall); + } + + let cache_size = self.total_size(); + let size_to_be_freed = size.saturating_sub(self.total_capacity - cache_size); + let mut size_freed: usize = 0; + + while size_freed < size_to_be_freed { + let oldest_entry = self + .pop() + .expect("bytes cache has no more entries, yet there isn't enough space."); + + size_freed += oldest_entry.bytes.len(); + } + + Ok(()) + } + + // Remove and return the oldest entry. + pub fn pop(&mut self) -> Option { + self.bytes_table.shift_remove_index(0).map(|(_, entry)| entry) + } +} + +impl Default for BytesCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use crate::cache::cache::BytesCache; + + #[tokio::test] + async fn set_bytes_cache_with_capacity_and_entry_size_limit_should_succeed() { + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(6, 6).unwrap(); + let bytes: Bytes = Bytes::from("abcdef"); + + assert!(bytes_cache.set("1".to_string(), bytes).await.is_ok()) + } + + #[tokio::test] + async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_allow_adding_new_entries_if_the_limit_is_not_exceeded() { + + let bytes: Bytes = Bytes::from("abcdef"); + + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(bytes.len() * 2, bytes.len()).unwrap(); + + // Add first entry (6 bytes) + assert!(bytes_cache.set("key1".to_string(), bytes.clone()).await.is_ok()); + + // Add second entry (6 bytes) + assert!(bytes_cache.set("key2".to_string(), bytes).await.is_ok()); + + // Both entries were added because we did not reach the limit + assert_eq!(bytes_cache.len().await, 2) + } + + #[tokio::test] + async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_not_allow_adding_new_entries_if_the_capacity_is_exceeded() { + + let bytes: Bytes = Bytes::from("abcdef"); + + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(bytes.len() * 2 - 1, bytes.len()).unwrap(); + + // Add first entry (6 bytes) + assert!(bytes_cache.set("key1".to_string(), bytes.clone()).await.is_ok()); + + // Add second entry (6 bytes) + assert!(bytes_cache.set("key2".to_string(), bytes).await.is_ok()); + + // Only one entry is in the cache, because otherwise the total capacity would have been exceeded + assert_eq!(bytes_cache.len().await, 1) + } + + #[tokio::test] + async fn set_bytes_cache_with_capacity_and_entry_size_limit_should_fail() { + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(6, 5).unwrap(); + let bytes: Bytes = Bytes::from("abcdef"); + + assert!(bytes_cache.set("1".to_string(), bytes).await.is_err()) + } +} diff --git a/src/cache/image/manager.rs b/src/cache/image/manager.rs new file mode 100644 index 00000000..8cc96cec --- /dev/null +++ b/src/cache/image/manager.rs @@ -0,0 +1,200 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use bytes::Bytes; +use tokio::sync::RwLock; + +use crate::cache::cache::BytesCache; +use crate::config::Configuration; +use crate::models::user::UserCompact; + +pub enum Error { + UrlIsUnreachable, + UrlIsNotAnImage, + ImageTooBig, + UserQuotaMet, + Unauthenticated, +} + +type UserQuotas = HashMap; + +pub fn now_in_secs() -> u64 { + match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + Ok(n) => n.as_secs(), + Err(_) => panic!("SystemTime before UNIX EPOCH!"), + } +} + +#[derive(Clone)] +pub struct ImageCacheQuota { + pub user_id: i64, + pub usage: usize, + pub max_usage: usize, + pub date_start_secs: u64, + pub period_secs: u64, +} + +impl ImageCacheQuota { + pub fn new(user_id: i64, max_usage: usize, period_secs: u64) -> Self { + Self { + user_id, + usage: 0, + max_usage, + date_start_secs: now_in_secs(), + period_secs, + } + } + + pub fn add_usage(&mut self, amount: usize) -> Result<(), ()> { + // Check if quota needs to be reset. + if now_in_secs() - self.date_start_secs > self.period_secs { + self.reset(); + } + + if self.is_reached() { + return Err(()); + } + + self.usage = self.usage.saturating_add(amount); + + Ok(()) + } + + pub fn reset(&mut self) { + self.usage = 0; + self.date_start_secs = now_in_secs(); + } + + pub fn is_reached(&self) -> bool { + self.usage >= self.max_usage + } +} + +pub struct ImageCacheService { + image_cache: RwLock, + user_quotas: RwLock, + reqwest_client: reqwest::Client, + cfg: Arc, +} + +impl ImageCacheService { + pub async fn new(cfg: Arc) -> Self { + let settings = cfg.settings.read().await; + + let image_cache = + BytesCache::with_capacity_and_entry_size_limit(settings.image_cache.capacity, settings.image_cache.entry_size_limit) + .expect("Could not create image cache."); + + let reqwest_client = reqwest::Client::builder() + .timeout(Duration::from_millis(settings.image_cache.max_request_timeout_ms)) + .build() + .unwrap(); + + drop(settings); + + Self { + image_cache: RwLock::new(image_cache), + user_quotas: RwLock::new(HashMap::new()), + reqwest_client, + cfg, + } + } + + /// Get an image from the url and insert it into the cache if it isn't cached already. + /// Unauthenticated users can only get already cached images. + pub async fn get_image_by_url(&self, url: &str, opt_user: Option) -> Result { + if let Some(entry) = self.image_cache.read().await.get(url).await { + return Ok(entry.bytes); + } + + if opt_user.is_none() { + return Err(Error::Unauthenticated); + } + + let user = opt_user.unwrap(); + + self.check_user_quota(&user).await?; + + let image_bytes = self.get_image_from_url_as_bytes(url).await?; + + self.check_image_size(&image_bytes).await?; + + // These two functions could be executed after returning the image to the client, + // but than we would need a dedicated task or thread that executes these functions. + // This can be problematic if a task is spawned after every user request. + // Since these functions execute very fast, I don't see a reason to further optimize this. + // For now. + self.update_image_cache(url, &image_bytes).await?; + + self.update_user_quota(&user, image_bytes.len()).await?; + + Ok(image_bytes) + } + + async fn get_image_from_url_as_bytes(&self, url: &str) -> Result { + let res = self.reqwest_client.clone() + .get(url) + .send() + .await + .map_err(|_| Error::UrlIsUnreachable)?; + + if let Some(content_type) = res.headers().get("Content-Type") { + if content_type != "image/jpeg" && content_type != "image/png" { + return Err(Error::UrlIsNotAnImage); + } + } else { + return Err(Error::UrlIsNotAnImage); + } + + res.bytes().await.map_err(|_| Error::UrlIsNotAnImage) + } + + async fn check_user_quota(&self, user: &UserCompact) -> Result<(), Error> { + if let Some(quota) = self.user_quotas.read().await.get(&user.user_id) { + if quota.is_reached() { + return Err(Error::UserQuotaMet); + } + } + + Ok(()) + } + + async fn check_image_size(&self, image_bytes: &Bytes) -> Result<(), Error> { + let settings = self.cfg.settings.read().await; + + if image_bytes.len() > settings.image_cache.entry_size_limit { + return Err(Error::ImageTooBig); + } + + Ok(()) + } + + async fn update_image_cache(&self, url: &str, image_bytes: &Bytes) -> Result<(), Error> { + if self.image_cache.write().await.set(url.to_string(), image_bytes.clone()).await.is_err() { + return Err(Error::ImageTooBig); + } + + Ok(()) + } + + async fn update_user_quota(&self, user: &UserCompact, amount: usize) -> Result<(), Error> { + let settings = self.cfg.settings.read().await; + + let mut quota = self.user_quotas.read().await + .get(&user.user_id) + .cloned() + .unwrap_or(ImageCacheQuota::new( + user.user_id, + settings.image_cache.user_quota_bytes, + settings.image_cache.user_quota_period_seconds, + )); + + let _ = quota.add_usage(amount); + + let _ = self.user_quotas.write().await.insert(user.user_id, quota); + + Ok(()) + } + +} diff --git a/src/cache/image/mod.rs b/src/cache/image/mod.rs new file mode 100644 index 00000000..ff8de9eb --- /dev/null +++ b/src/cache/image/mod.rs @@ -0,0 +1 @@ +pub mod manager; diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 00000000..3afdefbc --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,2 @@ +pub mod cache; +pub mod image; diff --git a/src/common.rs b/src/common.rs index 9bd43dd9..3432383b 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use crate::auth::AuthorizationService; +use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; use crate::mailer::MailerService; @@ -16,6 +17,7 @@ pub struct AppData { pub auth: Arc, pub tracker: Arc, pub mailer: Arc, + pub image_cache_manager: Arc, } impl AppData { @@ -25,6 +27,7 @@ impl AppData { auth: Arc, tracker: Arc, mailer: Arc, + image_cache_manager: Arc, ) -> AppData { AppData { cfg, @@ -32,6 +35,7 @@ impl AppData { auth, tracker, mailer, + image_cache_manager, } } } diff --git a/src/config.rs b/src/config.rs index ea11f554..ddccea13 100644 --- a/src/config.rs +++ b/src/config.rs @@ -70,6 +70,15 @@ pub struct Mail { pub port: u16, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageCache { + pub max_request_timeout_ms: u64, + pub capacity: usize, + pub entry_size_limit: usize, + pub user_quota_period_seconds: u64, + pub user_quota_bytes: usize +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TorrustConfig { pub website: Website, @@ -78,6 +87,7 @@ pub struct TorrustConfig { pub auth: Auth, pub database: Database, pub mail: Mail, + pub image_cache: ImageCache } impl TorrustConfig { @@ -116,10 +126,18 @@ impl TorrustConfig { server: "".to_string(), port: 25, }, + image_cache: ImageCache { + max_request_timeout_ms: 1000, + capacity: 128_000_000, + entry_size_limit: 4_000_000, + user_quota_period_seconds: 3600, + user_quota_bytes: 64_000_000 + } } } } + #[derive(Debug)] pub struct Configuration { pub settings: RwLock, diff --git a/src/errors.rs b/src/errors.rs index 24a413e6..571fd9fe 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -114,6 +114,9 @@ pub enum ServiceError { #[display(fmt = "Sorry, we have an error with our tracker connection.")] TrackerOffline, + #[display(fmt = "Could not whitelist torrent.")] + WhitelistingError, + #[display(fmt = "Failed to send verification email.")] FailedToSendVerificationEmail, @@ -172,6 +175,8 @@ impl ResponseError for ServiceError { ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::CategoryExists => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/lib.rs b/src/lib.rs index 89776482..6db3f410 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod auth; pub mod bootstrap; +pub mod cache; pub mod common; pub mod config; pub mod console; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5761390a..946e776f 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,6 +2,7 @@ use actix_web::web; pub mod about; pub mod category; +pub mod proxy; pub mod root; pub mod settings; pub mod torrent; @@ -13,5 +14,6 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { category::init_routes(cfg); settings::init_routes(cfg); about::init_routes(cfg); + proxy::init_routes(cfg); root::init_routes(cfg); } diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs new file mode 100644 index 00000000..443900df --- /dev/null +++ b/src/routes/proxy.rs @@ -0,0 +1,85 @@ +use std::sync::Once; + +use actix_web::http::StatusCode; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use bytes::Bytes; +use text_to_png::TextRenderer; + +use crate::cache::image::manager::Error; +use crate::common::WebAppData; +use crate::errors::ServiceResult; + +static ERROR_IMAGE_LOADER: Once = Once::new(); + +static mut ERROR_IMAGE_URL_IS_UNREACHABLE: Bytes = Bytes::new(); +static mut ERROR_IMAGE_URL_IS_NOT_AN_IMAGE: Bytes = Bytes::new(); +static mut ERROR_IMAGE_TOO_BIG: Bytes = Bytes::new(); +static mut ERROR_IMAGE_USER_QUOTA_MET: Bytes = Bytes::new(); +static mut ERROR_IMAGE_UNAUTHENTICATED: Bytes = Bytes::new(); + +const ERROR_IMG_FONT_SIZE: u8 = 16; +const ERROR_IMG_COLOR: &str = "Red"; + +const ERROR_IMAGE_URL_IS_UNREACHABLE_TEXT: &str = "Could not find image."; +const ERROR_IMAGE_URL_IS_NOT_AN_IMAGE_TEXT: &str = "Invalid image."; +const ERROR_IMAGE_TOO_BIG_TEXT: &str = "Image is too big."; +const ERROR_IMAGE_USER_QUOTA_MET_TEXT: &str = "Image proxy quota met."; +const ERROR_IMAGE_UNAUTHENTICATED_TEXT: &str = "Sign in to see image."; + +pub fn init_routes(cfg: &mut web::ServiceConfig) { + cfg.service(web::scope("/proxy").service(web::resource("/image/{url}").route(web::get().to(get_proxy_image)))); + + load_error_images(); +} + +fn generate_img_from_text(text: &str) -> Bytes { + let renderer = TextRenderer::default(); + + Bytes::from( + renderer + .render_text_to_png_data(text, ERROR_IMG_FONT_SIZE, ERROR_IMG_COLOR) + .unwrap() + .data, + ) +} + +fn load_error_images() { + ERROR_IMAGE_LOADER.call_once(|| unsafe { + ERROR_IMAGE_URL_IS_UNREACHABLE = generate_img_from_text(ERROR_IMAGE_URL_IS_UNREACHABLE_TEXT); + ERROR_IMAGE_URL_IS_NOT_AN_IMAGE = generate_img_from_text(ERROR_IMAGE_URL_IS_NOT_AN_IMAGE_TEXT); + ERROR_IMAGE_TOO_BIG = generate_img_from_text(ERROR_IMAGE_TOO_BIG_TEXT); + ERROR_IMAGE_USER_QUOTA_MET = generate_img_from_text(ERROR_IMAGE_USER_QUOTA_MET_TEXT); + ERROR_IMAGE_UNAUTHENTICATED = generate_img_from_text(ERROR_IMAGE_UNAUTHENTICATED_TEXT); + }); +} + +pub async fn get_proxy_image(req: HttpRequest, app_data: WebAppData, path: web::Path) -> ServiceResult { + // Check for optional user. + let opt_user = app_data.auth.get_user_compact_from_request(&req).await.ok(); + + let encoded_url = path.into_inner(); + let url = urlencoding::decode(&encoded_url).unwrap_or_default(); + + match app_data.image_cache_manager.get_image_by_url(&url, opt_user).await { + Ok(image_bytes) => Ok(HttpResponse::build(StatusCode::OK) + .content_type("image/png") + .append_header(("Cache-Control", "max-age=15552000")) + .body(image_bytes)), + Err(e) => unsafe { + // Handling status codes in the frontend other tan OK is quite a pain. + // Return OK for now. + let (_status_code, error_image_bytes): (StatusCode, Bytes) = match e { + Error::UrlIsUnreachable => (StatusCode::GATEWAY_TIMEOUT, ERROR_IMAGE_URL_IS_UNREACHABLE.clone()), + Error::UrlIsNotAnImage => (StatusCode::BAD_REQUEST, ERROR_IMAGE_URL_IS_NOT_AN_IMAGE.clone()), + Error::ImageTooBig => (StatusCode::BAD_REQUEST, ERROR_IMAGE_TOO_BIG.clone()), + Error::UserQuotaMet => (StatusCode::TOO_MANY_REQUESTS, ERROR_IMAGE_USER_QUOTA_MET.clone()), + Error::Unauthenticated => (StatusCode::UNAUTHORIZED, ERROR_IMAGE_UNAUTHENTICATED.clone()), + }; + + Ok(HttpResponse::build(StatusCode::OK) + .content_type("image/png") + .append_header(("Cache-Control", "no-cache")) + .body(error_image_bytes)) + }, + } +} diff --git a/src/tracker.rs b/src/tracker.rs index cb69bab7..4e547f75 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -65,7 +65,7 @@ impl TrackerService { if response.status().is_success() { Ok(()) } else { - Err(ServiceError::InternalServerError) + Err(ServiceError::WhitelistingError) } } From d5c5487af2fd327fc4f933fd7a1f2e233b76b334 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 3 May 2023 16:28:36 +0200 Subject: [PATCH 119/357] refactor: cargo fmt --- src/app.rs | 2 +- src/cache/cache.rs | 8 ++++---- src/cache/image/manager.rs | 19 +++++++++++++++---- src/config.rs | 9 ++++----- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/app.rs b/src/app.rs index 270d0589..18cc427f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -56,7 +56,7 @@ pub async fn run(configuration: Configuration) -> Running { auth.clone(), tracker_service.clone(), mailer_service, - image_cache_service + image_cache_service, )); // Start repeating task to import tracker torrent data and updating diff --git a/src/cache/cache.rs b/src/cache/cache.rs index 50f31a77..8573ba0d 100644 --- a/src/cache/cache.rs +++ b/src/cache/cache.rs @@ -154,8 +154,8 @@ mod tests { } #[tokio::test] - async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_allow_adding_new_entries_if_the_limit_is_not_exceeded() { - + async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_allow_adding_new_entries_if_the_limit_is_not_exceeded( + ) { let bytes: Bytes = Bytes::from("abcdef"); let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(bytes.len() * 2, bytes.len()).unwrap(); @@ -171,8 +171,8 @@ mod tests { } #[tokio::test] - async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_not_allow_adding_new_entries_if_the_capacity_is_exceeded() { - + async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_not_allow_adding_new_entries_if_the_capacity_is_exceeded( + ) { let bytes: Bytes = Bytes::from("abcdef"); let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(bytes.len() * 2 - 1, bytes.len()).unwrap(); diff --git a/src/cache/image/manager.rs b/src/cache/image/manager.rs index 8cc96cec..8a6960a1 100644 --- a/src/cache/image/manager.rs +++ b/src/cache/image/manager.rs @@ -133,7 +133,9 @@ impl ImageCacheService { } async fn get_image_from_url_as_bytes(&self, url: &str) -> Result { - let res = self.reqwest_client.clone() + let res = self + .reqwest_client + .clone() .get(url) .send() .await @@ -171,7 +173,14 @@ impl ImageCacheService { } async fn update_image_cache(&self, url: &str, image_bytes: &Bytes) -> Result<(), Error> { - if self.image_cache.write().await.set(url.to_string(), image_bytes.clone()).await.is_err() { + if self + .image_cache + .write() + .await + .set(url.to_string(), image_bytes.clone()) + .await + .is_err() + { return Err(Error::ImageTooBig); } @@ -181,7 +190,10 @@ impl ImageCacheService { async fn update_user_quota(&self, user: &UserCompact, amount: usize) -> Result<(), Error> { let settings = self.cfg.settings.read().await; - let mut quota = self.user_quotas.read().await + let mut quota = self + .user_quotas + .read() + .await .get(&user.user_id) .cloned() .unwrap_or(ImageCacheQuota::new( @@ -196,5 +208,4 @@ impl ImageCacheService { Ok(()) } - } diff --git a/src/config.rs b/src/config.rs index ddccea13..ad00c711 100644 --- a/src/config.rs +++ b/src/config.rs @@ -76,7 +76,7 @@ pub struct ImageCache { pub capacity: usize, pub entry_size_limit: usize, pub user_quota_period_seconds: u64, - pub user_quota_bytes: usize + pub user_quota_bytes: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -87,7 +87,7 @@ pub struct TorrustConfig { pub auth: Auth, pub database: Database, pub mail: Mail, - pub image_cache: ImageCache + pub image_cache: ImageCache, } impl TorrustConfig { @@ -131,13 +131,12 @@ impl TorrustConfig { capacity: 128_000_000, entry_size_limit: 4_000_000, user_quota_period_seconds: 3600, - user_quota_bytes: 64_000_000 - } + user_quota_bytes: 64_000_000, + }, } } } - #[derive(Debug)] pub struct Configuration { pub settings: RwLock, From 916d869b85a217e6f2c101af0b6ff10362017953 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 3 May 2023 22:04:57 +0200 Subject: [PATCH 120/357] refactor: remove patch versions from added packages --- Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 276efc77..d84f7d4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,9 +38,9 @@ pbkdf2 = { version = "0.12", features = ["simple"] } text-colorizer = "1.0.0" log = "0.4" fern = "0.6" -bytes = "1.4.0" -text-to-png = "0.2.0" -indexmap = "1.9.3" +bytes = "1.4" +text-to-png = "0.2" +indexmap = "1.9" [dev-dependencies] rand = "0.8" From 88bd59810b299a2b0bee091d69dbfef8a80624db Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 4 May 2023 13:51:40 +0100 Subject: [PATCH 121/357] fix: tests. Missing new image cache section in config --- config-idx-back.toml.local | 7 +++++++ config.toml.local | 9 ++++++++- tests/common/contexts/settings/mod.rs | 10 ++++++++++ tests/e2e/contexts/settings/contract.rs | 18 ++++++++++++++++-- tests/e2e/environment.rs | 16 +++++++++++++++- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/config-idx-back.toml.local b/config-idx-back.toml.local index 5d1ff7e8..ba47c0fb 100644 --- a/config-idx-back.toml.local +++ b/config-idx-back.toml.local @@ -30,3 +30,10 @@ username = "" password = "" server = "mailcatcher" port = 1025 + +[image_cache] +max_request_timeout_ms = 1000 +capacity = 128000000 +entry_size_limit = 4000000 +user_quota_period_seconds = 3600 +user_quota_bytes = 64000000 diff --git a/config.toml.local b/config.toml.local index c8154bc9..e13eb6d4 100644 --- a/config.toml.local +++ b/config.toml.local @@ -18,7 +18,7 @@ max_password_length = 64 secret_key = "MaxVerstappenWC2021" [database] -connect_url = "sqlite://storage/database/data.db?mode=rwc" +connect_url = "sqlite://data.db?mode=rwc" torrent_info_update_interval = 3600 [mail] @@ -29,3 +29,10 @@ username = "" password = "" server = "" port = 25 + +[image_cache] +max_request_timeout_ms = 1000 +capacity = 128000000 +entry_size_limit = 4000000 +user_quota_period_seconds = 3600 +user_quota_bytes = 64000000 diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index a046c859..1384f2a2 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -11,6 +11,7 @@ pub struct Settings { pub auth: Auth, pub database: Database, pub mail: Mail, + pub image_cache: ImageCache, } #[derive(Deserialize, Serialize, PartialEq, Debug)] @@ -57,3 +58,12 @@ pub struct Mail { pub server: String, pub port: u64, } + +#[derive(Deserialize, Serialize, PartialEq, Debug)] +pub struct ImageCache { + pub max_request_timeout_ms: u64, + pub capacity: u64, + pub entry_size_limit: u64, + pub user_quota_period_seconds: u64, + pub user_quota_bytes: u64, +} diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index b266d86a..96d4e3a2 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -1,7 +1,7 @@ use crate::common::client::Client; use crate::common::contexts::settings::form::UpdateSettingsForm; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; -use crate::common::contexts::settings::{Auth, Database, Mail, Net, Settings, Tracker, Website}; +use crate::common::contexts::settings::{Auth, Database, ImageCache, Mail, Net, Settings, Tracker, Website}; use crate::e2e::contexts::user::steps::new_logged_in_admin; use crate::e2e::environment::TestEnv; @@ -112,6 +112,13 @@ async fn it_should_allow_admins_to_update_all_the_settings() { server: "mailcatcher".to_string(), port: 1025, }, + image_cache: ImageCache { + max_request_timeout_ms: 1000, + capacity: 128_000_000, + entry_size_limit: 4_000_000, + user_quota_period_seconds: 3600, + user_quota_bytes: 64_000_000, + }, }) .await; @@ -152,7 +159,14 @@ async fn it_should_allow_admins_to_update_all_the_settings() { password: String::new(), server: "mailcatcher".to_string(), port: 1025, - } + }, + image_cache: ImageCache { + max_request_timeout_ms: 1000, + capacity: 128_000_000, + entry_size_limit: 4_000_000, + user_quota_period_seconds: 3600, + user_quota_bytes: 64_000_000, + }, } ); if let Some(content_type) = &response.content_type { diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 352d84e1..221719ea 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -1,6 +1,6 @@ use std::env; -use crate::common::contexts::settings::{Auth, Database, Mail, Net, Settings, Tracker, Website}; +use crate::common::contexts::settings::{Auth, Database, ImageCache, Mail, Net, Settings, Tracker, Website}; use crate::environments::{self, isolated, shared}; enum State { @@ -128,6 +128,13 @@ impl TestEnv { server: "mailcatcher".to_string(), port: 1025, }, + image_cache: ImageCache { + max_request_timeout_ms: 1000, + capacity: 128_000_000, + entry_size_limit: 4_000_000, + user_quota_period_seconds: 3600, + user_quota_bytes: 64_000_000, + }, }), State::RunningIsolated => Some(Settings { website: Website { @@ -160,6 +167,13 @@ impl TestEnv { server: String::new(), port: 25, }, + image_cache: ImageCache { + max_request_timeout_ms: 1000, + capacity: 128_000_000, + entry_size_limit: 4_000_000, + user_quota_period_seconds: 3600, + user_quota_bytes: 64_000_000, + }, }), } } From 87edb36bfb73bffd1b18fcff30323322c28ffaf3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 4 May 2023 16:02:33 +0100 Subject: [PATCH 122/357] test: [#130] do not update settings for shared test evns The test `it_should_allow_admins_to_update_all_the_settings` overwrites the config file `config.toml`. It uses the same values but: 1. That does not assert that the file was actually updated. 2. It can lead to conflicts with other tests since all of them use the same shared env. For isolated environments, it should not be a problem becuase we inject the configuration into the app directly wuthout getting it from the environment via confif file or env var. --- tests/common/client.rs | 7 +- tests/common/contexts/settings/form.rs | 2 +- tests/e2e/contexts/settings/contract.rs | 107 +++--------------------- tests/e2e/environment.rs | 4 + 4 files changed, 23 insertions(+), 97 deletions(-) diff --git a/tests/common/client.rs b/tests/common/client.rs index ed899741..d0f84991 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -3,7 +3,7 @@ use serde::Serialize; use super::connection_info::ConnectionInfo; use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; -use super::contexts::settings::form::UpdateSettingsForm; +use super::contexts::settings::form::UpdateSettings; use super::contexts::torrent::forms::UpdateTorrentFrom; use super::contexts::torrent::requests::TorrentId; use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; @@ -16,6 +16,9 @@ pub struct Client { } impl Client { + // todo: forms in POST requests can be passed by reference. It's already + // changed for the `update_settings` method. + pub fn unauthenticated(bind_address: &str) -> Self { Self::new(ConnectionInfo::anonymous(bind_address)) } @@ -80,7 +83,7 @@ impl Client { self.http_client.get("settings", Query::empty()).await } - pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> TextResponse { + pub async fn update_settings(&self, update_settings_form: &UpdateSettings) -> TextResponse { self.http_client.post("settings", &update_settings_form).await } diff --git a/tests/common/contexts/settings/form.rs b/tests/common/contexts/settings/form.rs index f2f21086..1a20fddb 100644 --- a/tests/common/contexts/settings/form.rs +++ b/tests/common/contexts/settings/form.rs @@ -1,3 +1,3 @@ use super::Settings; -pub type UpdateSettingsForm = Settings; +pub type UpdateSettings = Settings; diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 96d4e3a2..809dd631 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -1,7 +1,5 @@ use crate::common::client::Client; -use crate::common::contexts::settings::form::UpdateSettingsForm; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; -use crate::common::contexts::settings::{Auth, Database, ImageCache, Mail, Net, Settings, Tracker, Website}; use crate::e2e::contexts::user::steps::new_logged_in_admin; use crate::e2e::environment::TestEnv; @@ -69,106 +67,27 @@ async fn it_should_allow_admins_to_get_all_the_settings() { #[tokio::test] async fn it_should_allow_admins_to_update_all_the_settings() { let mut env = TestEnv::new(); + + if !env.is_isolated() { + // This test can't be executed in a non-isolated environment because + // it will change the settings for all the other tests. + return; + } + env.start().await; let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - // todo: we can't actually change the settings because it would affect other E2E tests. - // Location for the `config.toml` file is hardcoded. We could use a ENV variable to change it. - - let response = client - .update_settings(UpdateSettingsForm { - website: Website { - name: "Torrust".to_string(), - }, - tracker: Tracker { - url: "udp://tracker:6969".to_string(), - mode: "Public".to_string(), - api_url: "http://tracker:1212".to_string(), - token: "MyAccessToken".to_string(), - token_valid_seconds: 7_257_600, - }, - net: Net { - port: 3000, - base_url: None, - }, - auth: Auth { - email_on_signup: "Optional".to_string(), - min_password_length: 6, - max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string(), - }, - database: Database { - connect_url: "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string(), - torrent_info_update_interval: 3600, - }, - mail: Mail { - email_verification_enabled: false, - from: "example@email.com".to_string(), - reply_to: "noreply@email.com".to_string(), - username: String::new(), - password: String::new(), - server: "mailcatcher".to_string(), - port: 1025, - }, - image_cache: ImageCache { - max_request_timeout_ms: 1000, - capacity: 128_000_000, - entry_size_limit: 4_000_000, - user_quota_period_seconds: 3600, - user_quota_bytes: 64_000_000, - }, - }) - .await; + let mut new_settings = env.server_settings().unwrap(); + + new_settings.website.name = "UPDATED NAME".to_string(); + + let response = client.update_settings(&new_settings).await; let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - assert_eq!( - res.data, - Settings { - website: Website { - name: "Torrust".to_string(), - }, - tracker: Tracker { - url: "udp://tracker:6969".to_string(), - mode: "Public".to_string(), - api_url: "http://tracker:1212".to_string(), - token: "MyAccessToken".to_string(), - token_valid_seconds: 7_257_600, - }, - net: Net { - port: 3000, - base_url: None, - }, - auth: Auth { - email_on_signup: "Optional".to_string(), - min_password_length: 6, - max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string(), - }, - database: Database { - connect_url: "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string(), - torrent_info_update_interval: 3600, - }, - mail: Mail { - email_verification_enabled: false, - from: "example@email.com".to_string(), - reply_to: "noreply@email.com".to_string(), - username: String::new(), - password: String::new(), - server: "mailcatcher".to_string(), - port: 1025, - }, - image_cache: ImageCache { - max_request_timeout_ms: 1000, - capacity: 128_000_000, - entry_size_limit: 4_000_000, - user_quota_period_seconds: 3600, - user_quota_bytes: 64_000_000, - }, - } - ); + assert_eq!(res.data, new_settings); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "application/json"); } diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 221719ea..513cd53e 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -27,6 +27,10 @@ impl TestEnv { Self::default() } + pub fn is_isolated(&self) -> bool { + matches!(self.mode, State::RunningIsolated) + } + pub async fn start(&mut self) { let e2e_shared = "TORRUST_IDX_BACK_E2E_SHARED"; // bool From b97da41c20d1e2e06fcbb62a3e62b0b231e99682 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 4 May 2023 17:33:47 +0100 Subject: [PATCH 123/357] refactor: [#130] configuration for E2E tests Clean code for E2E test env configuration initialization. And the configuration is loaded from the same config file used to start the docker container: `config-idx-back.local.toml`. --- .dockerignore | 8 +- ...k.toml.local => config-idx-back.local.toml | 0 ...er.toml.local => config-tracker.local.toml | 0 docker/README.md | 6 +- docker/bin/e2e-env-up.sh | 4 +- src/bootstrap/config.rs | 23 +- src/routes/settings.rs | 7 +- tests/common/contexts/settings/mod.rs | 125 ++++++++-- tests/e2e/config.rs | 44 ++++ tests/e2e/contexts/settings/contract.rs | 8 +- tests/e2e/contexts/user/contract.rs | 4 +- tests/e2e/environment.rs | 227 ++++++------------ tests/e2e/mod.rs | 4 +- tests/environments/app_starter.rs | 5 + tests/environments/isolated.rs | 8 + tests/environments/shared.rs | 4 + 16 files changed, 288 insertions(+), 189 deletions(-) rename config-idx-back.toml.local => config-idx-back.local.toml (100%) rename config-tracker.toml.local => config-tracker.local.toml (100%) create mode 100644 tests/e2e/config.rs diff --git a/.dockerignore b/.dockerignore index 89d167c9..74351152 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,15 +5,15 @@ /.github /.gitignore /.vscode -/data_v2.db* -/data.db* /bin/ -/config-idx-back.toml.local -/config-tracker.toml.local +/config-idx-back.local.toml +/config-tracker.local.toml /config.toml /config.toml.local /cspell.json +/data_v2.db* /data.db +/data.db* /docker/ /project-words.txt /README.md diff --git a/config-idx-back.toml.local b/config-idx-back.local.toml similarity index 100% rename from config-idx-back.toml.local rename to config-idx-back.local.toml diff --git a/config-tracker.toml.local b/config-tracker.local.toml similarity index 100% rename from config-tracker.toml.local rename to config-tracker.local.toml diff --git a/docker/README.md b/docker/README.md index d38a8066..3dbfa038 100644 --- a/docker/README.md +++ b/docker/README.md @@ -46,7 +46,7 @@ docker run -it \ ### With docker-compose -The docker-compose configuration includes the MySQL service configuration. If you want to use MySQL instead of SQLite you have to change your `config.toml` or `config-idx-back.toml.local` configuration from: +The docker-compose configuration includes the MySQL service configuration. If you want to use MySQL instead of SQLite you have to change your `config.toml` or `config-idx-back.local.toml` configuration from: ```toml connect_url = "sqlite://storage/database/data.db?mode=rwc" @@ -64,8 +64,8 @@ Build and run it locally: ```s TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ - TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ - TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.local.toml) \ + TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ docker compose up -d --build ``` diff --git a/docker/bin/e2e-env-up.sh b/docker/bin/e2e-env-up.sh index a5de770c..fd7cd427 100755 --- a/docker/bin/e2e-env-up.sh +++ b/docker/bin/e2e-env-up.sh @@ -4,7 +4,7 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ docker compose build TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ - TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.toml.local) \ - TORRUST_TRACKER_CONFIG=$(cat config-tracker.toml.local) \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.local.toml) \ + TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ docker compose up -d diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 1f130344..22a5590f 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -1,7 +1,16 @@ +//! Initialize configuration from file or env var. +//! +//! All environment variables are prefixed with `TORRUST_IDX_BACK_`. use std::env; -pub const CONFIG_PATH: &str = "./config.toml"; -pub const CONFIG_ENV_VAR_NAME: &str = "TORRUST_IDX_BACK_CONFIG"; +// Environment variables + +/// The whole `config.toml` file content. It has priority over the config file. +pub const ENV_VAR_CONFIG: &str = "TORRUST_IDX_BACK_CONFIG"; + +// Default values + +pub const ENV_VAR_DEFAULT_CONFIG_PATH: &str = "./config.toml"; use crate::config::Configuration; @@ -11,14 +20,14 @@ use crate::config::Configuration; /// /// Will panic if configuration is not found or cannot be parsed pub async fn init_configuration() -> Configuration { - if env::var(CONFIG_ENV_VAR_NAME).is_ok() { - println!("Loading configuration from env var `{}`", CONFIG_ENV_VAR_NAME); + if env::var(ENV_VAR_CONFIG).is_ok() { + println!("Loading configuration from env var `{}`", ENV_VAR_CONFIG); - Configuration::load_from_env_var(CONFIG_ENV_VAR_NAME).unwrap() + Configuration::load_from_env_var(ENV_VAR_CONFIG).unwrap() } else { - println!("Loading configuration from config file `{}`", CONFIG_PATH); + println!("Loading configuration from config file `{}`", ENV_VAR_DEFAULT_CONFIG_PATH); - match Configuration::load_from_file(CONFIG_PATH).await { + match Configuration::load_from_file(ENV_VAR_DEFAULT_CONFIG_PATH).await { Ok(config) => config, Err(error) => { panic!("{}", error) diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 08a1d821..ba44317b 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -1,6 +1,6 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use crate::bootstrap::config::CONFIG_PATH; +use crate::bootstrap::config::ENV_VAR_DEFAULT_CONFIG_PATH; use crate::common::WebAppData; use crate::config::TorrustConfig; use crate::errors::{ServiceError, ServiceResult}; @@ -60,7 +60,10 @@ pub async fn update_settings( return Err(ServiceError::Unauthorized); } - let _ = app_data.cfg.update_settings(payload.into_inner(), CONFIG_PATH).await; + let _ = app_data + .cfg + .update_settings(payload.into_inner(), ENV_VAR_DEFAULT_CONFIG_PATH) + .await; let settings = app_data.cfg.settings.read().await; diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index 1384f2a2..2871602c 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -2,24 +2,28 @@ pub mod form; pub mod responses; use serde::{Deserialize, Serialize}; +use torrust_index_backend::config::{ + Auth as DomainAuth, Database as DomainDatabase, ImageCache as DomainImageCache, Mail as DomainMail, Network as DomainNetwork, + TorrustConfig as DomainSettings, Tracker as DomainTracker, Website as DomainWebsite, +}; -#[derive(Deserialize, Serialize, PartialEq, Debug)] +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Settings { pub website: Website, pub tracker: Tracker, - pub net: Net, + pub net: Network, pub auth: Auth, pub database: Database, pub mail: Mail, pub image_cache: ImageCache, } -#[derive(Deserialize, Serialize, PartialEq, Debug)] +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Website { pub name: String, } -#[derive(Deserialize, Serialize, PartialEq, Debug)] +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Tracker { pub url: String, pub mode: String, @@ -28,27 +32,27 @@ pub struct Tracker { pub token_valid_seconds: u64, } -#[derive(Deserialize, Serialize, PartialEq, Debug)] -pub struct Net { - pub port: u64, +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Network { + pub port: u16, pub base_url: Option, } -#[derive(Deserialize, Serialize, PartialEq, Debug)] +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Auth { pub email_on_signup: String, - pub min_password_length: u64, - pub max_password_length: u64, + pub min_password_length: usize, + pub max_password_length: usize, pub secret_key: String, } -#[derive(Deserialize, Serialize, PartialEq, Debug)] +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Database { pub connect_url: String, pub torrent_info_update_interval: u64, } -#[derive(Deserialize, Serialize, PartialEq, Debug)] +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Mail { pub email_verification_enabled: bool, pub from: String, @@ -56,14 +60,101 @@ pub struct Mail { pub username: String, pub password: String, pub server: String, - pub port: u64, + pub port: u16, } -#[derive(Deserialize, Serialize, PartialEq, Debug)] +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct ImageCache { pub max_request_timeout_ms: u64, - pub capacity: u64, - pub entry_size_limit: u64, + pub capacity: usize, + pub entry_size_limit: usize, pub user_quota_period_seconds: u64, - pub user_quota_bytes: u64, + pub user_quota_bytes: usize, +} + +impl From for Settings { + fn from(settings: DomainSettings) -> Self { + Settings { + website: Website::from(settings.website), + tracker: Tracker::from(settings.tracker), + net: Network::from(settings.net), + auth: Auth::from(settings.auth), + database: Database::from(settings.database), + mail: Mail::from(settings.mail), + image_cache: ImageCache::from(settings.image_cache), + } + } +} + +impl From for Website { + fn from(website: DomainWebsite) -> Self { + Website { name: website.name } + } +} + +impl From for Tracker { + fn from(tracker: DomainTracker) -> Self { + Tracker { + url: tracker.url, + mode: format!("{:?}", tracker.mode), + api_url: tracker.api_url, + token: tracker.token, + token_valid_seconds: tracker.token_valid_seconds, + } + } +} + +impl From for Network { + fn from(net: DomainNetwork) -> Self { + Network { + port: net.port, + base_url: net.base_url, + } + } +} + +impl From for Auth { + fn from(auth: DomainAuth) -> Self { + Auth { + email_on_signup: format!("{:?}", auth.email_on_signup), + min_password_length: auth.min_password_length, + max_password_length: auth.max_password_length, + secret_key: auth.secret_key, + } + } +} + +impl From for Database { + fn from(database: DomainDatabase) -> Self { + Database { + connect_url: database.connect_url, + torrent_info_update_interval: database.torrent_info_update_interval, + } + } +} + +impl From for Mail { + fn from(mail: DomainMail) -> Self { + Mail { + email_verification_enabled: mail.email_verification_enabled, + from: mail.from, + reply_to: mail.reply_to, + username: mail.username, + password: mail.password, + server: mail.server, + port: mail.port, + } + } +} + +impl From for ImageCache { + fn from(image_cache: DomainImageCache) -> Self { + ImageCache { + max_request_timeout_ms: image_cache.max_request_timeout_ms, + capacity: image_cache.capacity, + entry_size_limit: image_cache.entry_size_limit, + user_quota_period_seconds: image_cache.user_quota_period_seconds, + user_quota_bytes: image_cache.user_quota_bytes, + } + } } diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs new file mode 100644 index 00000000..f0c20397 --- /dev/null +++ b/tests/e2e/config.rs @@ -0,0 +1,44 @@ +//! Initialize configuration for the shared E2E tests environment from a +//! config file `config.toml` or env var. +//! +//! All environment variables are prefixed with `TORRUST_IDX_BACK_`. +use std::env; + +use torrust_index_backend::config::Configuration; + +// Environment variables + +/// If present, E2E tests will run against a shared instance of the server +pub const ENV_VAR_E2E_SHARED: &str = "TORRUST_IDX_BACK_E2E_SHARED"; + +/// The whole `config.toml` file content. It has priority over the config file. +pub const ENV_VAR_E2E_CONFIG: &str = "TORRUST_IDX_BACK_E2E_CONFIG"; + +// Default values + +pub const ENV_VAR_E2E_DEFAULT_CONFIG_PATH: &str = "./config-idx-back.local.toml"; + +/// Initialize configuration from file or env var. +/// +/// # Panics +/// +/// Will panic if configuration is not found or cannot be parsed +pub async fn init_shared_env_configuration() -> Configuration { + if env::var(ENV_VAR_E2E_CONFIG).is_ok() { + println!("Loading configuration for E2E env from env var `{}`", ENV_VAR_E2E_CONFIG); + + Configuration::load_from_env_var(ENV_VAR_E2E_CONFIG).unwrap() + } else { + println!( + "Loading configuration for E2E env from config file `{}`", + ENV_VAR_E2E_DEFAULT_CONFIG_PATH + ); + + match Configuration::load_from_file(ENV_VAR_E2E_DEFAULT_CONFIG_PATH).await { + Ok(config) => config, + Err(error) => { + panic!("{}", error) + } + } + } +} diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 809dd631..38645b2a 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -16,10 +16,10 @@ async fn it_should_allow_guests_to_get_the_public_settings() { assert_eq!( res.data, Public { - website_name: "Torrust".to_string(), - tracker_url: env.tracker_url(), - tracker_mode: "Public".to_string(), - email_on_signup: "Optional".to_string(), + website_name: env.server_settings().unwrap().website.name, + tracker_url: env.server_settings().unwrap().tracker.url, + tracker_mode: env.server_settings().unwrap().tracker.mode, + email_on_signup: env.server_settings().unwrap().auth.email_on_signup, } ); if let Some(content_type) = &response.content_type { diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index ef82c55c..06a12f79 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -37,7 +37,7 @@ the mailcatcher API. // Responses data #[tokio::test] -async fn it_should_allow_a_guess_user_to_register() { +async fn it_should_allow_a_guest_user_to_register() { let mut env = TestEnv::new(); env.start().await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); @@ -175,7 +175,7 @@ mod banned_user_list { } #[tokio::test] - async fn it_should_not_allow_guess_to_ban_a_user() { + async fn it_should_not_allow_a_guest_to_ban_a_user() { let mut env = TestEnv::new(); env.start().await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 513cd53e..43eb7af3 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -1,7 +1,8 @@ use std::env; -use crate::common::contexts::settings::{Auth, Database, ImageCache, Mail, Net, Settings, Tracker, Website}; -use crate::environments::{self, isolated, shared}; +use super::config::{init_shared_env_configuration, ENV_VAR_E2E_SHARED}; +use crate::common::contexts::settings::Settings; +use crate::environments::{isolated, shared}; enum State { Stopped, @@ -9,186 +10,118 @@ enum State { RunningIsolated, } +/// Test environment for E2E tests. It's a wrapper around the shared or isolated +/// test environment. +/// +/// Shared test environment: +/// +/// - It's a out-of-process test environment. +/// - It has to be started before running the tests. +/// - All tests run against the same instance of the server. +/// +/// Isolated test environment: +/// +/// - It's an in-process test environment. +/// - It's started automatically when the test starts. +/// - Each test runs against a different instance of the server. +#[derive(Default)] pub struct TestEnv { - mode: State, + /// Copy of the settings when the test environment was started. + starting_settings: Option, + /// Shared independent test environment if we start using it. shared: Option, + /// Isolated test environment if we start an isolate test environment. isolated: Option, } impl TestEnv { - // todo: this class needs a big refactor: - // - It should load the `server_settings` rom both shared or isolated env. - // And `tracker_url`, `server_socket_addr`, `database_connect_url` methods - // should get the values from `server_settings`. - // - We should consider extracting a trait for test environments, so we can - // only one attribute like `AppStarter`. + // code-review: consider extracting a trait for test environments. The state + // could be only `Running` or `Stopped`, and we could have a single + // attribute with the current started test environment (`Option`). pub fn new() -> Self { Self::default() } + pub fn is_shared(&self) -> bool { + self.shared.is_some() + } + pub fn is_isolated(&self) -> bool { - matches!(self.mode, State::RunningIsolated) + self.isolated.is_some() } + /// It starts the test environment. It can be a shared or isolated test + /// environment depending on the value of the `ENV_VAR_E2E_SHARED` env var. pub async fn start(&mut self) { - let e2e_shared = "TORRUST_IDX_BACK_E2E_SHARED"; // bool + let e2e_shared = ENV_VAR_E2E_SHARED; // bool - if let Ok(_val) = env::var(e2e_shared) { - let env = shared::TestEnv::running().await; - self.mode = State::RunningShared; - self.shared = Some(env); - } + if let Ok(_e2e_test_env_is_shared) = env::var(e2e_shared) { + // Using the shared test env. + let shared_env = shared::TestEnv::running().await; - let isolated_env = isolated::TestEnv::running().await; - self.mode = State::RunningIsolated; - self.isolated = Some(isolated_env); - } + self.shared = Some(shared_env); + self.starting_settings = self.server_settings_for_shared_env().await; + } else { + // Using an isolated test env. + let isolated_env = isolated::TestEnv::running().await; - pub fn tracker_url(&self) -> String { - // todo: get from `server_settings` - match self.mode { - // todo: for shared instance, get it from env var - // `TORRUST_IDX_BACK_CONFIG` or `TORRUST_IDX_BACK_CONFIG_PATH` - State::RunningShared => "udp://tracker:6969".to_string(), - // todo - State::RunningIsolated => "udp://localhost:6969".to_string(), - State::Stopped => panic!("TestEnv is not running"), + self.isolated = Some(isolated_env); + self.starting_settings = self.server_settings_for_isolated_env(); } } /// Some test requires the real tracker to be running, so they can only /// be run in shared mode. pub fn provides_a_tracker(&self) -> bool { - matches!(self.mode, State::RunningShared) + self.is_shared() + } + + /// Returns the server starting settings if the servers was already started. + /// We do not know the settings until we start the server. + pub fn server_settings(&self) -> Option { + self.starting_settings.as_ref().cloned() } + /// Provides the API server socket address. + /// For example: `localhost:3000`. pub fn server_socket_addr(&self) -> Option { - // todo: get from `server_settings` - match self.mode { - // todo: for shared instance, get it from env var - // `TORRUST_IDX_BACK_CONFIG` or `TORRUST_IDX_BACK_CONFIG_PATH` - State::RunningShared => match &self.shared { - Some(env) => env.server_socket_addr(), - None => panic!("TestEnv is not running"), - }, - State::RunningIsolated => match &self.isolated { - Some(env) => env.server_socket_addr(), - None => panic!("TestEnv is not running"), - }, - State::Stopped => panic!("TestEnv is not running"), + match self.state() { + State::RunningShared => self.shared.as_ref().unwrap().server_socket_addr(), + State::RunningIsolated => self.isolated.as_ref().unwrap().server_socket_addr(), + State::Stopped => None, } } + /// Provides the database connect URL. + /// For example: `sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc`. pub fn database_connect_url(&self) -> Option { - // todo: get from `server_settings` - match self.mode { - State::Stopped => None, - State::RunningShared => Some("sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc".to_string()), - State::RunningIsolated => self - .isolated - .as_ref() - .map(environments::isolated::TestEnv::database_connect_url), - } + self.starting_settings + .as_ref() + .map(|settings| settings.database.connect_url.clone()) } - pub fn server_settings(&self) -> Option { - // todo: - // - For shared instance, get it from env var: `TORRUST_IDX_BACK_CONFIG` or `TORRUST_IDX_BACK_CONFIG_PATH`. - // - For isolated instance, get it from the isolated env configuration (`TorrustConfig`). - match self.mode { - State::Stopped => None, - State::RunningShared => Some(Settings { - website: Website { - name: "Torrust".to_string(), - }, - tracker: Tracker { - url: self.tracker_url(), - mode: "Public".to_string(), - api_url: "http://tracker:1212".to_string(), - token: "MyAccessToken".to_string(), - token_valid_seconds: 7_257_600, - }, - net: Net { - port: 3000, - base_url: None, - }, - auth: Auth { - email_on_signup: "Optional".to_string(), - min_password_length: 6, - max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string(), - }, - database: Database { - connect_url: self.database_connect_url().unwrap(), - torrent_info_update_interval: 3600, - }, - mail: Mail { - email_verification_enabled: false, - from: "example@email.com".to_string(), - reply_to: "noreply@email.com".to_string(), - username: String::new(), - password: String::new(), - server: "mailcatcher".to_string(), - port: 1025, - }, - image_cache: ImageCache { - max_request_timeout_ms: 1000, - capacity: 128_000_000, - entry_size_limit: 4_000_000, - user_quota_period_seconds: 3600, - user_quota_bytes: 64_000_000, - }, - }), - State::RunningIsolated => Some(Settings { - website: Website { - name: "Torrust".to_string(), - }, - tracker: Tracker { - url: self.tracker_url(), - mode: "Public".to_string(), - api_url: "http://localhost:1212".to_string(), - token: "MyAccessToken".to_string(), - token_valid_seconds: 7_257_600, - }, - net: Net { port: 0, base_url: None }, - auth: Auth { - email_on_signup: "Optional".to_string(), - min_password_length: 6, - max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string(), - }, - database: Database { - connect_url: self.database_connect_url().unwrap(), - torrent_info_update_interval: 3600, - }, - mail: Mail { - email_verification_enabled: false, - from: "example@email.com".to_string(), - reply_to: "noreply@email.com".to_string(), - username: String::new(), - password: String::new(), - server: String::new(), - port: 25, - }, - image_cache: ImageCache { - max_request_timeout_ms: 1000, - capacity: 128_000_000, - entry_size_limit: 4_000_000, - user_quota_period_seconds: 3600, - user_quota_bytes: 64_000_000, - }, - }), + fn state(&self) -> State { + if self.is_shared() { + return State::RunningShared; } - } -} -impl Default for TestEnv { - fn default() -> Self { - Self { - mode: State::Stopped, - shared: None, - isolated: None, + if self.is_isolated() { + return State::RunningIsolated; } + + State::Stopped + } + + fn server_settings_for_isolated_env(&self) -> Option { + self.isolated + .as_ref() + .map(|env| Settings::from(env.app_starter.server_configuration())) + } + + async fn server_settings_for_shared_env(&self) -> Option { + let configuration = init_shared_env_configuration().await; + let settings = configuration.settings.read().await; + Some(Settings::from(settings.clone())) } } diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index e9613a90..71386b0f 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -34,6 +34,8 @@ //! ``` //! //! You can also make that change permanent, please refer to your OS -//! documentation. +//! documentation. See for more +//! information. +pub mod config; pub mod contexts; pub mod environment; diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index 4adef42c..7e3b28d1 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -71,6 +71,11 @@ impl AppStarter { } } + #[must_use] + pub fn server_configuration(&self) -> TorrustConfig { + self.configuration.clone() + } + #[must_use] pub fn server_socket_addr(&self) -> Option { self.running_state.as_ref().map(|running_state| running_state.socket_addr) diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index 40fa74a9..470488b2 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -38,11 +38,19 @@ impl TestEnv { self.app_starter.start().await; } + /// Provides the whole server configuration. + #[must_use] + pub fn server_configuration(&self) -> TorrustConfig { + self.app_starter.server_configuration() + } + /// Provides the API server socket address. + #[must_use] pub fn server_socket_addr(&self) -> Option { self.app_starter.server_socket_addr().map(|addr| addr.to_string()) } + #[must_use] pub fn database_connect_url(&self) -> String { self.app_starter.database_connect_url() } diff --git a/tests/environments/shared.rs b/tests/environments/shared.rs index 9478ce02..1920f0cd 100644 --- a/tests/environments/shared.rs +++ b/tests/environments/shared.rs @@ -22,7 +22,11 @@ impl TestEnv { } /// Provides the API server socket address. + #[must_use] pub fn server_socket_addr(&self) -> Option { + // If the E2E configuration uses port 0 in the future instead of a + // predefined port (right now we are using port 3000) we will + // need to pass an env var with the port used by the server. Some(self.authority.clone()) } } From d2f8db926f8b5069d5eda076362201f928961afb Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 10:13:51 +0100 Subject: [PATCH 124/357] fix: [#132] deprecate chrono function warning ``` warning: use of deprecated associated function `chrono::NaiveDateTime::from_timestamp`: use `from_timestamp_opt()` instead --> src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs:53:41 | 53 | let naive_datetime = NaiveDateTime::from_timestamp(timestamp, 0); | ^^^^^^^^^^^^^^ | = note: `#[warn(deprecated)]` on by default ``` --- src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 35207ad4..e3379b52 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -46,11 +46,12 @@ impl TorrentRecordV2 { } } +#[must_use] pub fn convert_timestamp_to_datetime(timestamp: i64) -> String { // The expected format in database is: 2022-11-04 09:53:57 // MySQL uses a DATETIME column and SQLite uses a TEXT column. - let naive_datetime = NaiveDateTime::from_timestamp(timestamp, 0); + let naive_datetime = NaiveDateTime::from_timestamp_opt(timestamp, 0).expect("Overflow of i64 seconds, very future!"); let datetime_again: DateTime = DateTime::from_utc(naive_datetime, Utc); // Format without timezone From df650dbfd7018f213c07e6821ec1957774727e7e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 10:26:33 +0100 Subject: [PATCH 125/357] refactor: rename mod time to clock And safer type convertion from i64 (timestamp) to u64. --- src/auth.rs | 2 +- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/mailer.rs | 2 +- src/routes/user.rs | 2 +- src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs | 3 ++- src/utils/clock.rs | 4 ++++ src/utils/mod.rs | 2 +- src/utils/time.rs | 3 --- 9 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 src/utils/clock.rs delete mode 100644 src/utils/time.rs diff --git a/src/auth.rs b/src/auth.rs index 57ec50e5..8c0f2c27 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,7 +7,7 @@ use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; use crate::models::user::{UserClaims, UserCompact}; -use crate::utils::time::current_time; +use crate::utils::clock::current_time; pub struct AuthorizationService { cfg: Arc, diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index e87a5401..028eaf5b 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -9,8 +9,8 @@ use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; +use crate::utils::clock::current_time; use crate::utils::hex::bytes_to_hex; -use crate::utils::time::current_time; pub struct MysqlDatabase { pub pool: MySqlPool, diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 835979fe..538bf378 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -9,8 +9,8 @@ use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; +use crate::utils::clock::current_time; use crate::utils::hex::bytes_to_hex; -use crate::utils::time::current_time; pub struct SqliteDatabase { pub pool: SqlitePool, diff --git a/src/mailer.rs b/src/mailer.rs index bd8f0383..258106d2 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::config::Configuration; use crate::errors::ServiceError; -use crate::utils::time::current_time; +use crate::utils::clock::current_time; pub struct MailerService { cfg: Arc, diff --git a/src/routes/user.rs b/src/routes/user.rs index 7fb36d46..11fa4ab9 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -13,8 +13,8 @@ use crate::errors::{ServiceError, ServiceResult}; use crate::mailer::VerifyClaims; use crate::models::response::{OkResponse, TokenResponse}; use crate::models::user::UserAuthentication; +use crate::utils::clock::current_time; use crate::utils::regex::validate_email_address; -use crate::utils::time::current_time; pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index e3379b52..9107356b 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -29,6 +29,7 @@ pub struct TorrentRecordV2 { } impl TorrentRecordV2 { + #[must_use] pub fn from_v1_data(torrent: &TorrentRecordV1, torrent_info: &TorrentInfo, uploader: &UserRecordV1) -> Self { Self { torrent_id: torrent.torrent_id, @@ -75,7 +76,7 @@ impl SqliteDatabaseV2_0_0 { sqlx::migrate!("migrations/sqlite3") .run(&self.pool) .await - .expect("Could not run database migrations.") + .expect("Could not run database migrations."); } pub async fn reset_categories_sequence(&self) -> Result { diff --git a/src/utils/clock.rs b/src/utils/clock.rs new file mode 100644 index 00000000..6ba681a5 --- /dev/null +++ b/src/utils/clock.rs @@ -0,0 +1,4 @@ +#[must_use] +pub fn current_time() -> u64 { + u64::try_from(chrono::prelude::Utc::now().timestamp()).expect("timestamp should be positive") +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 53ec37a3..cf6e4d3a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ +pub mod clock; pub mod hex; pub mod parse_torrent; pub mod regex; -pub mod time; diff --git a/src/utils/time.rs b/src/utils/time.rs deleted file mode 100644 index 45f60cb4..00000000 --- a/src/utils/time.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub fn current_time() -> u64 { - chrono::prelude::Utc::now().timestamp() as u64 -} From 234e07a891bed9a93b9b87f7b56c40c71cd6c942 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 12:07:08 +0100 Subject: [PATCH 126/357] refactor: [#115] make TrackerService::retrieve_new_tracker_key private It does need to be public. --- src/tracker.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tracker.rs b/src/tracker.rs index 4e547f75..f91c33e2 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -111,7 +111,7 @@ impl TrackerService { } // issue a new tracker key from tracker and save it in database, tied to a user - pub async fn retrieve_new_tracker_key(&self, user_id: i64) -> Result { + async fn retrieve_new_tracker_key(&self, user_id: i64) -> Result { let settings = self.cfg.settings.read().await; let request_url = format!( From 4782f67114743c0052152bc854d148a66a0a2120 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 13:24:46 +0100 Subject: [PATCH 127/357] fix: run E2E tests that require a running tracker We have to start the test env before the condition to skip the test: ``` if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; } ``` Itherwise the test is never executed, even when you are using the shared env: ``` TORRUST_IDX_BACK_E2E_SHARED=true cargo test ``` --- tests/e2e/contexts/torrent/contract.rs | 77 ++++++++------------------ 1 file changed, 23 insertions(+), 54 deletions(-) diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 0f18f05b..d1cdc0cf 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -37,16 +37,13 @@ mod for_guests { #[tokio::test] async fn it_should_allow_guests_to_get_torrents() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; @@ -64,16 +61,13 @@ mod for_guests { #[tokio::test] async fn it_should_allow_guests_to_get_torrent_details_searching_by_id() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; @@ -121,16 +115,13 @@ mod for_guests { #[tokio::test] async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_id() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; @@ -160,16 +151,13 @@ mod for_guests { #[tokio::test] async fn it_should_not_allow_guests_to_delete_torrents() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; @@ -193,16 +181,13 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_authenticated_users_to_upload_new_torrents() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let uploader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); @@ -245,14 +230,13 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let uploader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); @@ -268,23 +252,20 @@ mod for_authenticated_users { let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); let response = client.upload_torrent(form.into()).await; - assert_eq!(response.body, ""); + assert_eq!(response.body, "{\"error\":\"This torrent title has already been used.\"}"); assert_eq!(response.status, 400); } #[tokio::test] async fn it_should_not_allow_uploading_a_torrent_with_a_infohash_that_already_exists() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let uploader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); @@ -314,16 +295,13 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_not_allow_non_admins_to_delete_torrents() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let uploader = new_logged_in_user(&env).await; let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; @@ -346,16 +324,13 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_torrent_owners_to_update_their_torrents() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let uploader = new_logged_in_user(&env).await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; @@ -395,16 +370,13 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_admins_to_delete_torrents_searching_by_id() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let uploader = new_logged_in_user(&env).await; let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; @@ -422,16 +394,13 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_admins_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); + env.start().await; if !env.provides_a_tracker() { - // This test requires the tracker to be running, - // because when you upload a torrent, it's added to the tracker - // whitelist. + println!("test skipped. It requires a tracker to be running."); return; } - env.start().await; - let uploader = new_logged_in_user(&env).await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; From e8bf53752bdf346a2fbde95e9f16b523678cbdbe Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 13:29:03 +0100 Subject: [PATCH 128/357] refactor: [#115] extractfunction expected_torrent --- tests/e2e/contexts/torrent/asserts.rs | 20 ++++++++++++++++++++ tests/e2e/contexts/torrent/contract.rs | 21 +++++---------------- tests/e2e/contexts/torrent/mod.rs | 1 + 3 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/contexts/torrent/asserts.rs diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs new file mode 100644 index 00000000..a7561a97 --- /dev/null +++ b/tests/e2e/contexts/torrent/asserts.rs @@ -0,0 +1,20 @@ +use torrust_index_backend::models::torrent_file::Torrent; + +/// The backend does not generate exactly the same torrent that was uploaded. +/// So we need to update the expected torrent to match the one generated by +/// the backend. +pub fn expected_torrent(mut uploaded_torrent: Torrent) -> Torrent { + // code-review: The backend does not generate exactly the same torrent + // that was uploaded and created by the `imdl` command-line tool. + // So we need to update the expected torrent to match the one generated + // by the backend. For some of them it makes sense (`announce` and `announce_list`), + // for others it does not. + uploaded_torrent.info.private = Some(0); + uploaded_torrent.announce = Some("udp://tracker:6969".to_string()); + uploaded_torrent.encoding = None; + uploaded_torrent.announce_list = Some(vec![vec!["udp://tracker:6969".to_string()]]); + uploaded_torrent.creation_date = None; + uploaded_torrent.created_by = None; + + uploaded_torrent +} diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index d1cdc0cf..3e102f4e 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -30,6 +30,7 @@ mod for_guests { use crate::common::contexts::torrent::responses::{ Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, }; + use crate::e2e::contexts::torrent::asserts::expected_torrent; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -125,25 +126,13 @@ mod for_guests { let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.download_torrent(uploaded_torrent.torrent_id).await; + let response = client.download_torrent(torrent_listed_in_index.torrent_id).await; let torrent = decode_torrent(&response.bytes).unwrap(); - let mut expected_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); - - // code-review: The backend does not generate exactly the same torrent - // that was uploaded and created by the `imdl` command-line tool. - // So we need to update the expected torrent to match the one generated - // by the backend. For some of them it makes sense (`announce` and `announce_list`), - // for others it does not. - expected_torrent.info.private = Some(0); - expected_torrent.announce = Some("udp://tracker:6969".to_string()); - expected_torrent.encoding = None; - expected_torrent.announce_list = Some(vec![vec!["udp://tracker:6969".to_string()]]); - expected_torrent.creation_date = None; - expected_torrent.created_by = None; - + let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + let expected_torrent = expected_torrent(uploaded_torrent); assert_eq!(torrent, expected_torrent); assert!(response.is_bittorrent_and_ok()); } diff --git a/tests/e2e/contexts/torrent/mod.rs b/tests/e2e/contexts/torrent/mod.rs index 2001efb8..c0126d77 100644 --- a/tests/e2e/contexts/torrent/mod.rs +++ b/tests/e2e/contexts/torrent/mod.rs @@ -1,2 +1,3 @@ +pub mod asserts; pub mod contract; pub mod steps; From 32af56d4e2f8f00d2effa36ef0a05c0ba553c658 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 14:13:17 +0100 Subject: [PATCH 129/357] test: [#115] add E2E tests for torrent download with personal announce url --- tests/e2e/contexts/torrent/asserts.rs | 61 ++++++++++++++++++++++++-- tests/e2e/contexts/torrent/contract.rs | 41 ++++++++++++++--- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs index a7561a97..b6e8ae76 100644 --- a/tests/e2e/contexts/torrent/asserts.rs +++ b/tests/e2e/contexts/torrent/asserts.rs @@ -1,20 +1,75 @@ +use std::sync::Arc; + +use torrust_index_backend::databases::database::connect_database; use torrust_index_backend::models::torrent_file::Torrent; +use torrust_index_backend::models::tracker_key::TrackerKey; + +use crate::common::contexts::user::responses::LoggedInUserData; +use crate::e2e::environment::TestEnv; /// The backend does not generate exactly the same torrent that was uploaded. /// So we need to update the expected torrent to match the one generated by /// the backend. -pub fn expected_torrent(mut uploaded_torrent: Torrent) -> Torrent { +pub async fn expected_torrent(mut uploaded_torrent: Torrent, env: &TestEnv, downloader: &Option) -> Torrent { // code-review: The backend does not generate exactly the same torrent // that was uploaded and created by the `imdl` command-line tool. // So we need to update the expected torrent to match the one generated // by the backend. For some of them it makes sense (`announce` and `announce_list`), // for others it does not. + + let tracker_url = format!("{}", env.server_settings().unwrap().tracker.url); + + let tracker_key = match downloader { + Some(logged_in_user) => get_user_tracker_key(logged_in_user, env).await, + None => None, + }; + uploaded_torrent.info.private = Some(0); - uploaded_torrent.announce = Some("udp://tracker:6969".to_string()); + uploaded_torrent.announce = Some(build_announce_url(&tracker_url, &tracker_key)); uploaded_torrent.encoding = None; - uploaded_torrent.announce_list = Some(vec![vec!["udp://tracker:6969".to_string()]]); + uploaded_torrent.announce_list = Some(build_announce_list(&tracker_url, &tracker_key)); uploaded_torrent.creation_date = None; uploaded_torrent.created_by = None; uploaded_torrent } + +async fn get_user_tracker_key(logged_in_user: &LoggedInUserData, env: &TestEnv) -> Option { + // code-review: could we add a new endpoint to get the user's tracker key? + // `/user/keys/recent` or `/user/keys/latest + // We could use that endpoint to get the user's tracker key instead of + // querying the database. + + let database = Arc::new( + connect_database(&env.database_connect_url().unwrap()) + .await + .expect("Database error."), + ); + + // Get the logged-in user id + let user_profile = database + .get_user_profile_from_username(&logged_in_user.username) + .await + .unwrap(); + + // Get the user's tracker key + let tracker_key = database.get_user_tracker_key(user_profile.user_id).await.unwrap(); + + Some(tracker_key) +} + +fn build_announce_url(tracker_url: &str, tracker_key: &Option) -> String { + if let Some(key) = &tracker_key { + format!("{tracker_url}/{}", key.key) + } else { + format!("{tracker_url}") + } +} + +fn build_announce_list(tracker_url: &str, tracker_key: &Option) -> Vec> { + if let Some(key) = &tracker_key { + vec![vec![format!("{tracker_url}/{}", key.key)], vec![format!("{tracker_url}")]] + } else { + vec![vec![format!("{tracker_url}")]] + } +} diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 3e102f4e..4d477cc5 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -3,10 +3,6 @@ /* todo: -Download torrent file: - -- It should allow authenticated users to download a torrent with a personal tracker url - Delete torrent: - After deleting a torrent, it should be removed from the tracker whitelist @@ -132,7 +128,7 @@ mod for_guests { let torrent = decode_torrent(&response.bytes).unwrap(); let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); - let expected_torrent = expected_torrent(uploaded_torrent); + let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; assert_eq!(torrent, expected_torrent); assert!(response.is_bittorrent_and_ok()); } @@ -160,10 +156,14 @@ mod for_guests { mod for_authenticated_users { + use torrust_index_backend::utils::parse_torrent::decode_torrent; + use crate::common::client::Client; use crate::common::contexts::torrent::fixtures::random_torrent; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::contexts::torrent::asserts::expected_torrent; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -275,6 +275,37 @@ mod for_authenticated_users { assert_eq!(response.status, 400); } + #[tokio::test] + async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { + let mut env = TestEnv::new(); + env.start().await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + // Given a previously uploaded torrent + let uploader = new_logged_in_user(&env).await; + let (test_torrent, torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + let torrent_id = torrent_listed_in_index.torrent_id; + let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + + // And a logged in user who is going to download the torrent + let downloader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); + + // When the user downloads the torrent + let response = client.download_torrent(torrent_id).await; + let torrent = decode_torrent(&response.bytes).unwrap(); + + // Then the torrent should have the personal announce URL + let expected_torrent = expected_torrent(uploaded_torrent, &env, &Some(downloader)).await; + + assert_eq!(torrent, expected_torrent); + assert!(response.is_bittorrent_and_ok()); + } + mod and_non_admins { use crate::common::client::Client; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; From e0c01d66ef2da6c2087051bc970bd81459520bb4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 17:01:57 +0100 Subject: [PATCH 130/357] feat: [#115] add cargo dependencies: thiserror and binascii They will used in a new struct InfoHash. --- Cargo.lock | 8 ++++++++ Cargo.toml | 2 ++ 2 files changed, 10 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fb3ebdd2..4daab711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + [[package]] name = "bitflags" version = "1.3.2" @@ -3024,6 +3030,7 @@ dependencies = [ "actix-web", "argon2", "async-trait", + "binascii", "bytes", "chrono", "config", @@ -3050,6 +3057,7 @@ dependencies = [ "tempfile", "text-colorizer", "text-to-png", + "thiserror", "tokio", "toml 0.7.3", "urlencoding", diff --git a/Cargo.toml b/Cargo.toml index d84f7d4e..0d92bcdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,8 @@ fern = "0.6" bytes = "1.4" text-to-png = "0.2" indexmap = "1.9" +thiserror = "1.0" +binascii = "0.1" [dev-dependencies] rand = "0.8" From 16bd04c763d9f056d132fe2b17d42b81186d3b28 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 17:06:21 +0100 Subject: [PATCH 131/357] feat!: [#115] change endpoint GET /torrent/download/{id} to GET /torrent/download/{infohash} BREAKING CHANGE: you can not use the odl endpoint enaymore: `GET /torrent/download/{id}`. You have to use the torrent infohash. --- project-words.txt | 4 + src/databases/database.rs | 33 +- src/databases/mysql.rs | 27 +- src/databases/sqlite.rs | 27 +- src/models/info_hash.rs | 419 ++++++++++++++++++++++ src/models/mod.rs | 1 + src/models/torrent_file.rs | 2 + src/routes/torrent.rs | 25 +- tests/common/client.rs | 6 +- tests/common/contexts/torrent/fixtures.rs | 4 + tests/common/contexts/torrent/requests.rs | 1 + tests/e2e/contexts/torrent/contract.rs | 20 +- 12 files changed, 522 insertions(+), 47 deletions(-) create mode 100644 src/models/info_hash.rs diff --git a/project-words.txt b/project-words.txt index 3b609bb7..a76aa985 100644 --- a/project-words.txt +++ b/project-words.txt @@ -3,6 +3,7 @@ addrs AUTOINCREMENT bencode bencoded +binascii btih chrono compatiblelicenses @@ -16,6 +17,7 @@ Dont Grünwald hasher Hasher +hexlify httpseeds imagoodboy imdl @@ -27,6 +29,7 @@ LEECHERS lettre luckythelab mailcatcher +metainfo nanos NCCA nilm @@ -47,6 +50,7 @@ sublist subpoints tempdir tempfile +thiserror torrust Torrust upgrader diff --git a/src/databases/database.rs b/src/databases/database.rs index 0f06f702..17ce8743 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::databases::mysql::MysqlDatabase; use crate::databases::sqlite::SqliteDatabase; +use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; @@ -154,12 +155,42 @@ pub trait Database: Sync + Send { description: &str, ) -> Result; + /// Get `Torrent` from `InfoHash`. + async fn get_torrent_from_info_hash(&self, info_hash: &InfoHash) -> Result { + let torrent_info = self.get_torrent_info_from_infohash(*info_hash).await?; + + let torrent_files = self.get_torrent_files_from_id(torrent_info.torrent_id).await?; + + let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_info.torrent_id).await?; + + Ok(Torrent::from_db_info_files_and_announce_urls( + torrent_info, + torrent_files, + torrent_announce_urls, + )) + } + /// Get `Torrent` from `torrent_id`. - async fn get_torrent_from_id(&self, torrent_id: i64) -> Result; + async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { + let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; + + let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; + + let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; + + Ok(Torrent::from_db_info_files_and_announce_urls( + torrent_info, + torrent_files, + torrent_announce_urls, + )) + } /// Get torrent's info as `DbTorrentInfo` from `torrent_id`. async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; + /// Get torrent's info as `DbTorrentInfo` from torrent `InfoHash`. + async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result; + /// Get all torrent's files as `Vec` from `torrent_id`. async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 028eaf5b..252a10e2 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -4,6 +4,7 @@ use sqlx::mysql::MySqlPoolOptions; use sqlx::{query, query_as, Acquire, MySqlPool}; use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; +use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; @@ -528,23 +529,9 @@ impl Database for MysqlDatabase { } } - async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { - let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; - - let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; - - let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - - Ok(Torrent::from_db_info_files_and_announce_urls( - torrent_info, - torrent_files, - torrent_announce_urls, - )) - } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( - "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) .bind(torrent_id) .fetch_one(&self.pool) @@ -552,6 +539,16 @@ impl Database for MysqlDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } + async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result { + query_as::<_, DbTorrentInfo>( + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", + ) + .bind(info_hash.to_string()) + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) + } + async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { let db_torrent_files = query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 538bf378..ee7814c6 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -4,6 +4,7 @@ use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query, query_as, Acquire, SqlitePool}; use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; +use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; @@ -523,23 +524,9 @@ impl Database for SqliteDatabase { } } - async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { - let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; - - let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; - - let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - - Ok(Torrent::from_db_info_files_and_announce_urls( - torrent_info, - torrent_files, - torrent_announce_urls, - )) - } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( - "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) .bind(torrent_id) .fetch_one(&self.pool) @@ -547,6 +534,16 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } + async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result { + query_as::<_, DbTorrentInfo>( + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", + ) + .bind(info_hash.to_string().to_uppercase()) // info_hash is stored as uppercase + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) + } + async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { let db_torrent_files = query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs new file mode 100644 index 00000000..7392c791 --- /dev/null +++ b/src/models/info_hash.rs @@ -0,0 +1,419 @@ +//! A `BitTorrent` `InfoHash`. It's a unique identifier for a `BitTorrent` torrent. +//! +//! "The 20-byte sha1 hash of the bencoded form of the info value +//! from the metainfo file." +//! +//! See [BEP 3. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) +//! for the official specification. +//! +//! This modules provides a type that can be used to represent infohashes. +//! +//! > **NOTICE**: It only supports Info Hash v1. +//! +//! Typically infohashes are represented as hex strings, but internally they are +//! a 20-byte array. +//! +//! # Calculating the info-hash of a torrent file +//! +//! A sample torrent: +//! +//! - Torrent file: `mandelbrot_2048x2048_infohash_v1.png.torrent` +//! - File: `mandelbrot_2048x2048.png` +//! - Info Hash v1: `5452869be36f9f3350ccee6b4544e7e76caaadab` +//! - Sha1 hash of the info dictionary: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +//! +//! A torrent file is a binary file encoded with [Bencode encoding](https://en.wikipedia.org/wiki/Bencode): +//! +//! ```text +//! 0000000: 6431 303a 6372 6561 7465 6420 6279 3138 d10:created by18 +//! 0000010: 3a71 4269 7474 6f72 7265 6e74 2076 342e :qBittorrent v4. +//! 0000020: 342e 3131 333a 6372 6561 7469 6f6e 2064 4.113:creation d +//! 0000030: 6174 6569 3136 3739 3637 3436 3238 6534 atei1679674628e4 +//! 0000040: 3a69 6e66 6f64 363a 6c65 6e67 7468 6931 :infod6:lengthi1 +//! 0000050: 3732 3230 3465 343a 6e61 6d65 3234 3a6d 72204e4:name24:m +//! 0000060: 616e 6465 6c62 726f 745f 3230 3438 7832 andelbrot_2048x2 +//! 0000070: 3034 382e 706e 6731 323a 7069 6563 6520 048.png12:piece +//! 0000080: 6c65 6e67 7468 6931 3633 3834 6536 3a70 lengthi16384e6:p +//! 0000090: 6965 6365 7332 3230 3a7d 9171 0d9d 4dba ieces220:}.q..M. +//! 00000a0: 889b 5420 54d5 2672 8d5a 863f e121 df77 ..T T.&r.Z.?.!.w +//! 00000b0: c7f7 bb6c 7796 2166 2538 c5d9 cdab 8b08 ...lw.!f%8...... +//! 00000c0: ef8c 249b b2f5 c4cd 2adf 0bc0 0cf0 addf ..$.....*....... +//! 00000d0: 7290 e5b6 414c 236c 479b 8e9f 46aa 0c0d r...AL#lG...F... +//! 00000e0: 8ed1 97ff ee68 8b5f 34a3 87d7 71c5 a6f9 .....h._4...q... +//! 00000f0: 8e2e a631 7cbd f0f9 e223 f9cc 80af 5400 ...1|....#....T. +//! 0000100: 04f9 8569 1c77 89c1 764e d6aa bf61 a6c2 ...i.w..vN...a.. +//! 0000110: 8099 abb6 5f60 2f40 a825 be32 a33d 9d07 ...._`/@.%.2.=.. +//! 0000120: 0c79 6898 d49d 6349 af20 5866 266f 986b .yh...cI. Xf&o.k +//! 0000130: 6d32 34cd 7d08 155e 1ad0 0009 57ab 303b m24.}..^....W.0; +//! 0000140: 2060 c1dc 1287 d6f3 e745 4f70 6709 3631 `.......EOpg.61 +//! 0000150: 55f2 20f6 6ca5 156f 2c89 9569 1653 817d U. .l..o,..i.S.} +//! 0000160: 31f1 b6bd 3742 cc11 0bb2 fc2b 49a5 85b6 1...7B.....+I... +//! 0000170: fc76 7444 9365 65 .vtD.ee +//! ``` +//! +//! You can generate that output with the command: +//! +//! ```text +//! xxd mandelbrot_2048x2048_infohash_v1.png.torrent +//! ``` +//! +//! And you can show only the bytes (hexadecimal): +//! +//! ```text +//! 6431303a6372656174656420627931383a71426974746f7272656e742076 +//! 342e342e3131333a6372656174696f6e2064617465693136373936373436 +//! 323865343a696e666f64363a6c656e6774686931373232303465343a6e61 +//! 6d6532343a6d616e64656c62726f745f3230343878323034382e706e6731 +//! 323a7069656365206c656e67746869313633383465363a70696563657332 +//! 32303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c +//! 779621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290 +//! e5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f9 +//! 8e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61 +//! a6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866 +//! 266f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e745 +//! 4f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc11 +//! 0bb2fc2b49a585b6fc767444936565 +//! ``` +//! +//! You can generate that output with the command: +//! +//! ```text +//! `xxd -ps mandelbrot_2048x2048_infohash_v1.png.torrent`. +//! ``` +//! +//! The same data can be represented in a JSON format: +//! +//! ```json +//! { +//! "created by": "qBittorrent v4.4.1", +//! "creation date": 1679674628, +//! "info": { +//! "length": 172204, +//! "name": "mandelbrot_2048x2048.png", +//! "piece length": 16384, +//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" +//! } +//! } +//! ``` +//! +//! The JSON object was generated with: +//! +//! As you can see, there is a `info` attribute: +//! +//! ```json +//! { +//! "length": 172204, +//! "name": "mandelbrot_2048x2048.png", +//! "piece length": 16384, +//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" +//! } +//! ``` +//! +//! The infohash is the [SHA1](https://en.wikipedia.org/wiki/SHA-1) hash +//! of the `info` attribute. That is, the SHA1 hash of: +//! +//! ```text +//! 64363a6c656e6774686931373232303465343a6e61 +//! d6532343a6d616e64656c62726f745f3230343878323034382e706e6731 +//! 23a7069656365206c656e67746869313633383465363a70696563657332 +//! 2303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c +//! 79621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290 +//! 5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f9 +//! e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61 +//! 6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866 +//! 66f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e745 +//! f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc11 +//! bb2fc2b49a585b6fc7674449365 +//! ``` +//! +//! You can hash that byte string with +//! +//! The result is a 20-char string: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +use std::panic::Location; + +use thiserror::Error; + +/// `BitTorrent` Info Hash v1 +#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)] +pub struct InfoHash(pub [u8; 20]); + +const INFO_HASH_BYTES_LEN: usize = 20; + +impl InfoHash { + /// Create a new `InfoHash` from a byte slice. + /// + /// # Panics + /// + /// Will panic if byte slice does not contains the exact amount of bytes need for the `InfoHash`. + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Self { + assert_eq!(bytes.len(), INFO_HASH_BYTES_LEN); + let mut ret = Self([0u8; INFO_HASH_BYTES_LEN]); + ret.0.clone_from_slice(bytes); + ret + } + + /// Returns the `InfoHash` internal byte array. + #[must_use] + pub fn bytes(&self) -> [u8; 20] { + self.0 + } + + /// Returns the `InfoHash` as a hex string. + #[must_use] + pub fn to_hex_string(&self) -> String { + self.to_string() + } +} + +impl std::fmt::Display for InfoHash { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let mut chars = [0u8; 40]; + binascii::bin2hex(&self.0, &mut chars).expect("failed to hexlify"); + write!(f, "{}", std::str::from_utf8(&chars).unwrap()) + } +} + +impl std::str::FromStr for InfoHash { + type Err = binascii::ConvertError; + + fn from_str(s: &str) -> Result { + let mut i = Self([0u8; 20]); + if s.len() != 40 { + return Err(binascii::ConvertError::InvalidInputLength); + } + binascii::hex2bin(s.as_bytes(), &mut i.0)?; + Ok(i) + } +} + +impl Ord for InfoHash { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } +} + +impl std::cmp::PartialOrd for InfoHash { + fn partial_cmp(&self, other: &InfoHash) -> Option { + self.0.partial_cmp(&other.0) + } +} + +impl std::convert::From<&[u8]> for InfoHash { + fn from(data: &[u8]) -> InfoHash { + assert_eq!(data.len(), 20); + let mut ret = InfoHash([0u8; 20]); + ret.0.clone_from_slice(data); + ret + } +} + +impl std::convert::From<[u8; 20]> for InfoHash { + fn from(val: [u8; 20]) -> Self { + InfoHash(val) + } +} + +/// Errors that can occur when converting from a `Vec` to an `InfoHash`. +#[derive(Error, Debug)] +pub enum ConversionError { + /// Not enough bytes for infohash. An infohash is 20 bytes. + #[error("not enough bytes for infohash: {message} {location}")] + NotEnoughBytes { + location: &'static Location<'static>, + message: String, + }, + /// Too many bytes for infohash. An infohash is 20 bytes. + #[error("too many bytes for infohash: {message} {location}")] + TooManyBytes { + location: &'static Location<'static>, + message: String, + }, +} + +impl TryFrom> for InfoHash { + type Error = ConversionError; + + fn try_from(bytes: Vec) -> Result { + if bytes.len() < INFO_HASH_BYTES_LEN { + return Err(ConversionError::NotEnoughBytes { + location: Location::caller(), + message: format! {"got {} bytes, expected {}", bytes.len(), INFO_HASH_BYTES_LEN}, + }); + } + if bytes.len() > INFO_HASH_BYTES_LEN { + return Err(ConversionError::TooManyBytes { + location: Location::caller(), + message: format! {"got {} bytes, expected {}", bytes.len(), INFO_HASH_BYTES_LEN}, + }); + } + Ok(Self::from_bytes(&bytes)) + } +} + +impl serde::ser::Serialize for InfoHash { + fn serialize(&self, serializer: S) -> Result { + let mut buffer = [0u8; 40]; + let bytes_out = binascii::bin2hex(&self.0, &mut buffer).ok().unwrap(); + let str_out = std::str::from_utf8(bytes_out).unwrap(); + serializer.serialize_str(str_out) + } +} + +impl<'de> serde::de::Deserialize<'de> for InfoHash { + fn deserialize>(des: D) -> Result { + des.deserialize_str(InfoHashVisitor) + } +} + +struct InfoHashVisitor; + +impl<'v> serde::de::Visitor<'v> for InfoHashVisitor { + type Value = InfoHash; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a 40 character long hash") + } + + fn visit_str(self, v: &str) -> Result { + if v.len() != 40 { + return Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(v), + &"a 40 character long string", + )); + } + + let mut res = InfoHash([0u8; 20]); + + if binascii::hex2bin(v.as_bytes(), &mut res.0).is_err() { + return Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(v), + &"a hexadecimal string", + )); + }; + Ok(res) + } +} + +#[cfg(test)] +mod tests { + + use std::str::FromStr; + + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use super::InfoHash; + + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + struct ContainingInfoHash { + pub info_hash: InfoHash, + } + + #[test] + fn an_info_hash_can_be_created_from_a_valid_40_utf8_char_string_representing_an_hexadecimal_value() { + let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); + assert!(info_hash.is_ok()); + } + + #[test] + fn an_info_hash_can_not_be_created_from_a_utf8_string_representing_a_not_valid_hexadecimal_value() { + let info_hash = InfoHash::from_str("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"); + assert!(info_hash.is_err()); + } + + #[test] + fn an_info_hash_can_only_be_created_from_a_40_utf8_char_string() { + let info_hash = InfoHash::from_str(&"F".repeat(39)); + assert!(info_hash.is_err()); + + let info_hash = InfoHash::from_str(&"F".repeat(41)); + assert!(info_hash.is_err()); + } + + #[test] + fn an_info_hash_should_by_displayed_like_a_40_utf8_lowercased_char_hex_string() { + let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(); + + let output = format!("{info_hash}"); + + assert_eq!(output, "ffffffffffffffffffffffffffffffffffffffff"); + } + + #[test] + fn an_info_hash_should_return_its_a_40_utf8_lowercased_char_hex_representations_as_string() { + let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(); + + assert_eq!(info_hash.to_hex_string(), "ffffffffffffffffffffffffffffffffffffffff"); + } + + #[test] + fn an_info_hash_can_be_created_from_a_valid_20_byte_array_slice() { + let info_hash: InfoHash = [255u8; 20].as_slice().into(); + + assert_eq!( + info_hash, + InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + ); + } + + #[test] + fn an_info_hash_can_be_created_from_a_valid_20_byte_array() { + let info_hash: InfoHash = [255u8; 20].into(); + + assert_eq!( + info_hash, + InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + ); + } + + #[test] + fn an_info_hash_can_be_created_from_a_byte_vector() { + let info_hash: InfoHash = [255u8; 20].to_vec().try_into().unwrap(); + + assert_eq!( + info_hash, + InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + ); + } + + #[test] + fn it_should_fail_trying_to_create_an_info_hash_from_a_byte_vector_with_less_than_20_bytes() { + assert!(InfoHash::try_from([255u8; 19].to_vec()).is_err()); + } + + #[test] + fn it_should_fail_trying_to_create_an_info_hash_from_a_byte_vector_with_more_than_20_bytes() { + assert!(InfoHash::try_from([255u8; 21].to_vec()).is_err()); + } + + #[test] + fn an_info_hash_can_be_serialized() { + let s = ContainingInfoHash { + info_hash: InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(), + }; + + let json_serialized_value = serde_json::to_string(&s).unwrap(); + + assert_eq!( + json_serialized_value, + r#"{"info_hash":"ffffffffffffffffffffffffffffffffffffffff"}"# + ); + } + + #[test] + fn an_info_hash_can_be_deserialized() { + let json = json!({ + "info_hash": "ffffffffffffffffffffffffffffffffffffffff", + }); + + let s: ContainingInfoHash = serde_json::from_value(json).unwrap(); + + assert_eq!( + s, + ContainingInfoHash { + info_hash: InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + } + ); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index acfccf77..6a317c58 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod info_hash; pub mod response; pub mod torrent; pub mod torrent_file; diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index ff34be5e..be3b6101 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -233,6 +233,8 @@ pub struct DbTorrentFile { #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct DbTorrentInfo { + pub torrent_id: i64, + pub info_hash: String, pub name: String, pub pieces: String, pub piece_length: i64, diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 141be82f..10f5fdba 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -1,4 +1,5 @@ use std::io::{Cursor, Write}; +use std::str::FromStr; use actix_multipart::Multipart; use actix_web::web::Query; @@ -10,6 +11,7 @@ use sqlx::FromRow; use crate::common::WebAppData; use crate::databases::database::Sorting; use crate::errors::{ServiceError, ServiceResult}; +use crate::models::info_hash::InfoHash; use crate::models::response::{NewTorrentResponse, OkResponse, TorrentResponse}; use crate::models::torrent::TorrentRequest; use crate::utils::parse_torrent; @@ -19,7 +21,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/torrent") .service(web::resource("/upload").route(web::post().to(upload_torrent))) - .service(web::resource("/download/{id}").route(web::get().to(download_torrent))) + .service(web::resource("/download/{info_hash}").route(web::get().to(download_torrent_handler))) .service( web::resource("/{id}") .route(web::get().to(get_torrent)) @@ -121,13 +123,18 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA })) } -pub async fn download_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let torrent_id = get_torrent_id_from_request(&req)?; +/// Returns the torrent as a byte stream `application/x-bittorrent`. +/// +/// # Errors +/// +/// Returns `ServiceError::BadRequest` if the torrent infohash is invalid. +pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { + let info_hash = get_torrent_info_hash_from_request(&req)?; // optional let user = app_data.auth.get_user_compact_from_request(&req).await; - let mut torrent = app_data.database.get_torrent_from_id(torrent_id).await?; + let mut torrent = app_data.database.get_torrent_from_info_hash(&info_hash).await?; let settings = app_data.cfg.settings.read().await; @@ -333,6 +340,16 @@ fn get_torrent_id_from_request(req: &HttpRequest) -> Result { } } +fn get_torrent_info_hash_from_request(req: &HttpRequest) -> Result { + match req.match_info().get("info_hash") { + None => Err(ServiceError::BadRequest), + Some(info_hash) => match InfoHash::from_str(info_hash) { + Err(_) => Err(ServiceError::BadRequest), + Ok(v) => Ok(v), + }, + } +} + async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let torrent_buffer = vec![0u8]; let mut torrent_cursor = Cursor::new(torrent_buffer); diff --git a/tests/common/client.rs b/tests/common/client.rs index d0f84991..c3501519 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -5,7 +5,7 @@ use super::connection_info::ConnectionInfo; use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::settings::form::UpdateSettings; use super::contexts::torrent::forms::UpdateTorrentFrom; -use super::contexts::torrent::requests::TorrentId; +use super::contexts::torrent::requests::{InfoHash, TorrentId}; use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; use super::http::{Query, ReqwestQuery}; use super::responses::{self, BinaryResponse, TextResponse}; @@ -109,9 +109,9 @@ impl Client { self.http_client.post_multipart("torrent/upload", form).await } - pub async fn download_torrent(&self, id: TorrentId) -> responses::BinaryResponse { + pub async fn download_torrent(&self, info_hash: InfoHash) -> responses::BinaryResponse { self.http_client - .get_binary(&format!("torrent/download/{id}"), Query::empty()) + .get_binary(&format!("torrent/download/{info_hash}"), Query::empty()) .await } diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index f2a46748..00e26ecf 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -90,6 +90,10 @@ impl TestTorrent { index_info: torrent_to_index, } } + + pub fn info_hash(&self) -> String { + self.file_info.info_hash.clone() + } } pub fn random_torrent() -> TestTorrent { diff --git a/tests/common/contexts/torrent/requests.rs b/tests/common/contexts/torrent/requests.rs index 259a2dae..946c475a 100644 --- a/tests/common/contexts/torrent/requests.rs +++ b/tests/common/contexts/torrent/requests.rs @@ -1 +1,2 @@ pub type TorrentId = i64; +pub type InfoHash = String; diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 4d477cc5..2620dd37 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -122,12 +122,13 @@ mod for_guests { let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; - let (test_torrent, torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.download_torrent(torrent_listed_in_index.torrent_id).await; + let response = client.download_torrent(test_torrent.info_hash()).await; - let torrent = decode_torrent(&response.bytes).unwrap(); - let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + let uploaded_torrent = + decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; assert_eq!(torrent, expected_torrent); assert!(response.is_bittorrent_and_ok()); @@ -287,17 +288,18 @@ mod for_authenticated_users { // Given a previously uploaded torrent let uploader = new_logged_in_user(&env).await; - let (test_torrent, torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let torrent_id = torrent_listed_in_index.torrent_id; - let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + let uploaded_torrent = + decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); // And a logged in user who is going to download the torrent let downloader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); // When the user downloads the torrent - let response = client.download_torrent(torrent_id).await; - let torrent = decode_torrent(&response.bytes).unwrap(); + let response = client.download_torrent(test_torrent.info_hash()).await; + + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); // Then the torrent should have the personal announce URL let expected_torrent = expected_torrent(uploaded_torrent, &env, &Some(downloader)).await; From e9762ff9f719fd66b32725aab5fb9f12a10c3c81 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 8 May 2023 12:54:08 +0100 Subject: [PATCH 132/357] test: [#115] add more E2E tests for endpoints using torrent ID Since we are going to change those endpoint, it's convenient to add more test. Endpoints starting with `torrent/{id}` will be change to use the infohash instead of the internal torrent id, generated by the database. --- src/routes/torrent.rs | 2 + tests/e2e/contexts/torrent/asserts.rs | 11 +-- tests/e2e/contexts/torrent/contract.rs | 92 ++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 10f5fdba..411ad6eb 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -220,6 +220,8 @@ pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResul } } + // todo: extract a struct or function to build the magnet links + // add magnet link let mut magnet = format!( "magnet:?xt=urn:btih:{}&dn={}", diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs index b6e8ae76..c5c9759f 100644 --- a/tests/e2e/contexts/torrent/asserts.rs +++ b/tests/e2e/contexts/torrent/asserts.rs @@ -34,7 +34,7 @@ pub async fn expected_torrent(mut uploaded_torrent: Torrent, env: &TestEnv, down uploaded_torrent } -async fn get_user_tracker_key(logged_in_user: &LoggedInUserData, env: &TestEnv) -> Option { +pub async fn get_user_tracker_key(logged_in_user: &LoggedInUserData, env: &TestEnv) -> Option { // code-review: could we add a new endpoint to get the user's tracker key? // `/user/keys/recent` or `/user/keys/latest // We could use that endpoint to get the user's tracker key instead of @@ -43,7 +43,7 @@ async fn get_user_tracker_key(logged_in_user: &LoggedInUserData, env: &TestEnv) let database = Arc::new( connect_database(&env.database_connect_url().unwrap()) .await - .expect("Database error."), + .expect("database connection to be established."), ); // Get the logged-in user id @@ -53,12 +53,15 @@ async fn get_user_tracker_key(logged_in_user: &LoggedInUserData, env: &TestEnv) .unwrap(); // Get the user's tracker key - let tracker_key = database.get_user_tracker_key(user_profile.user_id).await.unwrap(); + let tracker_key = database + .get_user_tracker_key(user_profile.user_id) + .await + .expect("user to have a tracker key"); Some(tracker_key) } -fn build_announce_url(tracker_url: &str, tracker_key: &Option) -> String { +pub fn build_announce_url(tracker_url: &str, tracker_key: &Option) -> String { if let Some(key) = &tracker_key { format!("{tracker_url}/{}", key.key) } else { diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 2620dd37..cca181ac 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -10,9 +10,6 @@ Delete torrent: Get torrent info: - The torrent info: - - should contain the tracker URL - - If no user owned tracker key can be found, it should use the default tracker url - - If user owned tracker key can be found, it should use the personal tracker url - should contain the magnet link with the trackers from the torrent file - should contain realtime seeders and leechers from the tracker */ @@ -74,6 +71,9 @@ mod for_guests { let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); + let tracker_url = format!("{}", env.server_settings().unwrap().tracker.url); + let encoded_tracker_url = urlencoding::encode(&tracker_url); + let expected_torrent = TorrentDetails { torrent_id: uploaded_torrent.torrent_id, uploader: uploader.username, @@ -95,13 +95,19 @@ mod for_guests { length: test_torrent.file_info.content_size, md5sum: None, }], - // code-review: why is this duplicated? - trackers: vec!["udp://tracker:6969".to_string(), "udp://tracker:6969".to_string()], + // code-review: why is this duplicated? It seems that is adding the + // same tracker twice because first ti adds all trackers and then + // it adds the tracker with the personal announce url, if the user + // is logged in. If the user is not logged in, it adds the default + // tracker again, and it ends up with two trackers. + trackers: vec![tracker_url.clone(), tracker_url.clone()], magnet_link: format!( // cspell:disable-next-line - "magnet:?xt=urn:btih:{}&dn={}&tr=udp%3A%2F%2Ftracker%3A6969&tr=udp%3A%2F%2Ftracker%3A6969", + "magnet:?xt=urn:btih:{}&dn={}&tr={}&tr={}", test_torrent.file_info.info_hash.to_uppercase(), - test_torrent.index_info.title + urlencoding::encode(&test_torrent.index_info.title), + encoded_tracker_url, + encoded_tracker_url ), }; @@ -134,6 +140,26 @@ mod for_guests { assert!(response.is_bittorrent_and_ok()); } + #[tokio::test] + async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { + let mut env = TestEnv::new(); + env.start().await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let non_existing_info_hash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); + + let response = client.download_torrent(non_existing_info_hash).await; + + // code-review: should this be 404? + assert_eq!(response.status, 400); + } + #[tokio::test] async fn it_should_not_allow_guests_to_delete_torrents() { let mut env = TestEnv::new(); @@ -163,7 +189,7 @@ mod for_authenticated_users { use crate::common::contexts::torrent::fixtures::random_torrent; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; - use crate::e2e::contexts::torrent::asserts::expected_torrent; + use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -289,8 +315,6 @@ mod for_authenticated_users { // Given a previously uploaded torrent let uploader = new_logged_in_user(&env).await; let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let uploaded_torrent = - decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); // And a logged in user who is going to download the torrent let downloader = new_logged_in_user(&env).await; @@ -302,14 +326,20 @@ mod for_authenticated_users { let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); // Then the torrent should have the personal announce URL - let expected_torrent = expected_torrent(uploaded_torrent, &env, &Some(downloader)).await; - - assert_eq!(torrent, expected_torrent); - assert!(response.is_bittorrent_and_ok()); + let tracker_key = get_user_tracker_key(&downloader, &env) + .await + .expect("uploader should have a valid tracker key"); + let tracker_url = format!("{}", env.server_settings().unwrap().tracker.url); + + assert_eq!( + torrent.announce.unwrap(), + build_announce_url(&tracker_url, &Some(tracker_key)) + ); } mod and_non_admins { use crate::common::client::Client; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -333,6 +363,40 @@ mod for_authenticated_users { assert_eq!(response.status, 403); } + + #[tokio::test] + async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { + let mut env = TestEnv::new(); + env.start().await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + // Given a users uploads a torrent + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + // Then another non admin user should not be able to update the torrent + let not_the_uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), ¬_the_uploader.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + uploaded_torrent.torrent_id, + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + assert_eq!(response.status, 403); + } } mod and_torrent_owners { From 7298238e3e8e8167b354170cf2773b70c26df725 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 8 May 2023 16:13:47 +0100 Subject: [PATCH 133/357] feat!: [#115] change endpoints /torrent/{id} to /torrent({infohash} BREAKING CHANGE: you cannot use the old endpoints anymore: - `GET /torrent/{id}`. - `PUT /torrent/{id}`. - `DELETE /torrent/{id}`. New endpoints: - `GET /torrent/{infohashi`. - `PUT /torrent/{infohash}`. - `DELETE /torrent/{infohash}`. --- src/databases/database.rs | 9 ++- src/databases/mysql.rs | 26 ++++++-- src/databases/sqlite.rs | 24 ++++++- src/routes/torrent.rs | 79 ++++++++++++++--------- tests/common/client.rs | 18 +++--- tests/common/contexts/torrent/fixtures.rs | 3 +- tests/common/contexts/torrent/requests.rs | 1 - tests/e2e/contexts/torrent/contract.rs | 41 ++++++------ 8 files changed, 129 insertions(+), 72 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 17ce8743..915b52ce 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -156,8 +156,8 @@ pub trait Database: Sync + Send { ) -> Result; /// Get `Torrent` from `InfoHash`. - async fn get_torrent_from_info_hash(&self, info_hash: &InfoHash) -> Result { - let torrent_info = self.get_torrent_info_from_infohash(*info_hash).await?; + async fn get_torrent_from_infohash(&self, infohash: &InfoHash) -> Result { + let torrent_info = self.get_torrent_info_from_infohash(infohash).await?; let torrent_files = self.get_torrent_files_from_id(torrent_info.torrent_id).await?; @@ -189,7 +189,7 @@ pub trait Database: Sync + Send { async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; /// Get torrent's info as `DbTorrentInfo` from torrent `InfoHash`. - async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result; + async fn get_torrent_info_from_infohash(&self, info_hash: &InfoHash) -> Result; /// Get all torrent's files as `Vec` from `torrent_id`. async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError>; @@ -200,6 +200,9 @@ pub trait Database: Sync + Send { /// Get `TorrentListing` from `torrent_id`. async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result; + /// Get `TorrentListing` from `InfoHash`. + async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result; + /// Get all torrents as `Vec`. async fn get_all_torrents_compact(&self) -> Result, DatabaseError>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 252a10e2..668e94bc 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -406,7 +406,7 @@ impl Database for MysqlDatabase { let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") .bind(uploader_id) .bind(category_id) - .bind(info_hash) + .bind(info_hash.to_uppercase()) .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) .bind(pieces) @@ -539,11 +539,11 @@ impl Database for MysqlDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } - async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result { + async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { query_as::<_, DbTorrentInfo>( - "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) - .bind(info_hash.to_string()) + .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await .map_err(|_| DatabaseError::TorrentNotFound) @@ -596,6 +596,24 @@ impl Database for MysqlDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } + async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { + query_as::<_, TorrentListing>( + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, + CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, + CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers + FROM torrust_torrents tt + INNER JOIN torrust_user_profiles tp ON tt.uploader_id = tp.user_id + INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id + LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id + WHERE tt.info_hash = ? + GROUP BY torrent_id" + ) + .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) + } + async fn get_all_torrents_compact(&self) -> Result, DatabaseError> { query_as::<_, TorrentCompact>("SELECT torrent_id, info_hash FROM torrust_torrents") .fetch_all(&self.pool) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index ee7814c6..943d5e2d 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -401,7 +401,7 @@ impl Database for SqliteDatabase { let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") .bind(uploader_id) .bind(category_id) - .bind(info_hash) + .bind(info_hash.to_uppercase()) .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) .bind(pieces) @@ -534,11 +534,11 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } - async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result { + async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) - .bind(info_hash.to_string().to_uppercase()) // info_hash is stored as uppercase + .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await .map_err(|_| DatabaseError::TorrentNotFound) @@ -591,6 +591,24 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } + async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { + query_as::<_, TorrentListing>( + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, + CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, + CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers + FROM torrust_torrents tt + INNER JOIN torrust_user_profiles tp ON tt.uploader_id = tp.user_id + INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id + LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id + WHERE tt.info_hash = ? + GROUP BY ts.torrent_id" + ) + .bind(infohash.to_string().to_uppercase()) // `info_hash` is stored as uppercase + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) + } + async fn get_all_torrents_compact(&self) -> Result, DatabaseError> { query_as::<_, TorrentCompact>("SELECT torrent_id, info_hash FROM torrust_torrents") .fetch_all(&self.pool) diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 411ad6eb..55395f2e 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -23,10 +23,10 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { .service(web::resource("/upload").route(web::post().to(upload_torrent))) .service(web::resource("/download/{info_hash}").route(web::get().to(download_torrent_handler))) .service( - web::resource("/{id}") - .route(web::get().to(get_torrent)) - .route(web::put().to(update_torrent)) - .route(web::delete().to(delete_torrent)), + web::resource("/{info_hash}") + .route(web::get().to(get_torrent_handler)) + .route(web::put().to(update_torrent_handler)) + .route(web::delete().to(delete_torrent_handler)), ), ); cfg.service(web::scope("/torrents").service(web::resource("").route(web::get().to(get_torrents)))); @@ -129,12 +129,12 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA /// /// Returns `ServiceError::BadRequest` if the torrent infohash is invalid. pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let info_hash = get_torrent_info_hash_from_request(&req)?; + let info_hash = get_torrent_infohash_from_request(&req)?; // optional let user = app_data.auth.get_user_compact_from_request(&req).await; - let mut torrent = app_data.database.get_torrent_from_info_hash(&info_hash).await?; + let mut torrent = app_data.database.get_torrent_from_infohash(&info_hash).await?; let settings = app_data.cfg.settings.read().await; @@ -166,18 +166,26 @@ pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Ok(HttpResponse::Ok().content_type("application/x-bittorrent").body(buffer)) } -pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResult { +pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { // optional let user = app_data.auth.get_user_compact_from_request(&req).await; let settings = app_data.cfg.settings.read().await; - let torrent_id = get_torrent_id_from_request(&req)?; + let infohash = get_torrent_infohash_from_request(&req)?; - let torrent_listing = app_data.database.get_torrent_listing_from_id(torrent_id).await?; + println!("infohash: {}", infohash); + + let torrent_listing = app_data.database.get_torrent_listing_from_infohash(&infohash).await?; + + let torrent_id = torrent_listing.torrent_id; + + println!("torrent_listing: {:#?}", torrent_listing); let category = app_data.database.get_category_from_id(torrent_listing.category_id).await?; + println!("category: {:#?}", category); + let mut torrent_response = TorrentResponse::from_listing(torrent_listing); torrent_response.category = category; @@ -188,8 +196,12 @@ pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResul torrent_response.files = app_data.database.get_torrent_files_from_id(torrent_id).await?; + println!("torrent_response.files: {:#?}", torrent_response.files); + if torrent_response.files.len() == 1 { - let torrent_info = app_data.database.get_torrent_info_from_id(torrent_id).await?; + let torrent_info = app_data.database.get_torrent_info_from_infohash(&infohash).await?; + + println!("torrent_info: {:#?}", torrent_info); torrent_response .files @@ -203,6 +215,8 @@ pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResul .await .map(|v| v.into_iter().flatten().collect())?; + println!("trackers: {:#?}", torrent_response.trackers); + // add tracker url match user { Ok(user) => { @@ -249,16 +263,16 @@ pub async fn get_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResul Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } -pub async fn update_torrent( +pub async fn update_torrent_handler( req: HttpRequest, payload: web::Json, app_data: WebAppData, ) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; - let torrent_id = get_torrent_id_from_request(&req)?; + let infohash = get_torrent_infohash_from_request(&req)?; - let torrent_listing = app_data.database.get_torrent_listing_from_id(torrent_id).await?; + let torrent_listing = app_data.database.get_torrent_listing_from_infohash(&infohash).await?; // check if user is owner or administrator if torrent_listing.uploader != user.username && !user.administrator { @@ -267,22 +281,31 @@ pub async fn update_torrent( // update torrent title if let Some(title) = &payload.title { - app_data.database.update_torrent_title(torrent_id, title).await?; + app_data + .database + .update_torrent_title(torrent_listing.torrent_id, title) + .await?; } // update torrent description if let Some(description) = &payload.description { - app_data.database.update_torrent_description(torrent_id, description).await?; + app_data + .database + .update_torrent_description(torrent_listing.torrent_id, description) + .await?; } - let torrent_listing = app_data.database.get_torrent_listing_from_id(torrent_id).await?; + let torrent_listing = app_data + .database + .get_torrent_listing_from_id(torrent_listing.torrent_id) + .await?; let torrent_response = TorrentResponse::from_listing(torrent_listing); Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } -pub async fn delete_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResult { +pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator @@ -290,12 +313,12 @@ pub async fn delete_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceRe return Err(ServiceError::Unauthorized); } - let torrent_id = get_torrent_id_from_request(&req)?; + let infohash = get_torrent_infohash_from_request(&req)?; // needed later for removing torrent from tracker whitelist - let torrent_listing = app_data.database.get_torrent_listing_from_id(torrent_id).await?; + let torrent_listing = app_data.database.get_torrent_listing_from_infohash(&infohash).await?; - app_data.database.delete_torrent(torrent_id).await?; + app_data.database.delete_torrent(torrent_listing.torrent_id).await?; // remove info_hash from tracker whitelist let _ = app_data @@ -304,7 +327,9 @@ pub async fn delete_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceRe .await; Ok(HttpResponse::Ok().json(OkResponse { - data: NewTorrentResponse { torrent_id }, + data: NewTorrentResponse { + torrent_id: torrent_listing.torrent_id, + }, })) } @@ -332,17 +357,7 @@ pub async fn get_torrents(params: Query, app_data: WebAppData) -> Ok(HttpResponse::Ok().json(OkResponse { data: torrents_response })) } -fn get_torrent_id_from_request(req: &HttpRequest) -> Result { - match req.match_info().get("id") { - None => Err(ServiceError::BadRequest), - Some(torrent_id) => match torrent_id.parse() { - Err(_) => Err(ServiceError::BadRequest), - Ok(v) => Ok(v), - }, - } -} - -fn get_torrent_info_hash_from_request(req: &HttpRequest) -> Result { +fn get_torrent_infohash_from_request(req: &HttpRequest) -> Result { match req.match_info().get("info_hash") { None => Err(ServiceError::BadRequest), Some(info_hash) => match InfoHash::from_str(info_hash) { diff --git a/tests/common/client.rs b/tests/common/client.rs index c3501519..6ff016bc 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -5,7 +5,7 @@ use super::connection_info::ConnectionInfo; use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::settings::form::UpdateSettings; use super::contexts::torrent::forms::UpdateTorrentFrom; -use super::contexts::torrent::requests::{InfoHash, TorrentId}; +use super::contexts::torrent::requests::InfoHash; use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; use super::http::{Query, ReqwestQuery}; use super::responses::{self, BinaryResponse, TextResponse}; @@ -93,23 +93,25 @@ impl Client { self.http_client.get("torrents", Query::empty()).await } - pub async fn get_torrent(&self, id: TorrentId) -> TextResponse { - self.http_client.get(&format!("torrent/{id}"), Query::empty()).await + pub async fn get_torrent(&self, infohash: &InfoHash) -> TextResponse { + self.http_client.get(&format!("torrent/{infohash}"), Query::empty()).await } - pub async fn delete_torrent(&self, id: TorrentId) -> TextResponse { - self.http_client.delete(&format!("torrent/{id}")).await + pub async fn delete_torrent(&self, infohash: &InfoHash) -> TextResponse { + self.http_client.delete(&format!("torrent/{infohash}")).await } - pub async fn update_torrent(&self, id: TorrentId, update_torrent_form: UpdateTorrentFrom) -> TextResponse { - self.http_client.put(&format!("torrent/{id}"), &update_torrent_form).await + pub async fn update_torrent(&self, infohash: &InfoHash, update_torrent_form: UpdateTorrentFrom) -> TextResponse { + self.http_client + .put(&format!("torrent/{infohash}"), &update_torrent_form) + .await } pub async fn upload_torrent(&self, form: multipart::Form) -> TextResponse { self.http_client.post_multipart("torrent/upload", form).await } - pub async fn download_torrent(&self, info_hash: InfoHash) -> responses::BinaryResponse { + pub async fn download_torrent(&self, info_hash: &InfoHash) -> responses::BinaryResponse { self.http_client .get_binary(&format!("torrent/download/{info_hash}"), Query::empty()) .await diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index 00e26ecf..34146adf 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -7,6 +7,7 @@ use uuid::Uuid; use super::file::{create_torrent, parse_torrent, TorrentFileInfo}; use super::forms::{BinaryFile, UploadTorrentMultipartForm}; +use super::requests::InfoHash; use super::responses::Id; use crate::common::contexts::category::fixtures::software_predefined_category_name; @@ -91,7 +92,7 @@ impl TestTorrent { } } - pub fn info_hash(&self) -> String { + pub fn infohash(&self) -> InfoHash { self.file_info.info_hash.clone() } } diff --git a/tests/common/contexts/torrent/requests.rs b/tests/common/contexts/torrent/requests.rs index 946c475a..1d4ac583 100644 --- a/tests/common/contexts/torrent/requests.rs +++ b/tests/common/contexts/torrent/requests.rs @@ -1,2 +1 @@ -pub type TorrentId = i64; pub type InfoHash = String; diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index cca181ac..1a049890 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -20,6 +20,7 @@ mod for_guests { use crate::common::client::Client; use crate::common::contexts::category::fixtures::software_predefined_category_id; use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::common::contexts::torrent::requests::InfoHash; use crate::common::contexts::torrent::responses::{ Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, }; @@ -53,7 +54,7 @@ mod for_guests { } #[tokio::test] - async fn it_should_allow_guests_to_get_torrent_details_searching_by_id() { + async fn it_should_allow_guests_to_get_torrent_details_searching_by_infohash() { let mut env = TestEnv::new(); env.start().await; @@ -67,7 +68,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.get_torrent(uploaded_torrent.torrent_id).await; + let response = client.get_torrent(&test_torrent.infohash()).await; let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); @@ -116,7 +117,7 @@ mod for_guests { } #[tokio::test] - async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_id() { + async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_infohash() { let mut env = TestEnv::new(); env.start().await; @@ -130,7 +131,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.download_torrent(test_torrent.info_hash()).await; + let response = client.download_torrent(&test_torrent.infohash()).await; let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); let uploaded_torrent = @@ -152,9 +153,9 @@ mod for_guests { let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let non_existing_info_hash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); + let non_existing_info_hash: InfoHash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); - let response = client.download_torrent(non_existing_info_hash).await; + let response = client.download_torrent(&non_existing_info_hash).await; // code-review: should this be 404? assert_eq!(response.status, 400); @@ -173,9 +174,9 @@ mod for_guests { let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; - let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + let response = client.delete_torrent(&test_torrent.infohash()).await; assert_eq!(response.status, 401); } @@ -321,7 +322,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); // When the user downloads the torrent - let response = client.download_torrent(test_torrent.info_hash()).await; + let response = client.download_torrent(&test_torrent.infohash()).await; let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); @@ -355,11 +356,11 @@ mod for_authenticated_users { } let uploader = new_logged_in_user(&env).await; - let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + let response = client.delete_torrent(&test_torrent.infohash()).await; assert_eq!(response.status, 403); } @@ -376,7 +377,7 @@ mod for_authenticated_users { // Given a users uploads a torrent let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; // Then another non admin user should not be able to update the torrent let not_the_uploader = new_logged_in_user(&env).await; @@ -387,7 +388,7 @@ mod for_authenticated_users { let response = client .update_torrent( - uploaded_torrent.torrent_id, + &test_torrent.infohash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -418,7 +419,7 @@ mod for_authenticated_users { } let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); @@ -427,7 +428,7 @@ mod for_authenticated_users { let response = client .update_torrent( - uploaded_torrent.torrent_id, + &test_torrent.infohash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -454,7 +455,7 @@ mod for_authenticated_users { use crate::e2e::environment::TestEnv; #[tokio::test] - async fn it_should_allow_admins_to_delete_torrents_searching_by_id() { + async fn it_should_allow_admins_to_delete_torrents_searching_by_infohash() { let mut env = TestEnv::new(); env.start().await; @@ -464,12 +465,12 @@ mod for_authenticated_users { } let uploader = new_logged_in_user(&env).await; - let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); - let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + let response = client.delete_torrent(&test_torrent.infohash()).await; let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); @@ -488,7 +489,7 @@ mod for_authenticated_users { } let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -498,7 +499,7 @@ mod for_authenticated_users { let response = client .update_torrent( - uploaded_torrent.torrent_id, + &test_torrent.infohash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), From de56be0c5288e5252da87ccd2bc0c2de9d17afaf Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 8 May 2023 16:34:34 +0100 Subject: [PATCH 134/357] refactor: move docs and compose config for database tests Those files are only realted to `tests\database` tests. --- tests/README.md | 21 ----------- tests/databases/README.md | 47 ++++++++++++++++++++++++ tests/{ => databases}/docker-compose.yml | 0 3 files changed, 47 insertions(+), 21 deletions(-) delete mode 100644 tests/README.md create mode 100644 tests/databases/README.md rename tests/{ => databases}/docker-compose.yml (100%) diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 2cad69c7..00000000 --- a/tests/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Running Tests - -Torrust requires Docker to run different database systems for testing. [Install docker here](https://docs.docker.com/engine/). - -Start the databases with `docker-compose` before running tests: - -```s -docker-compose -f tests/docker-compose.yml up -``` - -Run all tests using: - -```s -cargo test -``` - -Connect to the DB using MySQL client: - -```s -mysql -h127.0.0.1 -uroot -ppassword torrust-index_test -``` diff --git a/tests/databases/README.md b/tests/databases/README.md new file mode 100644 index 00000000..54a1b842 --- /dev/null +++ b/tests/databases/README.md @@ -0,0 +1,47 @@ +# Persistence Tests + +Torrust requires Docker to run different database systems for testing. + +Start the databases with `docker-compose` before running tests: + +```s +docker-compose -f tests/databases/docker-compose.yml up +``` + +Run all tests using: + +```s +cargo test +``` + +Connect to the DB using MySQL client: + +```s +mysql -h127.0.0.1 -uroot -ppassword torrust-index_test +``` + +Right now only tests for MySQLite are executed. To run tests for MySQL too, +you have to replace this line in `tests/databases/mysql.rs`: + +```rust + +```rust +#[tokio::test] +#[should_panic] +async fn run_mysql_tests() { + panic!("Todo Test Times Out!"); + #[allow(unreachable_code)] + { + run_tests(DATABASE_URL).await; + } +} +``` + +with this: + +```rust +#[tokio::test] +async fn run_mysql_tests() { + run_tests(DATABASE_URL).await; +} +``` diff --git a/tests/docker-compose.yml b/tests/databases/docker-compose.yml similarity index 100% rename from tests/docker-compose.yml rename to tests/databases/docker-compose.yml From fc1567111ef15d4e918ed911f08ab49e8b999473 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 8 May 2023 16:58:43 +0100 Subject: [PATCH 135/357] feat: remove debug output --- src/routes/torrent.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 55395f2e..2719c115 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -174,18 +174,12 @@ pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Serv let infohash = get_torrent_infohash_from_request(&req)?; - println!("infohash: {}", infohash); - let torrent_listing = app_data.database.get_torrent_listing_from_infohash(&infohash).await?; let torrent_id = torrent_listing.torrent_id; - println!("torrent_listing: {:#?}", torrent_listing); - let category = app_data.database.get_category_from_id(torrent_listing.category_id).await?; - println!("category: {:#?}", category); - let mut torrent_response = TorrentResponse::from_listing(torrent_listing); torrent_response.category = category; @@ -196,13 +190,9 @@ pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Serv torrent_response.files = app_data.database.get_torrent_files_from_id(torrent_id).await?; - println!("torrent_response.files: {:#?}", torrent_response.files); - if torrent_response.files.len() == 1 { let torrent_info = app_data.database.get_torrent_info_from_infohash(&infohash).await?; - println!("torrent_info: {:#?}", torrent_info); - torrent_response .files .iter_mut() @@ -215,8 +205,6 @@ pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Serv .await .map(|v| v.into_iter().flatten().collect())?; - println!("trackers: {:#?}", torrent_response.trackers); - // add tracker url match user { Ok(user) => { From 41b600088d44d358cf48518dd7e6dbaf30e8148a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 8 May 2023 20:09:24 +0100 Subject: [PATCH 136/357] feat!: [#114] remove the torrents response min page size and add max page size Changes in GET torrents response pagination. Minimum page size for GET torrents results was removed. A default GET torrents page size was added (10). BREAKING CHANGE: a maximum page size was added (30). If you request more than 30 torrents per page, the result will contain 30 torrents at the most. --- src/routes/torrent.rs | 33 +++++--- tests/common/client.rs | 4 +- tests/e2e/contexts/torrent/contract.rs | 103 ++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 2719c115..07cec2a9 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -29,7 +29,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { .route(web::delete().to(delete_torrent_handler)), ), ); - cfg.service(web::scope("/torrents").service(web::resource("").route(web::get().to(get_torrents)))); + cfg.service(web::scope("/torrents").service(web::resource("").route(web::get().to(get_torrents_handler)))); } #[derive(FromRow)] @@ -321,30 +321,45 @@ pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> S })) } -// eg: /torrents?categories=music,other,movie&search=bunny&sort=size_DESC -pub async fn get_torrents(params: Query, app_data: WebAppData) -> ServiceResult { +/// It returns a list of torrents matching the search criteria. +/// Eg: `/torrents?categories=music,other,movie&search=bunny&sort=size_DESC` +/// +/// # Errors +/// +/// Returns a `ServiceError::DatabaseError` if the database query fails. +pub async fn get_torrents_handler(params: Query, app_data: WebAppData) -> ServiceResult { let sort = params.sort.unwrap_or(Sorting::UploadedDesc); let page = params.page.unwrap_or(0); - // make sure the min page size = 10 - let page_size = match params.page_size.unwrap_or(30) { - 0..=9 => 10, - v => v, + let page_size = params.page_size.unwrap_or(default_page_size()); + + let page_size = if page_size > max_torrent_page_size() { + max_torrent_page_size() + } else { + page_size }; - let offset = (page * page_size as u32) as u64; + let offset = u64::from(page * u32::from(page_size)); let categories = params.categories.as_csv::().unwrap_or(None); let torrents_response = app_data .database - .get_torrents_search_sorted_paginated(¶ms.search, &categories, &sort, offset, page_size as u8) + .get_torrents_search_sorted_paginated(¶ms.search, &categories, &sort, offset, page_size) .await?; Ok(HttpResponse::Ok().json(OkResponse { data: torrents_response })) } +fn max_torrent_page_size() -> u8 { + 30 +} + +fn default_page_size() -> u8 { + 10 +} + fn get_torrent_infohash_from_request(req: &HttpRequest) -> Result { match req.match_info().get("info_hash") { None => Err(ServiceError::BadRequest), diff --git a/tests/common/client.rs b/tests/common/client.rs index 6ff016bc..113e2f5a 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -89,8 +89,8 @@ impl Client { // Context: torrent - pub async fn get_torrents(&self) -> TextResponse { - self.http_client.get("torrents", Query::empty()).await + pub async fn get_torrents(&self, params: Query) -> TextResponse { + self.http_client.get("torrents", params).await } pub async fn get_torrent(&self, infohash: &InfoHash) -> TextResponse { diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 1a049890..6807ee3a 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -24,6 +24,7 @@ mod for_guests { use crate::common::contexts::torrent::responses::{ Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, }; + use crate::common::http::{Query, QueryParam}; use crate::e2e::contexts::torrent::asserts::expected_torrent; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; @@ -44,7 +45,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.get_torrents().await; + let response = client.get_torrents(Query::empty()).await; let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); @@ -53,6 +54,106 @@ mod for_guests { assert!(response.is_json_and_ok()); } + #[tokio::test] + async fn it_should_allow_to_get_torrents_with_pagination() { + let mut env = TestEnv::new(); + env.start().await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + // Given we insert two torrents + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request only one torrent per page + let response = client + .get_torrents(Query::with_params([QueryParam::new("page_size", "1")].to_vec())) + .await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should have only one torrent per page + assert_eq!(torrent_list_response.data.results.len(), 1); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_to_limit_the_number_of_torrents_per_request() { + let mut env = TestEnv::new(); + env.start().await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + let max_torrent_page_size = 30; + + // Given we insert one torrent more than the page size limit + for _ in 0..max_torrent_page_size { + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request more torrents than the page size limit + let response = client + .get_torrents(Query::with_params( + [QueryParam::new( + "page_size", + &format!("{}", (max_torrent_page_size + 1).to_string()), + )] + .to_vec(), + )) + .await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should get only the page size limit + assert_eq!(torrent_list_response.data.results.len(), max_torrent_page_size); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_return_a_default_amount_of_torrents_per_request_if_no_page_size_is_provided() { + let mut env = TestEnv::new(); + env.start().await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + let default_torrent_page_size = 10; + + // Given we insert one torrent more than the default page size + for _ in 0..default_torrent_page_size { + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request more torrents than the default page size limit + let response = client.get_torrents(Query::empty()).await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should get only the default number of torrents per page + assert_eq!(torrent_list_response.data.results.len(), default_torrent_page_size); + assert!(response.is_json_and_ok()); + } + #[tokio::test] async fn it_should_allow_guests_to_get_torrent_details_searching_by_infohash() { let mut env = TestEnv::new(); From 47648256ef50cc5659bf298a5659ff840f798419 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 8 May 2023 21:54:06 +0100 Subject: [PATCH 137/357] feat: [#114] add new config section api ```toml [api] default_torrent_page_size = 10 max_torrent_page_size = 30 ``` With pagination options for torrents. --- .dockerignore | 2 +- bin/install.sh | 2 +- config-idx-back.local.toml | 5 +++ config.toml.local => config.local.toml | 4 ++ src/config.rs | 51 +++++++++++++++----------- src/databases/mysql.rs | 4 +- src/databases/sqlite.rs | 4 +- src/routes/settings.rs | 6 +-- src/routes/torrent.rs | 18 ++++----- tests/common/contexts/settings/mod.rs | 36 +++++++++++++----- tests/environments/app_starter.rs | 8 ++-- tests/environments/isolated.rs | 8 ++-- 12 files changed, 90 insertions(+), 58 deletions(-) rename config.toml.local => config.local.toml (91%) diff --git a/.dockerignore b/.dockerignore index 74351152..b67eebd8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,8 +8,8 @@ /bin/ /config-idx-back.local.toml /config-tracker.local.toml +/config.local.toml /config.toml -/config.toml.local /cspell.json /data_v2.db* /data.db diff --git a/bin/install.sh b/bin/install.sh index d4d33a43..30b8a200 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -2,7 +2,7 @@ # Generate the default settings file if it does not exist if ! [ -f "./config.toml" ]; then - cp ./config.toml.local ./config.toml + cp ./config.local.toml ./config.toml fi # Generate storage directory if it does not exist diff --git a/config-idx-back.local.toml b/config-idx-back.local.toml index ba47c0fb..8cd29e90 100644 --- a/config-idx-back.local.toml +++ b/config-idx-back.local.toml @@ -37,3 +37,8 @@ capacity = 128000000 entry_size_limit = 4000000 user_quota_period_seconds = 3600 user_quota_bytes = 64000000 + +[api] +default_torrent_page_size = 10 +max_torrent_page_size = 30 + diff --git a/config.toml.local b/config.local.toml similarity index 91% rename from config.toml.local rename to config.local.toml index e13eb6d4..0e17b607 100644 --- a/config.toml.local +++ b/config.local.toml @@ -36,3 +36,7 @@ capacity = 128000000 entry_size_limit = 4000000 user_quota_period_seconds = 3600 user_quota_bytes = 64000000 + +[api] +default_torrent_page_size = 10 +max_torrent_page_size = 30 diff --git a/src/config.rs b/src/config.rs index ad00c711..1a382655 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,7 +80,13 @@ pub struct ImageCache { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TorrustConfig { +pub struct Api { + pub default_torrent_page_size: u8, + pub max_torrent_page_size: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfiguration { pub website: Website, pub tracker: Tracker, pub net: Network, @@ -88,10 +94,11 @@ pub struct TorrustConfig { pub database: Database, pub mail: Mail, pub image_cache: ImageCache, + pub api: Api, } -impl TorrustConfig { - pub fn default() -> Self { +impl Default for AppConfiguration { + fn default() -> Self { Self { website: Website { name: "Torrust".to_string(), @@ -121,9 +128,9 @@ impl TorrustConfig { email_verification_enabled: false, from: "example@email.com".to_string(), reply_to: "noreply@email.com".to_string(), - username: "".to_string(), - password: "".to_string(), - server: "".to_string(), + username: String::new(), + password: String::new(), + server: String::new(), port: 25, }, image_cache: ImageCache { @@ -133,22 +140,28 @@ impl TorrustConfig { user_quota_period_seconds: 3600, user_quota_bytes: 64_000_000, }, + api: Api { + default_torrent_page_size: 10, + max_torrent_page_size: 30, + }, } } } #[derive(Debug)] pub struct Configuration { - pub settings: RwLock, + pub settings: RwLock, } -impl Configuration { - pub fn default() -> Configuration { - Configuration { - settings: RwLock::new(TorrustConfig::default()), +impl Default for Configuration { + fn default() -> Self { + Self { + settings: RwLock::new(AppConfiguration::default()), } } +} +impl Configuration { /// Loads the configuration from the configuration file. pub async fn load_from_file(config_path: &str) -> Result { let config_builder = Config::builder(); @@ -168,7 +181,7 @@ impl Configuration { )); } - let torrust_config: TorrustConfig = match config.try_deserialize() { + let torrust_config: AppConfiguration = match config.try_deserialize() { Ok(data) => Ok(data), Err(e) => Err(ConfigError::Message(format!("Errors while processing config: {}.", e))), }?; @@ -191,16 +204,14 @@ impl Configuration { let config_builder = Config::builder() .add_source(File::from_str(&config_toml, FileFormat::Toml)) .build()?; - let torrust_config: TorrustConfig = config_builder.try_deserialize()?; + let torrust_config: AppConfiguration = config_builder.try_deserialize()?; Ok(Configuration { settings: RwLock::new(torrust_config), }) } - Err(_) => { - return Err(ConfigError::Message( - "Unable to load configuration from the configuration environment variable.".to_string(), - )) - } + Err(_) => Err(ConfigError::Message( + "Unable to load configuration from the configuration environment variable.".to_string(), + )), } } @@ -215,7 +226,7 @@ impl Configuration { Ok(()) } - pub async fn update_settings(&self, new_settings: TorrustConfig, config_path: &str) -> Result<(), ()> { + pub async fn update_settings(&self, new_settings: AppConfiguration, config_path: &str) -> Result<(), ()> { let mut settings = self.settings.write().await; *settings = new_settings; @@ -225,9 +236,7 @@ impl Configuration { Ok(()) } -} -impl Configuration { pub async fn get_public(&self) -> ConfigurationPublic { let settings_lock = self.settings.read().await; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 668e94bc..97f699a7 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -290,7 +290,7 @@ impl Database for MysqlDatabase { categories: &Option>, sort: &Sorting, offset: u64, - page_size: u8, + limit: u8, ) -> Result { let title = match search { None => "%".to_string(), @@ -365,7 +365,7 @@ impl Database for MysqlDatabase { let res: Vec = sqlx::query_as::<_, TorrentListing>(&query_string) .bind(title) .bind(offset as i64) - .bind(page_size) + .bind(limit) .fetch_all(&self.pool) .await .map_err(|_| DatabaseError::Error)?; diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 943d5e2d..3dc022e5 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -285,7 +285,7 @@ impl Database for SqliteDatabase { categories: &Option>, sort: &Sorting, offset: u64, - page_size: u8, + limit: u8, ) -> Result { let title = match search { None => "%".to_string(), @@ -360,7 +360,7 @@ impl Database for SqliteDatabase { let res: Vec = sqlx::query_as::<_, TorrentListing>(&query_string) .bind(title) .bind(offset as i64) - .bind(page_size) + .bind(limit) .fetch_all(&self.pool) .await .map_err(|_| DatabaseError::Error)?; diff --git a/src/routes/settings.rs b/src/routes/settings.rs index ba44317b..662530e0 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -2,7 +2,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use crate::bootstrap::config::ENV_VAR_DEFAULT_CONFIG_PATH; use crate::common::WebAppData; -use crate::config::TorrustConfig; +use crate::config::AppConfiguration; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::OkResponse; @@ -28,7 +28,7 @@ pub async fn get_settings(req: HttpRequest, app_data: WebAppData) -> ServiceResu return Err(ServiceError::Unauthorized); } - let settings: tokio::sync::RwLockReadGuard = app_data.cfg.settings.read().await; + let settings: tokio::sync::RwLockReadGuard = app_data.cfg.settings.read().await; Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) } @@ -49,7 +49,7 @@ pub async fn get_site_name(app_data: WebAppData) -> ServiceResult, + payload: web::Json, app_data: WebAppData, ) -> ServiceResult { // check for user diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 07cec2a9..8336edf5 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -328,14 +328,18 @@ pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> S /// /// Returns a `ServiceError::DatabaseError` if the database query fails. pub async fn get_torrents_handler(params: Query, app_data: WebAppData) -> ServiceResult { + let settings = app_data.cfg.settings.read().await; + let sort = params.sort.unwrap_or(Sorting::UploadedDesc); let page = params.page.unwrap_or(0); - let page_size = params.page_size.unwrap_or(default_page_size()); + let page_size = params.page_size.unwrap_or(settings.api.default_torrent_page_size); - let page_size = if page_size > max_torrent_page_size() { - max_torrent_page_size() + // Guard that page size does not exceed the maximum + let max_torrent_page_size = settings.api.max_torrent_page_size; + let page_size = if page_size > max_torrent_page_size { + max_torrent_page_size } else { page_size }; @@ -352,14 +356,6 @@ pub async fn get_torrents_handler(params: Query, app_data: WebApp Ok(HttpResponse::Ok().json(OkResponse { data: torrents_response })) } -fn max_torrent_page_size() -> u8 { - 30 -} - -fn default_page_size() -> u8 { - 10 -} - fn get_torrent_infohash_from_request(req: &HttpRequest) -> Result { match req.match_info().get("info_hash") { None => Err(ServiceError::BadRequest), diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index 2871602c..4e4f1643 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -3,8 +3,9 @@ pub mod responses; use serde::{Deserialize, Serialize}; use torrust_index_backend::config::{ - Auth as DomainAuth, Database as DomainDatabase, ImageCache as DomainImageCache, Mail as DomainMail, Network as DomainNetwork, - TorrustConfig as DomainSettings, Tracker as DomainTracker, Website as DomainWebsite, + Api as DomainApi, AppConfiguration as DomainSettings, Auth as DomainAuth, Database as DomainDatabase, + ImageCache as DomainImageCache, Mail as DomainMail, Network as DomainNetwork, Tracker as DomainTracker, + Website as DomainWebsite, }; #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] @@ -16,6 +17,7 @@ pub struct Settings { pub database: Database, pub mail: Mail, pub image_cache: ImageCache, + pub api: Api, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] @@ -72,6 +74,12 @@ pub struct ImageCache { pub user_quota_bytes: usize, } +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct Api { + pub default_torrent_page_size: u8, + pub max_torrent_page_size: u8, +} + impl From for Settings { fn from(settings: DomainSettings) -> Self { Settings { @@ -82,19 +90,20 @@ impl From for Settings { database: Database::from(settings.database), mail: Mail::from(settings.mail), image_cache: ImageCache::from(settings.image_cache), + api: Api::from(settings.api), } } } impl From for Website { fn from(website: DomainWebsite) -> Self { - Website { name: website.name } + Self { name: website.name } } } impl From for Tracker { fn from(tracker: DomainTracker) -> Self { - Tracker { + Self { url: tracker.url, mode: format!("{:?}", tracker.mode), api_url: tracker.api_url, @@ -106,7 +115,7 @@ impl From for Tracker { impl From for Network { fn from(net: DomainNetwork) -> Self { - Network { + Self { port: net.port, base_url: net.base_url, } @@ -115,7 +124,7 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { - Auth { + Self { email_on_signup: format!("{:?}", auth.email_on_signup), min_password_length: auth.min_password_length, max_password_length: auth.max_password_length, @@ -126,7 +135,7 @@ impl From for Auth { impl From for Database { fn from(database: DomainDatabase) -> Self { - Database { + Self { connect_url: database.connect_url, torrent_info_update_interval: database.torrent_info_update_interval, } @@ -135,7 +144,7 @@ impl From for Database { impl From for Mail { fn from(mail: DomainMail) -> Self { - Mail { + Self { email_verification_enabled: mail.email_verification_enabled, from: mail.from, reply_to: mail.reply_to, @@ -149,7 +158,7 @@ impl From for Mail { impl From for ImageCache { fn from(image_cache: DomainImageCache) -> Self { - ImageCache { + Self { max_request_timeout_ms: image_cache.max_request_timeout_ms, capacity: image_cache.capacity, entry_size_limit: image_cache.entry_size_limit, @@ -158,3 +167,12 @@ impl From for ImageCache { } } } + +impl From for Api { + fn from(api: DomainApi) -> Self { + Self { + default_torrent_page_size: api.default_torrent_page_size, + max_torrent_page_size: api.max_torrent_page_size, + } + } +} diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index 7e3b28d1..6bcc8c53 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -3,11 +3,11 @@ use std::net::SocketAddr; use log::info; use tokio::sync::{oneshot, RwLock}; use torrust_index_backend::app; -use torrust_index_backend::config::{Configuration, TorrustConfig}; +use torrust_index_backend::config::{AppConfiguration, Configuration}; /// It launches the app and provides a way to stop it. pub struct AppStarter { - configuration: TorrustConfig, + configuration: AppConfiguration, /// The application binary state (started or not): /// - `None`: if the app is not started, /// - `RunningState`: if the app was started. @@ -16,7 +16,7 @@ pub struct AppStarter { impl AppStarter { #[must_use] - pub fn with_custom_configuration(configuration: TorrustConfig) -> Self { + pub fn with_custom_configuration(configuration: AppConfiguration) -> Self { Self { configuration, running_state: None, @@ -72,7 +72,7 @@ impl AppStarter { } #[must_use] - pub fn server_configuration(&self) -> TorrustConfig { + pub fn server_configuration(&self) -> AppConfiguration { self.configuration.clone() } diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index 470488b2..d2dd69d3 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -1,5 +1,5 @@ use tempfile::TempDir; -use torrust_index_backend::config::{TorrustConfig, FREE_PORT}; +use torrust_index_backend::config::{AppConfiguration, FREE_PORT}; use super::app_starter::AppStarter; use crate::common::random; @@ -40,7 +40,7 @@ impl TestEnv { /// Provides the whole server configuration. #[must_use] - pub fn server_configuration(&self) -> TorrustConfig { + pub fn server_configuration(&self) -> AppConfiguration { self.app_starter.server_configuration() } @@ -63,8 +63,8 @@ impl Default for TestEnv { } /// Provides a configuration with ephemeral data for testing. -fn ephemeral(temp_dir: &TempDir) -> TorrustConfig { - let mut configuration = TorrustConfig::default(); +fn ephemeral(temp_dir: &TempDir) -> AppConfiguration { + let mut configuration = AppConfiguration::default(); // Ephemeral API port configuration.net.port = FREE_PORT; From 655f631184fcf85a4ea40b0731c6787c1e3d4cb2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 8 May 2023 17:44:06 +0100 Subject: [PATCH 138/357] feat: [#130] add env var to change the default config path You can overwrite the default config file path with: ``` TORRUST_IDX_BACK_CONFIG_PATH=./storage/config/config.toml cargo run ``` The default path is `./config.toml` --- src/bootstrap/config.rs | 10 ++++++-- src/config.rs | 32 ++++++++++++++++++------- src/routes/settings.rs | 19 +++++++++------ tests/e2e/contexts/settings/contract.rs | 3 +-- tests/environments/app_starter.rs | 5 +++- tests/environments/isolated.rs | 6 ++++- 6 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 22a5590f..2edbd0fb 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -6,8 +6,12 @@ use std::env; // Environment variables /// The whole `config.toml` file content. It has priority over the config file. +/// Even if the file is not on the default path. pub const ENV_VAR_CONFIG: &str = "TORRUST_IDX_BACK_CONFIG"; +/// The `config.toml` file location. +pub const ENV_VAR_CONFIG_PATH: &str = "TORRUST_IDX_BACK_CONFIG_PATH"; + // Default values pub const ENV_VAR_DEFAULT_CONFIG_PATH: &str = "./config.toml"; @@ -25,9 +29,11 @@ pub async fn init_configuration() -> Configuration { Configuration::load_from_env_var(ENV_VAR_CONFIG).unwrap() } else { - println!("Loading configuration from config file `{}`", ENV_VAR_DEFAULT_CONFIG_PATH); + let config_path = env::var(ENV_VAR_CONFIG_PATH).unwrap_or_else(|_| ENV_VAR_DEFAULT_CONFIG_PATH.to_string()); + + println!("Loading configuration from config file `{}`", config_path); - match Configuration::load_from_file(ENV_VAR_DEFAULT_CONFIG_PATH).await { + match Configuration::load_from_file(&config_path).await { Ok(config) => config, Err(error) => { panic!("{}", error) diff --git a/src/config.rs b/src/config.rs index 1a382655..24fa6201 100644 --- a/src/config.rs +++ b/src/config.rs @@ -151,12 +151,14 @@ impl Default for AppConfiguration { #[derive(Debug)] pub struct Configuration { pub settings: RwLock, + pub config_path: Option, } impl Default for Configuration { fn default() -> Self { Self { settings: RwLock::new(AppConfiguration::default()), + config_path: None, } } } @@ -188,6 +190,7 @@ impl Configuration { Ok(Configuration { settings: RwLock::new(torrust_config), + config_path: Some(config_path.to_string()), }) } @@ -207,6 +210,7 @@ impl Configuration { let torrust_config: AppConfiguration = config_builder.try_deserialize()?; Ok(Configuration { settings: RwLock::new(torrust_config), + config_path: None, }) } Err(_) => Err(ConfigError::Message( @@ -215,7 +219,7 @@ impl Configuration { } } - pub async fn save_to_file(&self, config_path: &str) -> Result<(), ()> { + pub async fn save_to_file(&self, config_path: &str) { let settings = self.settings.read().await; let toml_string = toml::to_string(&*settings).expect("Could not encode TOML value"); @@ -223,18 +227,28 @@ impl Configuration { drop(settings); fs::write(config_path, toml_string).expect("Could not write to file!"); - Ok(()) } - pub async fn update_settings(&self, new_settings: AppConfiguration, config_path: &str) -> Result<(), ()> { - let mut settings = self.settings.write().await; - *settings = new_settings; - - drop(settings); + /// Updates the settings and saves them to the configuration file. + /// + /// # Panics + /// + /// Will panic if the configuration file path is not defined. That happens + /// when the configuration was loaded from the environment variable. + pub async fn update_settings(&self, new_settings: AppConfiguration) { + match &self.config_path { + Some(config_path) => { + let mut settings = self.settings.write().await; + *settings = new_settings; - let _ = self.save_to_file(config_path).await; + drop(settings); - Ok(()) + let _ = self.save_to_file(config_path).await; + } + None => panic!( + "Cannot update settings when the config file path is not defined. For example: when it's loaded from env var." + ), + } } pub async fn get_public(&self) -> ConfigurationPublic { diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 662530e0..e2b6849f 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -1,6 +1,5 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use crate::bootstrap::config::ENV_VAR_DEFAULT_CONFIG_PATH; use crate::common::WebAppData; use crate::config::AppConfiguration; use crate::errors::{ServiceError, ServiceResult}; @@ -12,7 +11,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { .service( web::resource("") .route(web::get().to(get_settings)) - .route(web::post().to(update_settings)), + .route(web::post().to(update_settings_handler)), ) .service(web::resource("/name").route(web::get().to(get_site_name))) .service(web::resource("/public").route(web::get().to(get_public_settings))), @@ -47,7 +46,16 @@ pub async fn get_site_name(app_data: WebAppData) -> ServiceResult, app_data: WebAppData, @@ -60,10 +68,7 @@ pub async fn update_settings( return Err(ServiceError::Unauthorized); } - let _ = app_data - .cfg - .update_settings(payload.into_inner(), ENV_VAR_DEFAULT_CONFIG_PATH) - .await; + let _ = app_data.cfg.update_settings(payload.into_inner()).await; let settings = app_data.cfg.settings.read().await; diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 38645b2a..3d87a0c7 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -67,6 +67,7 @@ async fn it_should_allow_admins_to_get_all_the_settings() { #[tokio::test] async fn it_should_allow_admins_to_update_all_the_settings() { let mut env = TestEnv::new(); + env.start().await; if !env.is_isolated() { // This test can't be executed in a non-isolated environment because @@ -74,8 +75,6 @@ async fn it_should_allow_admins_to_update_all_the_settings() { return; } - env.start().await; - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index 6bcc8c53..55cbb355 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -8,6 +8,7 @@ use torrust_index_backend::config::{AppConfiguration, Configuration}; /// It launches the app and provides a way to stop it. pub struct AppStarter { configuration: AppConfiguration, + config_path: Option, /// The application binary state (started or not): /// - `None`: if the app is not started, /// - `RunningState`: if the app was started. @@ -16,9 +17,10 @@ pub struct AppStarter { impl AppStarter { #[must_use] - pub fn with_custom_configuration(configuration: AppConfiguration) -> Self { + pub fn with_custom_configuration(configuration: AppConfiguration, config_path: Option) -> Self { Self { configuration, + config_path, running_state: None, } } @@ -29,6 +31,7 @@ impl AppStarter { pub async fn start(&mut self) { let configuration = Configuration { settings: RwLock::new(self.configuration.clone()), + config_path: self.config_path.clone(), }; // Open a channel to communicate back with this function diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index d2dd69d3..943497ee 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -27,8 +27,12 @@ impl TestEnv { let temp_dir = TempDir::new().expect("failed to create a temporary directory"); let configuration = ephemeral(&temp_dir); + // Even if we load the configuration from the environment variable, we + // still need to provide a path to save the configuration when the + // configuration is updated via the `POST /settings` endpoints. + let config_path = format!("{}/config.toml", temp_dir.path().to_string_lossy()); - let app_starter = AppStarter::with_custom_configuration(configuration); + let app_starter = AppStarter::with_custom_configuration(configuration, Some(config_path)); Self { app_starter, temp_dir } } From 6cc1380e425e4c18a6ad42e97d8b37a2cdcb4703 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 9 May 2023 12:03:19 +0100 Subject: [PATCH 139/357] refactor: extract tracker api client from tracker service --- src/app.rs | 2 +- .../commands/import_tracker_statistics.rs | 2 +- src/tracker.rs | 267 +++++++++++------- 3 files changed, 169 insertions(+), 102 deletions(-) diff --git a/src/app.rs b/src/app.rs index 18cc427f..67d10984 100644 --- a/src/app.rs +++ b/src/app.rs @@ -44,7 +44,7 @@ pub async fn run(configuration: Configuration) -> Running { let database = Arc::new(connect_database(&database_connect_url).await.expect("Database error.")); let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); - let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); + let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone()).await); let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 66f9f49c..49334f32 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -76,7 +76,7 @@ pub async fn import(_args: &Arguments) { .expect("Database error."), ); - let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone())); + let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone()).await); tracker_service.update_torrents().await.unwrap(); } diff --git a/src/tracker.rs b/src/tracker.rs index f91c33e2..23c2eb8c 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use log::{error, info}; +use reqwest::{Error, Response}; use serde::{Deserialize, Serialize}; use crate::config::Configuration; @@ -35,148 +36,147 @@ pub struct PeerId { } pub struct TrackerService { - cfg: Arc, database: Arc>, + api_client: ApiClient, + token_valid_seconds: u64, + tracker_url: String, } impl TrackerService { - pub fn new(cfg: Arc, database: Arc>) -> TrackerService { - TrackerService { cfg, database } + pub async fn new(cfg: Arc, database: Arc>) -> TrackerService { + let settings = cfg.settings.read().await; + let api_client = ApiClient::new(ApiConnectionInfo::new( + settings.tracker.api_url.clone(), + settings.tracker.token.clone(), + )); + let token_valid_seconds = settings.tracker.token_valid_seconds; + let tracker_url = settings.tracker.url.clone(); + drop(settings); + TrackerService { + database, + api_client, + token_valid_seconds, + tracker_url, + } } + /// Add a torrent to the tracker whitelist. + /// + /// # Errors + /// + /// Will return an error if the HTTP request failed (for example if the + /// tracker API is offline) or if the tracker API returned an error. pub async fn whitelist_info_hash(&self, info_hash: String) -> Result<(), ServiceError> { - let settings = self.cfg.settings.read().await; - - let request_url = format!( - "{}/api/v1/whitelist/{}?token={}", - settings.tracker.api_url, info_hash, settings.tracker.token - ); - - drop(settings); - - let client = reqwest::Client::new(); - - let response = client - .post(request_url) - .send() - .await - .map_err(|_| ServiceError::TrackerOffline)?; - - if response.status().is_success() { - Ok(()) - } else { - Err(ServiceError::WhitelistingError) + let response = self.api_client.whitelist_info_hash(&info_hash).await; + + match response { + Ok(response) => { + if response.status().is_success() { + Ok(()) + } else { + Err(ServiceError::WhitelistingError) + } + } + Err(_) => Err(ServiceError::TrackerOffline), } } + /// Remove a torrent from the tracker whitelist. + /// + /// # Errors + /// + /// Will return an error if the HTTP request failed (for example if the + /// tracker API is offline) or if the tracker API returned an error. pub async fn remove_info_hash_from_whitelist(&self, info_hash: String) -> Result<(), ServiceError> { - let settings = self.cfg.settings.read().await; - - let request_url = format!( - "{}/api/v1/whitelist/{}?token={}", - settings.tracker.api_url, info_hash, settings.tracker.token - ); - - drop(settings); - - let client = reqwest::Client::new(); - - let response = match client.delete(request_url).send().await { - Ok(v) => Ok(v), + let response = self.api_client.remove_info_hash_from_whitelist(&info_hash).await; + + match response { + Ok(response) => { + if response.status().is_success() { + Ok(()) + } else { + Err(ServiceError::InternalServerError) + } + } Err(_) => Err(ServiceError::InternalServerError), - }?; - - if response.status().is_success() { - return Ok(()); } - - Err(ServiceError::InternalServerError) } - // get personal tracker announce url of a user - // Eg: https://tracker.torrust.com/announce/USER_TRACKER_KEY + /// Get personal tracker announce url of a user. + /// + /// Eg: + /// + /// If the user doesn't have a not expired tracker key, it will generate a + /// new one and save it in the database. + /// + /// # Errors + /// + /// Will return an error if the HTTP request to get generated a new + /// user tracker key failed. pub async fn get_personal_announce_url(&self, user_id: i64) -> Result { - let settings = self.cfg.settings.read().await; - - // get a valid tracker key for this user from database let tracker_key = self.database.get_user_tracker_key(user_id).await; match tracker_key { - Some(v) => Ok(format!("{}/{}", settings.tracker.url, v.key)), + Some(v) => Ok(self.announce_url_with_key(&v)), None => match self.retrieve_new_tracker_key(user_id).await { - Ok(v) => Ok(format!("{}/{}", settings.tracker.url, v.key)), + Ok(v) => Ok(self.announce_url_with_key(&v)), Err(_) => Err(ServiceError::TrackerOffline), }, } } - // issue a new tracker key from tracker and save it in database, tied to a user - async fn retrieve_new_tracker_key(&self, user_id: i64) -> Result { - let settings = self.cfg.settings.read().await; - - let request_url = format!( - "{}/api/v1/key/{}?token={}", - settings.tracker.api_url, settings.tracker.token_valid_seconds, settings.tracker.token - ); - - drop(settings); - - let client = reqwest::Client::new(); + /// It builds the announce url appending the user tracker key. + /// Eg: + fn announce_url_with_key(&self, tracker_key: &TrackerKey) -> String { + format!("{}/{}", self.tracker_url, tracker_key.key) + } - // issue new tracker key - let response = client - .post(request_url) - .send() + /// Issue a new tracker key from tracker and save it in database, + /// tied to a user + async fn retrieve_new_tracker_key(&self, user_id: i64) -> Result { + // Request new tracker key from tracker + let response = self + .api_client + .retrieve_new_tracker_key(self.token_valid_seconds) .await .map_err(|_| ServiceError::InternalServerError)?; - // get tracker key from response + // Parse tracker key from response let tracker_key = response .json::() .await .map_err(|_| ServiceError::InternalServerError)?; - // add tracker key to database (tied to a user) + // Add tracker key to database (tied to a user) self.database.add_tracker_key(user_id, &tracker_key).await?; // return tracker key Ok(tracker_key) } - // get torrent info from tracker api + /// Get torrent info from tracker API + /// + /// # Errors + /// + /// Will return an error if the HTTP request failed or the torrent is not + /// found. pub async fn get_torrent_info(&self, torrent_id: i64, info_hash: &str) -> Result { - let settings = self.cfg.settings.read().await; - - let tracker_url = settings.tracker.url.clone(); - - let request_url = format!( - "{}/api/v1/torrent/{}?token={}", - settings.tracker.api_url, info_hash, settings.tracker.token - ); - - drop(settings); - - let client = reqwest::Client::new(); - let response = match client.get(request_url).send().await { - Ok(v) => Ok(v), - Err(_) => Err(ServiceError::InternalServerError), - }?; - - let torrent_info = match response.json::().await { - Ok(torrent_info) => { - let _ = self - .database - .update_tracker_info(torrent_id, &tracker_url, torrent_info.seeders, torrent_info.leechers) - .await; - Ok(torrent_info) - } - Err(_) => { - let _ = self.database.update_tracker_info(torrent_id, &tracker_url, 0, 0).await; - Err(ServiceError::TorrentNotFound) - } - }?; + let response = self + .api_client + .get_torrent_info(info_hash) + .await + .map_err(|_| ServiceError::InternalServerError)?; - Ok(torrent_info) + if let Ok(torrent_info) = response.json::().await { + let _ = self + .database + .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) + .await; + Ok(torrent_info) + } else { + let _ = self.database.update_tracker_info(torrent_id, &self.tracker_url, 0, 0).await; + Err(ServiceError::TorrentNotFound) + } } pub async fn update_torrents(&self) -> Result<(), ServiceError> { @@ -203,3 +203,70 @@ impl TrackerService { self.get_torrent_info(torrent_id, info_hash).await } } + +struct ApiConnectionInfo { + pub url: String, + pub token: String, +} + +impl ApiConnectionInfo { + pub fn new(url: String, token: String) -> Self { + Self { url, token } + } +} + +struct ApiClient { + pub connection_info: ApiConnectionInfo, + base_url: String, +} + +impl ApiClient { + pub fn new(connection_info: ApiConnectionInfo) -> Self { + let base_url = format!("{}/api/v1", connection_info.url); + Self { + connection_info, + base_url, + } + } + + pub async fn whitelist_info_hash(&self, info_hash: &str) -> Result { + let request_url = format!( + "{}/whitelist/{}?token={}", + self.base_url, info_hash, self.connection_info.token + ); + + let client = reqwest::Client::new(); + + client.post(request_url).send().await + } + + pub async fn remove_info_hash_from_whitelist(&self, info_hash: &str) -> Result { + let request_url = format!( + "{}/whitelist/{}?token={}", + self.base_url, info_hash, self.connection_info.token + ); + + let client = reqwest::Client::new(); + + client.delete(request_url).send().await + } + + async fn retrieve_new_tracker_key(&self, token_valid_seconds: u64) -> Result { + let request_url = format!( + "{}/key/{}?token={}", + self.base_url, token_valid_seconds, self.connection_info.token + ); + + let client = reqwest::Client::new(); + + client.post(request_url).send().await + } + + pub async fn get_torrent_info(&self, info_hash: &str) -> Result { + let request_url = format!("{}/torrent/{}?token={}", self.base_url, info_hash, self.connection_info.token); + + let client = reqwest::Client::new(); + + client.get(request_url).send().await + } +} From fddf020444d010a5665912a28a994af0d10e8c75 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 9 May 2023 13:21:34 +0100 Subject: [PATCH 140/357] fix: failing test The test for listing torrents was failing becuase the uploaded torrents was not in the result. Since we added a pagination limit in this PR: https://github.com/torrust/torrust-index-backend/pull/142 Now it can happen that the torrent you've just uploaded is not in the response, because tests run in paralell. When we will able to run this test mocking the `TrackerService` we couuld run it with an isolated environment, so the expected result if always only one torrent. Then we could check that the torrent is on the list again. --- tests/common/contexts/torrent/responses.rs | 2 +- tests/e2e/contexts/torrent/contract.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/common/contexts/torrent/responses.rs b/tests/common/contexts/torrent/responses.rs index f2b6739c..6f3c5105 100644 --- a/tests/common/contexts/torrent/responses.rs +++ b/tests/common/contexts/torrent/responses.rs @@ -21,7 +21,7 @@ pub struct TorrentList { } impl TorrentList { - pub fn contains(&self, torrent_id: Id) -> bool { + pub fn _contains(&self, torrent_id: Id) -> bool { self.results.iter().any(|item| item.torrent_id == torrent_id) } } diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 6807ee3a..4ee2bfe3 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -43,14 +43,13 @@ mod for_guests { let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; - let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; let response = client.get_torrents(Query::empty()).await; let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); assert!(torrent_list_response.data.total > 0); - assert!(torrent_list_response.data.contains(indexed_torrent.torrent_id)); assert!(response.is_json_and_ok()); } From 00926e13303705ae8c257c21646585f2627f5537 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 9 May 2023 13:33:36 +0100 Subject: [PATCH 141/357] refactor: split tracker mod into submods --- src/app.rs | 2 +- src/common.rs | 2 +- .../commands/import_tracker_statistics.rs | 2 +- src/tracker/api.rs | 67 ++++++++++++++++++ src/tracker/mod.rs | 2 + src/{tracker.rs => tracker/service.rs} | 69 +------------------ 6 files changed, 73 insertions(+), 71 deletions(-) create mode 100644 src/tracker/api.rs create mode 100644 src/tracker/mod.rs rename src/{tracker.rs => tracker/service.rs} (78%) diff --git a/src/app.rs b/src/app.rs index 67d10984..a3732982 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use crate::config::Configuration; use crate::databases::database::connect_database; use crate::mailer::MailerService; use crate::routes; -use crate::tracker::TrackerService; +use crate::tracker::service::TrackerService; pub struct Running { pub api_server: Server, diff --git a/src/common.rs b/src/common.rs index 3432383b..e91eef0f 100644 --- a/src/common.rs +++ b/src/common.rs @@ -5,7 +5,7 @@ use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; use crate::mailer::MailerService; -use crate::tracker::TrackerService; +use crate::tracker::service::TrackerService; pub type Username = String; diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 49334f32..6f489784 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -9,7 +9,7 @@ use text_colorizer::*; use crate::bootstrap::config::init_configuration; use crate::bootstrap::logging; use crate::databases::database::connect_database; -use crate::tracker::TrackerService; +use crate::tracker::service::TrackerService; const NUMBER_OF_ARGUMENTS: usize = 0; diff --git a/src/tracker/api.rs b/src/tracker/api.rs new file mode 100644 index 00000000..d0ae712b --- /dev/null +++ b/src/tracker/api.rs @@ -0,0 +1,67 @@ +use reqwest::{Error, Response}; +pub struct ApiConnectionInfo { + pub url: String, + pub token: String, +} + +impl ApiConnectionInfo { + pub fn new(url: String, token: String) -> Self { + Self { url, token } + } +} + +pub struct ApiClient { + pub connection_info: ApiConnectionInfo, + base_url: String, +} + +impl ApiClient { + pub fn new(connection_info: ApiConnectionInfo) -> Self { + let base_url = format!("{}/api/v1", connection_info.url); + Self { + connection_info, + base_url, + } + } + + pub async fn whitelist_info_hash(&self, info_hash: &str) -> Result { + let request_url = format!( + "{}/whitelist/{}?token={}", + self.base_url, info_hash, self.connection_info.token + ); + + let client = reqwest::Client::new(); + + client.post(request_url).send().await + } + + pub async fn remove_info_hash_from_whitelist(&self, info_hash: &str) -> Result { + let request_url = format!( + "{}/whitelist/{}?token={}", + self.base_url, info_hash, self.connection_info.token + ); + + let client = reqwest::Client::new(); + + client.delete(request_url).send().await + } + + pub async fn retrieve_new_tracker_key(&self, token_valid_seconds: u64) -> Result { + let request_url = format!( + "{}/key/{}?token={}", + self.base_url, token_valid_seconds, self.connection_info.token + ); + + let client = reqwest::Client::new(); + + client.post(request_url).send().await + } + + pub async fn get_torrent_info(&self, info_hash: &str) -> Result { + let request_url = format!("{}/torrent/{}?token={}", self.base_url, info_hash, self.connection_info.token); + + let client = reqwest::Client::new(); + + client.get(request_url).send().await + } +} diff --git a/src/tracker/mod.rs b/src/tracker/mod.rs new file mode 100644 index 00000000..6ee79ddb --- /dev/null +++ b/src/tracker/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod service; diff --git a/src/tracker.rs b/src/tracker/service.rs similarity index 78% rename from src/tracker.rs rename to src/tracker/service.rs index 23c2eb8c..116c8840 100644 --- a/src/tracker.rs +++ b/src/tracker/service.rs @@ -1,9 +1,9 @@ use std::sync::Arc; use log::{error, info}; -use reqwest::{Error, Response}; use serde::{Deserialize, Serialize}; +use super::api::{ApiClient, ApiConnectionInfo}; use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; @@ -203,70 +203,3 @@ impl TrackerService { self.get_torrent_info(torrent_id, info_hash).await } } - -struct ApiConnectionInfo { - pub url: String, - pub token: String, -} - -impl ApiConnectionInfo { - pub fn new(url: String, token: String) -> Self { - Self { url, token } - } -} - -struct ApiClient { - pub connection_info: ApiConnectionInfo, - base_url: String, -} - -impl ApiClient { - pub fn new(connection_info: ApiConnectionInfo) -> Self { - let base_url = format!("{}/api/v1", connection_info.url); - Self { - connection_info, - base_url, - } - } - - pub async fn whitelist_info_hash(&self, info_hash: &str) -> Result { - let request_url = format!( - "{}/whitelist/{}?token={}", - self.base_url, info_hash, self.connection_info.token - ); - - let client = reqwest::Client::new(); - - client.post(request_url).send().await - } - - pub async fn remove_info_hash_from_whitelist(&self, info_hash: &str) -> Result { - let request_url = format!( - "{}/whitelist/{}?token={}", - self.base_url, info_hash, self.connection_info.token - ); - - let client = reqwest::Client::new(); - - client.delete(request_url).send().await - } - - async fn retrieve_new_tracker_key(&self, token_valid_seconds: u64) -> Result { - let request_url = format!( - "{}/key/{}?token={}", - self.base_url, token_valid_seconds, self.connection_info.token - ); - - let client = reqwest::Client::new(); - - client.post(request_url).send().await - } - - pub async fn get_torrent_info(&self, info_hash: &str) -> Result { - let request_url = format!("{}/torrent/{}?token={}", self.base_url, info_hash, self.connection_info.token); - - let client = reqwest::Client::new(); - - client.get(request_url).send().await - } -} From 01983614ab59d4ba60fa05ba0295bd9bf763ecec Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 9 May 2023 13:45:38 +0100 Subject: [PATCH 142/357] refactor: rename structs and functions, and add docs --- src/app.rs | 4 +- src/common.rs | 6 +-- .../commands/import_tracker_statistics.rs | 4 +- src/tracker/api.rs | 40 +++++++++++++++---- src/tracker/service.rs | 18 ++++----- 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/app.rs b/src/app.rs index a3732982..93201b95 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use crate::config::Configuration; use crate::databases::database::connect_database; use crate::mailer::MailerService; use crate::routes; -use crate::tracker::service::TrackerService; +use crate::tracker::service::Service; pub struct Running { pub api_server: Server, @@ -44,7 +44,7 @@ pub async fn run(configuration: Configuration) -> Running { let database = Arc::new(connect_database(&database_connect_url).await.expect("Database error.")); let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); - let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone()).await); + let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); diff --git a/src/common.rs b/src/common.rs index e91eef0f..1eb14868 100644 --- a/src/common.rs +++ b/src/common.rs @@ -5,7 +5,7 @@ use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; use crate::mailer::MailerService; -use crate::tracker::service::TrackerService; +use crate::tracker::service::Service; pub type Username = String; @@ -15,7 +15,7 @@ pub struct AppData { pub cfg: Arc, pub database: Arc>, pub auth: Arc, - pub tracker: Arc, + pub tracker: Arc, pub mailer: Arc, pub image_cache_manager: Arc, } @@ -25,7 +25,7 @@ impl AppData { cfg: Arc, database: Arc>, auth: Arc, - tracker: Arc, + tracker: Arc, mailer: Arc, image_cache_manager: Arc, ) -> AppData { diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 6f489784..fc86772b 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -9,7 +9,7 @@ use text_colorizer::*; use crate::bootstrap::config::init_configuration; use crate::bootstrap::logging; use crate::databases::database::connect_database; -use crate::tracker::service::TrackerService; +use crate::tracker::service::Service; const NUMBER_OF_ARGUMENTS: usize = 0; @@ -76,7 +76,7 @@ pub async fn import(_args: &Arguments) { .expect("Database error."), ); - let tracker_service = Arc::new(TrackerService::new(cfg.clone(), database.clone()).await); + let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); tracker_service.update_torrents().await.unwrap(); } diff --git a/src/tracker/api.rs b/src/tracker/api.rs index d0ae712b..d3fa3fcb 100644 --- a/src/tracker/api.rs +++ b/src/tracker/api.rs @@ -1,22 +1,26 @@ use reqwest::{Error, Response}; -pub struct ApiConnectionInfo { +pub struct ConnectionInfo { + /// The URL of the tracker. Eg: or pub url: String, + /// The token used to authenticate with the tracker API. pub token: String, } -impl ApiConnectionInfo { +impl ConnectionInfo { + #[must_use] pub fn new(url: String, token: String) -> Self { Self { url, token } } } -pub struct ApiClient { - pub connection_info: ApiConnectionInfo, +pub struct Client { + pub connection_info: ConnectionInfo, base_url: String, } -impl ApiClient { - pub fn new(connection_info: ApiConnectionInfo) -> Self { +impl Client { + #[must_use] + pub fn new(connection_info: ConnectionInfo) -> Self { let base_url = format!("{}/api/v1", connection_info.url); Self { connection_info, @@ -24,7 +28,12 @@ impl ApiClient { } } - pub async fn whitelist_info_hash(&self, info_hash: &str) -> Result { + /// Add a torrent to the tracker whitelist. + /// + /// # Errors + /// + /// Will return an error if the HTTP request fails. + pub async fn whitelist_torrent(&self, info_hash: &str) -> Result { let request_url = format!( "{}/whitelist/{}?token={}", self.base_url, info_hash, self.connection_info.token @@ -35,7 +44,12 @@ impl ApiClient { client.post(request_url).send().await } - pub async fn remove_info_hash_from_whitelist(&self, info_hash: &str) -> Result { + /// Remove a torrent from the tracker whitelist. + /// + /// # Errors + /// + /// Will return an error if the HTTP request fails. + pub async fn remove_torrent_from_whitelist(&self, info_hash: &str) -> Result { let request_url = format!( "{}/whitelist/{}?token={}", self.base_url, info_hash, self.connection_info.token @@ -46,6 +60,11 @@ impl ApiClient { client.delete(request_url).send().await } + /// Retrieve a new tracker key. + /// + /// # Errors + /// + /// Will return an error if the HTTP request fails. pub async fn retrieve_new_tracker_key(&self, token_valid_seconds: u64) -> Result { let request_url = format!( "{}/key/{}?token={}", @@ -57,6 +76,11 @@ impl ApiClient { client.post(request_url).send().await } + /// Retrieve the info for a torrent. + /// + /// # Errors + /// + /// Will return an error if the HTTP request fails. pub async fn get_torrent_info(&self, info_hash: &str) -> Result { let request_url = format!("{}/torrent/{}?token={}", self.base_url, info_hash, self.connection_info.token); diff --git a/src/tracker/service.rs b/src/tracker/service.rs index 116c8840..62122888 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use log::{error, info}; use serde::{Deserialize, Serialize}; -use super::api::{ApiClient, ApiConnectionInfo}; +use super::api::{Client, ConnectionInfo}; use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; @@ -35,24 +35,24 @@ pub struct PeerId { pub client: Option, } -pub struct TrackerService { +pub struct Service { database: Arc>, - api_client: ApiClient, + api_client: Client, token_valid_seconds: u64, tracker_url: String, } -impl TrackerService { - pub async fn new(cfg: Arc, database: Arc>) -> TrackerService { +impl Service { + pub async fn new(cfg: Arc, database: Arc>) -> Service { let settings = cfg.settings.read().await; - let api_client = ApiClient::new(ApiConnectionInfo::new( + let api_client = Client::new(ConnectionInfo::new( settings.tracker.api_url.clone(), settings.tracker.token.clone(), )); let token_valid_seconds = settings.tracker.token_valid_seconds; let tracker_url = settings.tracker.url.clone(); drop(settings); - TrackerService { + Service { database, api_client, token_valid_seconds, @@ -67,7 +67,7 @@ impl TrackerService { /// Will return an error if the HTTP request failed (for example if the /// tracker API is offline) or if the tracker API returned an error. pub async fn whitelist_info_hash(&self, info_hash: String) -> Result<(), ServiceError> { - let response = self.api_client.whitelist_info_hash(&info_hash).await; + let response = self.api_client.whitelist_torrent(&info_hash).await; match response { Ok(response) => { @@ -88,7 +88,7 @@ impl TrackerService { /// Will return an error if the HTTP request failed (for example if the /// tracker API is offline) or if the tracker API returned an error. pub async fn remove_info_hash_from_whitelist(&self, info_hash: String) -> Result<(), ServiceError> { - let response = self.api_client.remove_info_hash_from_whitelist(&info_hash).await; + let response = self.api_client.remove_torrent_from_whitelist(&info_hash).await; match response { Ok(response) => { From 63aefcf687085934ddbf8f4a64eeef38010a96e2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 9 May 2023 14:35:19 +0100 Subject: [PATCH 143/357] refactor: decouple tracker::StatisticsImporter from tracker::Service --- src/app.rs | 13 +- src/common.rs | 10 +- .../commands/import_tracker_statistics.rs | 6 +- src/routes/torrent.rs | 16 +-- src/tracker/mod.rs | 1 + src/tracker/service.rs | 78 ----------- src/tracker/statistics_importer.rs | 122 ++++++++++++++++++ 7 files changed, 149 insertions(+), 97 deletions(-) create mode 100644 src/tracker/statistics_importer.rs diff --git a/src/app.rs b/src/app.rs index 93201b95..d0f7e361 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use crate::databases::database::connect_database; use crate::mailer::MailerService; use crate::routes; use crate::tracker::service::Service; +use crate::tracker::statistics_importer::StatisticsImporter; pub struct Running { pub api_server: Server, @@ -45,6 +46,7 @@ pub async fn run(configuration: Configuration) -> Running { let database = Arc::new(connect_database(&database_connect_url).await.expect("Database error.")); let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); + let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), database.clone()).await); let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); @@ -55,6 +57,7 @@ pub async fn run(configuration: Configuration) -> Running { database.clone(), auth.clone(), tracker_service.clone(), + tracker_statistics_importer.clone(), mailer_service, image_cache_service, )); @@ -62,16 +65,16 @@ pub async fn run(configuration: Configuration) -> Running { // Start repeating task to import tracker torrent data and updating // seeders and leechers info. - let weak_tracker_service = Arc::downgrade(&tracker_service); + let weak_tracker_statistics_importer = Arc::downgrade(&tracker_statistics_importer); - let tracker_data_importer_handle = tokio::spawn(async move { + let tracker_statistics_importer_handle = tokio::spawn(async move { let interval = std::time::Duration::from_secs(database_torrent_info_update_interval); let mut interval = tokio::time::interval(interval); interval.tick().await; // first tick is immediate... loop { interval.tick().await; - if let Some(tracker) = weak_tracker_service.upgrade() { - let _ = tracker.update_torrents().await; + if let Some(tracker) = weak_tracker_statistics_importer.upgrade() { + let _ = tracker.import_all_torrents_statistics().await; } else { break; } @@ -105,6 +108,6 @@ pub async fn run(configuration: Configuration) -> Running { Running { api_server: running_server, socket_address, - tracker_data_importer_handle, + tracker_data_importer_handle: tracker_statistics_importer_handle, } } diff --git a/src/common.rs b/src/common.rs index 1eb14868..25759f71 100644 --- a/src/common.rs +++ b/src/common.rs @@ -6,6 +6,7 @@ use crate::config::Configuration; use crate::databases::database::Database; use crate::mailer::MailerService; use crate::tracker::service::Service; +use crate::tracker::statistics_importer::StatisticsImporter; pub type Username = String; @@ -15,7 +16,8 @@ pub struct AppData { pub cfg: Arc, pub database: Arc>, pub auth: Arc, - pub tracker: Arc, + pub tracker_service: Arc, + pub tracker_statistics_importer: Arc, pub mailer: Arc, pub image_cache_manager: Arc, } @@ -25,7 +27,8 @@ impl AppData { cfg: Arc, database: Arc>, auth: Arc, - tracker: Arc, + tracker_service: Arc, + tracker_statistics_importer: Arc, mailer: Arc, image_cache_manager: Arc, ) -> AppData { @@ -33,7 +36,8 @@ impl AppData { cfg, database, auth, - tracker, + tracker_service, + tracker_statistics_importer, mailer, image_cache_manager, } diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index fc86772b..26473164 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -9,7 +9,7 @@ use text_colorizer::*; use crate::bootstrap::config::init_configuration; use crate::bootstrap::logging; use crate::databases::database::connect_database; -use crate::tracker::service::Service; +use crate::tracker::statistics_importer::StatisticsImporter; const NUMBER_OF_ARGUMENTS: usize = 0; @@ -76,7 +76,7 @@ pub async fn import(_args: &Arguments) { .expect("Database error."), ); - let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); + let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), database.clone()).await); - tracker_service.update_torrents().await.unwrap(); + tracker_statistics_importer.import_all_torrents_statistics().await.unwrap(); } diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 8336edf5..293e31b5 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -100,15 +100,15 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA // update torrent tracker stats let _ = app_data - .tracker - .update_torrent_tracker_stats(torrent_id, &torrent_request.torrent.info_hash()) + .tracker_statistics_importer + .import_torrent_statistics(torrent_id, &torrent_request.torrent.info_hash()) .await; // whitelist info hash on tracker // code-review: why do we always try to whitelist the torrent on the tracker? // shouldn't we only do this if the torrent is in "Listed" mode? if let Err(e) = app_data - .tracker + .tracker_service .whitelist_info_hash(torrent_request.torrent.info_hash()) .await { @@ -146,7 +146,7 @@ pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> match user { Ok(user) => { let personal_announce_url = app_data - .tracker + .tracker_service .get_personal_announce_url(user.user_id) .await .unwrap_or(tracker_url); @@ -210,7 +210,7 @@ pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Serv Ok(user) => { // if no user owned tracker key can be found, use default tracker url let personal_announce_url = app_data - .tracker + .tracker_service .get_personal_announce_url(user.user_id) .await .unwrap_or(tracker_url); @@ -240,8 +240,8 @@ pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Serv // get realtime seeders and leechers if let Ok(torrent_info) = app_data - .tracker - .get_torrent_info(torrent_response.torrent_id, &torrent_response.info_hash) + .tracker_statistics_importer + .import_torrent_statistics(torrent_response.torrent_id, &torrent_response.info_hash) .await { torrent_response.seeders = torrent_info.seeders; @@ -310,7 +310,7 @@ pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> S // remove info_hash from tracker whitelist let _ = app_data - .tracker + .tracker_service .remove_info_hash_from_whitelist(torrent_listing.info_hash) .await; diff --git a/src/tracker/mod.rs b/src/tracker/mod.rs index 6ee79ddb..5fc5a030 100644 --- a/src/tracker/mod.rs +++ b/src/tracker/mod.rs @@ -1,2 +1,3 @@ pub mod api; pub mod service; +pub mod statistics_importer; diff --git a/src/tracker/service.rs b/src/tracker/service.rs index 62122888..33fb0664 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -1,40 +1,11 @@ use std::sync::Arc; -use log::{error, info}; -use serde::{Deserialize, Serialize}; - use super::api::{Client, ConnectionInfo}; use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; use crate::models::tracker_key::TrackerKey; -#[derive(Debug, Serialize, Deserialize)] -pub struct TorrentInfo { - pub info_hash: String, - pub seeders: i64, - pub completed: i64, - pub leechers: i64, - pub peers: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Peer { - pub peer_id: Option, - pub peer_addr: Option, - pub updated: Option, - pub uploaded: Option, - pub downloaded: Option, - pub left: Option, - pub event: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PeerId { - pub id: Option, - pub client: Option, -} - pub struct Service { database: Arc>, api_client: Client, @@ -153,53 +124,4 @@ impl Service { // return tracker key Ok(tracker_key) } - - /// Get torrent info from tracker API - /// - /// # Errors - /// - /// Will return an error if the HTTP request failed or the torrent is not - /// found. - pub async fn get_torrent_info(&self, torrent_id: i64, info_hash: &str) -> Result { - let response = self - .api_client - .get_torrent_info(info_hash) - .await - .map_err(|_| ServiceError::InternalServerError)?; - - if let Ok(torrent_info) = response.json::().await { - let _ = self - .database - .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) - .await; - Ok(torrent_info) - } else { - let _ = self.database.update_tracker_info(torrent_id, &self.tracker_url, 0, 0).await; - Err(ServiceError::TorrentNotFound) - } - } - - pub async fn update_torrents(&self) -> Result<(), ServiceError> { - info!("Updating torrents ..."); - let torrents = self.database.get_all_torrents_compact().await?; - - for torrent in torrents { - info!("Updating torrent {} ...", torrent.torrent_id); - let ret = self - .update_torrent_tracker_stats(torrent.torrent_id, &torrent.info_hash) - .await; - if let Some(err) = ret.err() { - error!( - "Error updating torrent tracker stats for torrent {}: {:?}", - torrent.torrent_id, err - ); - } - } - - Ok(()) - } - - pub async fn update_torrent_tracker_stats(&self, torrent_id: i64, info_hash: &str) -> Result { - self.get_torrent_info(torrent_id, info_hash).await - } } diff --git a/src/tracker/statistics_importer.rs b/src/tracker/statistics_importer.rs new file mode 100644 index 00000000..151b394e --- /dev/null +++ b/src/tracker/statistics_importer.rs @@ -0,0 +1,122 @@ +use std::sync::Arc; + +use log::{error, info}; +use serde::{Deserialize, Serialize}; + +use super::api::{Client, ConnectionInfo}; +use crate::config::Configuration; +use crate::databases::database::{Database, DatabaseError}; +use crate::errors::ServiceError; + +// If `TorrentInfo` struct is used in the future for other purposes, it should +// be moved to a separate file. Maybe a `ClientWrapper` struct which returns +// `TorrentInfo` and `TrackerKey` structs instead of `Response` structs. + +#[derive(Debug, Serialize, Deserialize)] +pub struct TorrentInfo { + pub info_hash: String, + pub seeders: i64, + pub completed: i64, + pub leechers: i64, + pub peers: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Peer { + pub peer_id: Option, + pub peer_addr: Option, + pub updated: Option, + pub uploaded: Option, + pub downloaded: Option, + pub left: Option, + pub event: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PeerId { + pub id: Option, + pub client: Option, +} + +pub struct StatisticsImporter { + database: Arc>, + api_client: Client, + tracker_url: String, +} + +impl StatisticsImporter { + pub async fn new(cfg: Arc, database: Arc>) -> Self { + let settings = cfg.settings.read().await; + let api_client = Client::new(ConnectionInfo::new( + settings.tracker.api_url.clone(), + settings.tracker.token.clone(), + )); + let tracker_url = settings.tracker.url.clone(); + drop(settings); + Self { + database, + api_client, + tracker_url, + } + } + + /// Import torrents statistics from tracker and update them in database. + /// + /// # Errors + /// + /// Will return an error if the database query failed. + pub async fn import_all_torrents_statistics(&self) -> Result<(), DatabaseError> { + info!("Importing torrents statistics from tracker ..."); + let torrents = self.database.get_all_torrents_compact().await?; + + for torrent in torrents { + info!("Updating torrent {} ...", torrent.torrent_id); + + let ret = self.import_torrent_statistics(torrent.torrent_id, &torrent.info_hash).await; + + // code-review: should we treat differently for each case?. The + // tracker API could be temporarily offline, or there could be a + // tracker misconfiguration. + // + // This is the log when the torrent is not found in the tracker: + // + // ``` + // 2023-05-09T13:31:24.497465723+00:00 [torrust_index_backend::tracker::statistics_importer][ERROR] Error updating torrent tracker stats for torrent with id 140: TorrentNotFound + // ``` + + if let Some(err) = ret.err() { + error!( + "Error updating torrent tracker stats for torrent with id {}: {:?}", + torrent.torrent_id, err + ); + } + } + + Ok(()) + } + + /// Import torrent statistics from tracker and update them in database. + /// + /// # Errors + /// + /// Will return an error if the HTTP request failed or the torrent is not + /// found. + pub async fn import_torrent_statistics(&self, torrent_id: i64, info_hash: &str) -> Result { + let response = self + .api_client + .get_torrent_info(info_hash) + .await + .map_err(|_| ServiceError::InternalServerError)?; + + if let Ok(torrent_info) = response.json::().await { + let _ = self + .database + .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) + .await; + Ok(torrent_info) + } else { + let _ = self.database.update_tracker_info(torrent_id, &self.tracker_url, 0, 0).await; + Err(ServiceError::TorrentNotFound) + } + } +} From 404caee5e2f79580d37baf48743824bdaace4f6f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 9 May 2023 16:34:32 +0100 Subject: [PATCH 144/357] refactor: use tracker::Service in StatisticsImporter Instead of using the API client directly. TrackerService is easier to mock becuase you only need to build simple app structs instead of reqwest responses. --- src/app.rs | 3 +- .../commands/import_tracker_statistics.rs | 5 +- src/tracker/service.rs | 50 ++++++++++++++++++ src/tracker/statistics_importer.rs | 51 ++----------------- 4 files changed, 61 insertions(+), 48 deletions(-) diff --git a/src/app.rs b/src/app.rs index d0f7e361..3d1f818f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,7 +46,8 @@ pub async fn run(configuration: Configuration) -> Running { let database = Arc::new(connect_database(&database_connect_url).await.expect("Database error.")); let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); - let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), database.clone()).await); + let tracker_statistics_importer = + Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 26473164..61206449 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -9,6 +9,7 @@ use text_colorizer::*; use crate::bootstrap::config::init_configuration; use crate::bootstrap::logging; use crate::databases::database::connect_database; +use crate::tracker::service::Service; use crate::tracker::statistics_importer::StatisticsImporter; const NUMBER_OF_ARGUMENTS: usize = 0; @@ -76,7 +77,9 @@ pub async fn import(_args: &Arguments) { .expect("Database error."), ); - let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), database.clone()).await); + let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); + let tracker_statistics_importer = + Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); tracker_statistics_importer.import_all_torrents_statistics().await.unwrap(); } diff --git a/src/tracker/service.rs b/src/tracker/service.rs index 33fb0664..35374aab 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -1,11 +1,40 @@ use std::sync::Arc; +use log::error; +use serde::{Deserialize, Serialize}; + use super::api::{Client, ConnectionInfo}; use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; use crate::models::tracker_key::TrackerKey; +#[derive(Debug, Serialize, Deserialize)] +pub struct TorrentInfo { + pub info_hash: String, + pub seeders: i64, + pub completed: i64, + pub leechers: i64, + pub peers: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Peer { + pub peer_id: Option, + pub peer_addr: Option, + pub updated: Option, + pub uploaded: Option, + pub downloaded: Option, + pub left: Option, + pub event: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PeerId { + pub id: Option, + pub client: Option, +} + pub struct Service { database: Arc>, api_client: Client, @@ -96,6 +125,27 @@ impl Service { } } + /// Get torrent info from tracker. + /// + /// # Errors + /// + /// Will return an error if the HTTP request to get torrent info fails or + /// if the response cannot be parsed. + pub async fn get_torrent_info(&self, info_hash: &str) -> Result { + let response = self + .api_client + .get_torrent_info(info_hash) + .await + .map_err(|_| ServiceError::InternalServerError)?; + + if let Ok(torrent_info) = response.json::().await { + Ok(torrent_info) + } else { + error!("Failed to parse torrent info from tracker response"); + Err(ServiceError::InternalServerError) + } + } + /// It builds the announce url appending the user tracker key. /// Eg: fn announce_url_with_key(&self, tracker_key: &TrackerKey) -> String { diff --git a/src/tracker/statistics_importer.rs b/src/tracker/statistics_importer.rs index 151b394e..76c9d485 100644 --- a/src/tracker/statistics_importer.rs +++ b/src/tracker/statistics_importer.rs @@ -1,61 +1,26 @@ use std::sync::Arc; use log::{error, info}; -use serde::{Deserialize, Serialize}; -use super::api::{Client, ConnectionInfo}; +use super::service::{Service, TorrentInfo}; use crate::config::Configuration; use crate::databases::database::{Database, DatabaseError}; use crate::errors::ServiceError; -// If `TorrentInfo` struct is used in the future for other purposes, it should -// be moved to a separate file. Maybe a `ClientWrapper` struct which returns -// `TorrentInfo` and `TrackerKey` structs instead of `Response` structs. - -#[derive(Debug, Serialize, Deserialize)] -pub struct TorrentInfo { - pub info_hash: String, - pub seeders: i64, - pub completed: i64, - pub leechers: i64, - pub peers: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Peer { - pub peer_id: Option, - pub peer_addr: Option, - pub updated: Option, - pub uploaded: Option, - pub downloaded: Option, - pub left: Option, - pub event: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PeerId { - pub id: Option, - pub client: Option, -} - pub struct StatisticsImporter { database: Arc>, - api_client: Client, + tracker_service: Arc, tracker_url: String, } impl StatisticsImporter { - pub async fn new(cfg: Arc, database: Arc>) -> Self { + pub async fn new(cfg: Arc, tracker_service: Arc, database: Arc>) -> Self { let settings = cfg.settings.read().await; - let api_client = Client::new(ConnectionInfo::new( - settings.tracker.api_url.clone(), - settings.tracker.token.clone(), - )); let tracker_url = settings.tracker.url.clone(); drop(settings); Self { database, - api_client, + tracker_service, tracker_url, } } @@ -102,13 +67,7 @@ impl StatisticsImporter { /// Will return an error if the HTTP request failed or the torrent is not /// found. pub async fn import_torrent_statistics(&self, torrent_id: i64, info_hash: &str) -> Result { - let response = self - .api_client - .get_torrent_info(info_hash) - .await - .map_err(|_| ServiceError::InternalServerError)?; - - if let Ok(torrent_info) = response.json::().await { + if let Ok(torrent_info) = self.tracker_service.get_torrent_info(info_hash).await { let _ = self .database .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) From 4a70ee0649208b653805c42d48fb7a957cb42e44 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 18:44:42 +0200 Subject: [PATCH 145/357] dev: apply clippy auto-fixes --- src/cache/cache.rs | 4 +++ src/cache/image/manager.rs | 3 ++ .../commands/import_tracker_statistics.rs | 2 +- src/databases/mysql.rs | 11 +++++-- src/databases/sqlite.rs | 15 ++++++--- src/errors.rs | 10 +++--- src/models/response.rs | 5 +-- src/models/torrent_file.rs | 13 +++++++- src/routes/torrent.rs | 6 ++-- src/routes/user.rs | 2 +- .../transferrers/category_transferrer.rs | 11 +++---- .../transferrers/torrent_transferrer.rs | 32 +++++++++---------- .../transferrers/tracker_key_transferrer.rs | 11 +++---- .../transferrers/user_transferrer.rs | 8 +++-- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 3 +- src/utils/hex.rs | 3 +- src/utils/parse_torrent.rs | 4 +-- src/utils/regex.rs | 1 + tests/databases/tests.rs | 2 +- .../category_transferrer_tester.rs | 2 +- .../torrent_transferrer_tester.rs | 18 +++++------ .../tracker_key_transferrer_tester.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 12 +++---- 23 files changed, 106 insertions(+), 74 deletions(-) diff --git a/src/cache/cache.rs b/src/cache/cache.rs index 8573ba0d..ce842448 100644 --- a/src/cache/cache.rs +++ b/src/cache/cache.rs @@ -27,6 +27,7 @@ pub struct BytesCache { } impl BytesCache { + #[must_use] pub fn new() -> Self { Self { bytes_table: IndexMap::new(), @@ -36,6 +37,7 @@ impl BytesCache { } // With a total capacity in bytes. + #[must_use] pub fn with_capacity(capacity: usize) -> Self { let mut new = Self::new(); @@ -45,6 +47,7 @@ impl BytesCache { } // With a limit for individual entry sizes. + #[must_use] pub fn with_entry_size_limit(entry_size_limit: usize) -> Self { let mut new = Self::new(); @@ -77,6 +80,7 @@ impl BytesCache { } // Size of all the entry bytes combined. + #[must_use] pub fn total_size(&self) -> usize { let mut size: usize = 0; diff --git a/src/cache/image/manager.rs b/src/cache/image/manager.rs index 8a6960a1..bfef1589 100644 --- a/src/cache/image/manager.rs +++ b/src/cache/image/manager.rs @@ -19,6 +19,7 @@ pub enum Error { type UserQuotas = HashMap; +#[must_use] pub fn now_in_secs() -> u64 { match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { Ok(n) => n.as_secs(), @@ -36,6 +37,7 @@ pub struct ImageCacheQuota { } impl ImageCacheQuota { + #[must_use] pub fn new(user_id: i64, max_usage: usize, period_secs: u64) -> Self { Self { user_id, @@ -66,6 +68,7 @@ impl ImageCacheQuota { self.date_start_secs = now_in_secs(); } + #[must_use] pub fn is_reached(&self) -> bool { self.usage >= self.max_usage } diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 61206449..ae0c51cc 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -4,7 +4,7 @@ use std::env; use std::sync::Arc; use derive_more::{Display, Error}; -use text_colorizer::*; +use text_colorizer::Colorize; use crate::bootstrap::config::init_configuration; use crate::bootstrap::logging; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 97f699a7..38e07e8b 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -467,7 +467,7 @@ impl Database for MysqlDatabase { // flatten the nested vec (this will however remove the) let announce_urls = announce_urls.iter().flatten().collect::>(); - for tracker_url in announce_urls.iter() { + for tracker_url in &announce_urls { let _ = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") .bind(torrent_id) .bind(tracker_url) @@ -520,7 +520,7 @@ impl Database for MysqlDatabase { match insert_torrent_info_result { Ok(_) => { let _ = tx.commit().await; - Ok(torrent_id as i64) + Ok(torrent_id) } Err(e) => { let _ = tx.rollback().await; @@ -560,7 +560,12 @@ impl Database for MysqlDatabase { let torrent_files: Vec = db_torrent_files .into_iter() .map(|tf| TorrentFile { - path: tf.path.unwrap_or_default().split('/').map(|v| v.to_string()).collect(), + path: tf + .path + .unwrap_or_default() + .split('/') + .map(std::string::ToString::to_string) + .collect(), length: tf.length, md5sum: tf.md5sum, }) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 3dc022e5..936dd8d5 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -92,7 +92,7 @@ impl Database for SqliteDatabase { match insert_user_profile_result { Ok(_) => { let _ = tx.commit().await; - Ok(user_id as i64) + Ok(user_id) } Err(e) => { let _ = tx.rollback().await; @@ -410,7 +410,7 @@ impl Database for SqliteDatabase { .bind(root_hash) .execute(&self.pool) .await - .map(|v| v.last_insert_rowid() as i64) + .map(|v| v.last_insert_rowid()) .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("info_hash") { @@ -462,7 +462,7 @@ impl Database for SqliteDatabase { // flatten the nested vec (this will however remove the) let announce_urls = announce_urls.iter().flatten().collect::>(); - for tracker_url in announce_urls.iter() { + for tracker_url in &announce_urls { let _ = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") .bind(torrent_id) .bind(tracker_url) @@ -515,7 +515,7 @@ impl Database for SqliteDatabase { match insert_torrent_info_result { Ok(_) => { let _ = tx.commit().await; - Ok(torrent_id as i64) + Ok(torrent_id) } Err(e) => { let _ = tx.rollback().await; @@ -555,7 +555,12 @@ impl Database for SqliteDatabase { let torrent_files: Vec = db_torrent_files .into_iter() .map(|tf| TorrentFile { - path: tf.path.unwrap_or_default().split('/').map(|v| v.to_string()).collect(), + path: tf + .path + .unwrap_or_default() + .split('/') + .map(std::string::ToString::to_string) + .collect(), length: tf.length, md5sum: tf.md5sum, }) diff --git a/src/errors.rs b/src/errors.rs index 571fd9fe..a4eb7943 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -192,7 +192,7 @@ impl ResponseError for ServiceError { impl From for ServiceError { fn from(e: sqlx::Error) -> Self { - eprintln!("{:?}", e); + eprintln!("{e:?}"); if let Some(err) = e.as_database_error() { return if err.code() == Some(Cow::from("2067")) { @@ -229,28 +229,28 @@ impl From for ServiceError { impl From for ServiceError { fn from(e: argon2::password_hash::Error) -> Self { - eprintln!("{}", e); + eprintln!("{e}"); ServiceError::InternalServerError } } impl From for ServiceError { fn from(e: std::io::Error) -> Self { - eprintln!("{}", e); + eprintln!("{e}"); ServiceError::InternalServerError } } impl From> for ServiceError { fn from(e: Box) -> Self { - eprintln!("{}", e); + eprintln!("{e}"); ServiceError::InternalServerError } } impl From for ServiceError { fn from(e: serde_json::Error) -> Self { - eprintln!("{}", e); + eprintln!("{e}"); ServiceError::InternalServerError } } diff --git a/src/models/response.rs b/src/models/response.rs index 059e1c3b..58ec31fb 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -48,6 +48,7 @@ pub struct TorrentResponse { } impl TorrentResponse { + #[must_use] pub fn from_listing(torrent_listing: TorrentListing) -> TorrentResponse { TorrentResponse { torrent_id: torrent_listing.torrent_id, @@ -57,7 +58,7 @@ impl TorrentResponse { description: torrent_listing.description, category: Category { category_id: 0, - name: "".to_string(), + name: String::new(), num_torrents: 0, }, upload_date: torrent_listing.date_uploaded, @@ -66,7 +67,7 @@ impl TorrentResponse { leechers: torrent_listing.leechers, files: vec![], trackers: vec![], - magnet_link: "".to_string(), + magnet_link: String::new(), } } } diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index be3b6101..befc6161 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -42,13 +42,15 @@ pub struct TorrentInfo { impl TorrentInfo { /// torrent file can only hold a pieces key or a root hash key: /// http://www.bittorrent.org/beps/bep_0030.html + #[must_use] pub fn get_pieces_as_string(&self) -> String { match &self.pieces { - None => "".to_string(), + None => String::new(), Some(byte_buf) => bytes_to_hex(byte_buf.as_ref()), } } + #[must_use] pub fn get_root_hash_as_i64(&self) -> i64 { match &self.root_hash { None => 0i64, @@ -56,10 +58,12 @@ impl TorrentInfo { } } + #[must_use] pub fn is_a_single_file_torrent(&self) -> bool { self.length.is_some() } + #[must_use] pub fn is_a_multiple_file_torrent(&self) -> bool { self.files.is_some() } @@ -90,6 +94,7 @@ pub struct Torrent { } impl Torrent { + #[must_use] pub fn from_db_info_files_and_announce_urls( torrent_info: DbTorrentInfo, torrent_files: Vec, @@ -170,6 +175,7 @@ impl Torrent { } } + #[must_use] pub fn calculate_info_hash_as_bytes(&self) -> [u8; 20] { let info_bencoded = ser::to_bytes(&self.info).unwrap(); let mut hasher = Sha1::new(); @@ -180,10 +186,12 @@ impl Torrent { sum_bytes } + #[must_use] pub fn info_hash(&self) -> String { bytes_to_hex(&self.calculate_info_hash_as_bytes()) } + #[must_use] pub fn file_size(&self) -> i64 { if self.info.length.is_some() { self.info.length.unwrap() @@ -201,6 +209,7 @@ impl Torrent { } } + #[must_use] pub fn announce_urls(&self) -> Vec { if self.announce_list.is_none() { return vec![self.announce.clone().unwrap()]; @@ -214,10 +223,12 @@ impl Torrent { .collect::>() } + #[must_use] pub fn is_a_single_file_torrent(&self) -> bool { self.info.is_a_single_file_torrent() } + #[must_use] pub fn is_a_multiple_file_torrent(&self) -> bool { self.info.is_a_multiple_file_torrent() } diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 293e31b5..ffba1724 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -370,9 +370,9 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result, app_da return Err(ServiceError::UsernameInvalid); } - let email = payload.email.as_ref().unwrap_or(&"".to_string()).to_string(); + let email = payload.email.as_ref().unwrap_or(&String::new()).to_string(); let user_id = app_data .database diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs index f3d83d9b..795c9f34 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -22,12 +22,11 @@ pub async fn transfer_categories(source_database: Arc, tar .await .unwrap(); - if id != cat.category_id { - panic!( - "Error copying category {:?} from source DB to the target DB", - &cat.category_id - ); - } + assert!( + id == cat.category_id, + "Error copying category {:?} from source DB to the target DB", + &cat.category_id + ); println!("[v2] category: {:?} {:?} added.", id, &cat.name); } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs index 88a681f0..89b4fdd7 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs @@ -29,21 +29,22 @@ pub async fn transfer_torrents( let uploader = source_database.get_user_by_username(&torrent.uploader).await.unwrap(); - if uploader.username != torrent.uploader { - panic!( - "Error copying torrent with id {:?}. + assert!( + uploader.username == torrent.uploader, + "Error copying torrent with id {:?}. Username (`uploader`) in `torrust_torrents` table does not match `username` in `torrust_users` table", - &torrent.torrent_id - ); - } + &torrent.torrent_id + ); let filepath = format!("{}/{}.torrent", upload_path, &torrent.torrent_id); let torrent_from_file_result = read_torrent_from_file(&filepath); - if torrent_from_file_result.is_err() { - panic!("Error torrent file not found: {:?}", &filepath); - } + assert!( + torrent_from_file_result.is_ok(), + "Error torrent file not found: {:?}", + &filepath + ); let torrent_from_file = torrent_from_file_result.unwrap(); @@ -52,12 +53,11 @@ pub async fn transfer_torrents( .await .unwrap(); - if id != torrent.torrent_id { - panic!( - "Error copying torrent {:?} from source DB to the target DB", - &torrent.torrent_id - ); - } + assert!( + id == torrent.torrent_id, + "Error copying torrent {:?} from source DB to the target DB", + &torrent.torrent_id + ); println!("[v2][torrust_torrents] torrent with id {:?} added.", &torrent.torrent_id); @@ -144,7 +144,7 @@ pub async fn transfer_torrents( .flatten() .collect::>(); - for tracker_url in announce_urls.iter() { + for tracker_url in &announce_urls { println!( "[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", &torrent.torrent_id diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs index 51c451b0..1e475d8e 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs @@ -28,12 +28,11 @@ pub async fn transfer_tracker_keys(source_database: Arc, t .await .unwrap(); - if id != tracker_key.key_id { - panic!( - "Error copying tracker key {:?} from source DB to the target DB", - &tracker_key.key_id - ); - } + assert!( + id == tracker_key.key_id, + "Error copying tracker key {:?} from source DB to the target DB", + &tracker_key.key_id + ); println!( "[v2][torrust_tracker_keys] tracker key with id {:?} added.", diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs index 76f5ff44..76791d32 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs @@ -27,9 +27,11 @@ pub async fn transfer_users( .await .unwrap(); - if id != user.user_id { - panic!("Error copying user {:?} from source DB to the target DB", &user.user_id); - } + assert!( + id == user.user_id, + "Error copying user {:?} from source DB to the target DB", + &user.user_id + ); println!("[v2][torrust_users] user: {:?} {:?} added.", &user.user_id, &user.username); diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 0cc0ea53..6e8901f5 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -15,7 +15,7 @@ use std::env; use std::time::SystemTime; use chrono::prelude::{DateTime, Utc}; -use text_colorizer::*; +use text_colorizer::Colorize; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{current_db, migrate_target_database, new_db, reset_target_database}; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::category_transferrer::transfer_categories; @@ -102,6 +102,7 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) { /// Current datetime in ISO8601 without time zone. /// For example: 2022-11-10 10:35:15 +#[must_use] pub fn datetime_iso_8601() -> String { let dt: DateTime = SystemTime::now().into(); format!("{}", dt.format("%Y-%m-%d %H:%M:%S")) diff --git a/src/utils/hex.rs b/src/utils/hex.rs index 7903c741..3cc9f57e 100644 --- a/src/utils/hex.rs +++ b/src/utils/hex.rs @@ -1,11 +1,12 @@ use std::fmt::Write; use std::num::ParseIntError; +#[must_use] pub fn bytes_to_hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(2 * bytes.len()); for byte in bytes { - write!(s, "{:02X}", byte).unwrap(); + write!(s, "{byte:02X}").unwrap(); } s diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index e272ede8..d15e5c82 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -8,7 +8,7 @@ pub fn decode_torrent(bytes: &[u8]) -> Result> { match de::from_bytes::(bytes) { Ok(torrent) => Ok(torrent), Err(e) => { - println!("{:?}", e); + println!("{e:?}"); Err(e.into()) } } @@ -18,7 +18,7 @@ pub fn encode_torrent(torrent: &Torrent) -> Result, Error> { match serde_bencode::to_bytes(torrent) { Ok(bencode_bytes) => Ok(bencode_bytes), Err(e) => { - eprintln!("{:?}", e); + eprintln!("{e:?}"); Err(e) } } diff --git a/src/utils/regex.rs b/src/utils/regex.rs index 4c5b55ff..22df6176 100644 --- a/src/utils/regex.rs +++ b/src/utils/regex.rs @@ -1,5 +1,6 @@ use regex::Regex; +#[must_use] pub fn validate_email_address(email_address_to_be_checked: &str) -> bool { let email_regex = Regex::new(r"^([a-z\d_+]([a-z\d_+.]*[a-z\d_+])?)@([a-z\d]+([\-.][a-z\d]+)*\.[a-z]{2,6})").unwrap(); diff --git a/tests/databases/tests.rs b/tests/databases/tests.rs index d74088ff..834fdc47 100644 --- a/tests/databases/tests.rs +++ b/tests/databases/tests.rs @@ -81,7 +81,7 @@ pub async fn it_can_add_a_torrent_and_tracker_stats_to_that_torrent(db: &Box (Arc, Arc } async fn source_db_connection(source_database_file: &str) -> Arc { - Arc::new(SqliteDatabaseV1_0_0::db_connection(&source_database_file).await) + Arc::new(SqliteDatabaseV1_0_0::db_connection(source_database_file).await) } async fn target_db_connection(target_database_file: &str) -> Arc { - Arc::new(SqliteDatabaseV2_0_0::db_connection(&target_database_file).await) + Arc::new(SqliteDatabaseV2_0_0::db_connection(target_database_file).await) } /// Reset databases from previous executions fn reset_databases(source_database_file: &str, target_database_file: &str) { if Path::new(source_database_file).exists() { - fs::remove_file(&source_database_file).expect("Can't remove the source DB file."); + fs::remove_file(source_database_file).expect("Can't remove the source DB file."); } if Path::new(target_database_file).exists() { - fs::remove_file(&target_database_file).expect("Can't remove the target DB file."); + fs::remove_file(target_database_file).expect("Can't remove the target DB file."); } } From a741a22b8039d26067375c537e29566fc06fad42 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 19:10:21 +0200 Subject: [PATCH 146/357] dev: fix clippy warnings for: src/auth.rs --- src/auth.rs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 8c0f2c27..21432ac8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -19,6 +19,7 @@ impl AuthorizationService { AuthorizationService { cfg, database } } + /// Create Json Web Token pub async fn sign_jwt(&self, user: UserCompact) -> String { let settings = self.cfg.settings.read().await; @@ -29,9 +30,14 @@ impl AuthorizationService { let claims = UserClaims { user, exp: exp_date }; - encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap() + encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).expect("argument `Header` should match `EncodingKey`") } + /// Verify Json Web Token + /// + /// # Errors + /// + /// This function will return an error if the JWT is not good or expired. pub async fn verify_jwt(&self, token: &str) -> Result { let settings = self.cfg.settings.read().await; @@ -50,12 +56,21 @@ impl AuthorizationService { } } + /// Get Claims from Request + /// + /// # Errors + /// + /// This function will return an `ServiceError::TokenNotFound` if `HeaderValue` is `None` + /// This function will pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. pub async fn get_claims_from_request(&self, req: &HttpRequest) -> Result { - let _auth = req.headers().get("Authorization"); - match _auth { - Some(_) => { - let _split: Vec<&str> = _auth.unwrap().to_str().unwrap().split("Bearer").collect(); - let token = _split[1].trim(); + match req.headers().get("Authorization") { + Some(auth) => { + let split: Vec<&str> = auth + .to_str() + .expect("variable `auth` contains data that is not visible ASCII chars.") + .split("Bearer") + .collect(); + let token = split[1].trim(); match self.verify_jwt(token).await { Ok(claims) => Ok(claims), @@ -66,6 +81,11 @@ impl AuthorizationService { } } + /// Get User (in compact form) from Request + /// + /// # Errors + /// + /// This function will return an `ServiceError::UserNotFound` if unable to get user from database. pub async fn get_user_compact_from_request(&self, req: &HttpRequest) -> Result { let claims = self.get_claims_from_request(req).await?; From 836d53f7991a5ca8e6ade68fd459468fdffa439e Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 19:24:35 +0200 Subject: [PATCH 147/357] dev: fix clippy warnings for: src/config.rs --- src/config.rs | 180 +++++++++++++++++--------- src/routes/settings.rs | 6 +- tests/common/contexts/settings/mod.rs | 5 +- tests/environments/app_starter.rs | 10 +- tests/environments/isolated.rs | 9 +- 5 files changed, 134 insertions(+), 76 deletions(-) diff --git a/src/config.rs b/src/config.rs index 24fa6201..5a95ff2f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,14 @@ pub struct Website { pub name: String, } +impl Default for Website { + fn default() -> Self { + Self { + name: "Torrust".to_string(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TrackerMode { // todo: use https://crates.io/crates/torrust-tracker-primitives @@ -20,6 +28,12 @@ pub enum TrackerMode { PrivateWhitelisted, } +impl Default for TrackerMode { + fn default() -> Self { + Self::Public + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tracker { pub url: String, @@ -29,6 +43,18 @@ pub struct Tracker { pub token_valid_seconds: u64, } +impl Default for Tracker { + fn default() -> Self { + Self { + url: "udp://localhost:6969".to_string(), + mode: TrackerMode::default(), + api_url: "http://localhost:1212".to_string(), + token: "MyAccessToken".to_string(), + token_valid_seconds: 7_257_600, + } + } +} + /// Port 0 means that the OS will choose a random free port. pub const FREE_PORT: u16 = 0; @@ -38,6 +64,15 @@ pub struct Network { pub base_url: Option, } +impl Default for Network { + fn default() -> Self { + Self { + port: 3000, + base_url: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum EmailOnSignup { Required, @@ -45,6 +80,12 @@ pub enum EmailOnSignup { None, } +impl Default for EmailOnSignup { + fn default() -> Self { + Self::Optional + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Auth { pub email_on_signup: EmailOnSignup, @@ -53,12 +94,32 @@ pub struct Auth { pub secret_key: String, } +impl Default for Auth { + fn default() -> Self { + Self { + email_on_signup: EmailOnSignup::default(), + min_password_length: 6, + max_password_length: 64, + secret_key: "MaxVerstappenWC2021".to_string(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Database { pub connect_url: String, pub torrent_info_update_interval: u64, } +impl Default for Database { + fn default() -> Self { + Self { + connect_url: "sqlite://data.db?mode=rwc".to_string(), + torrent_info_update_interval: 3600, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Mail { pub email_verification_enabled: bool, @@ -70,6 +131,21 @@ pub struct Mail { pub port: u16, } +impl Default for Mail { + fn default() -> Self { + Self { + email_verification_enabled: false, + from: "example@email.com".to_string(), + reply_to: "noreply@email.com".to_string(), + username: String::default(), + password: String::default(), + server: String::default(), + port: 25, + } + } +} + +#[allow(clippy::module_name_repetitions)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageCache { pub max_request_timeout_ms: u64, @@ -85,8 +161,29 @@ pub struct Api { pub max_torrent_page_size: u8, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppConfiguration { +impl Default for Api { + fn default() -> Self { + Self { + default_torrent_page_size: 10, + max_torrent_page_size: 30, + } + } +} + +impl Default for ImageCache { + fn default() -> Self { + Self { + max_request_timeout_ms: 1000, + capacity: 128_000_000, + entry_size_limit: 4_000_000, + user_quota_period_seconds: 3600, + user_quota_bytes: 64_000_000, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct TorrustBackend { pub website: Website, pub tracker: Tracker, pub net: Network, @@ -97,67 +194,16 @@ pub struct AppConfiguration { pub api: Api, } -impl Default for AppConfiguration { - fn default() -> Self { - Self { - website: Website { - name: "Torrust".to_string(), - }, - tracker: Tracker { - url: "udp://localhost:6969".to_string(), - mode: TrackerMode::Public, - api_url: "http://localhost:1212".to_string(), - token: "MyAccessToken".to_string(), - token_valid_seconds: 7_257_600, - }, - net: Network { - port: 3000, - base_url: None, - }, - auth: Auth { - email_on_signup: EmailOnSignup::Optional, - min_password_length: 6, - max_password_length: 64, - secret_key: "MaxVerstappenWC2021".to_string(), - }, - database: Database { - connect_url: "sqlite://data.db?mode=rwc".to_string(), - torrent_info_update_interval: 3600, - }, - mail: Mail { - email_verification_enabled: false, - from: "example@email.com".to_string(), - reply_to: "noreply@email.com".to_string(), - username: String::new(), - password: String::new(), - server: String::new(), - port: 25, - }, - image_cache: ImageCache { - max_request_timeout_ms: 1000, - capacity: 128_000_000, - entry_size_limit: 4_000_000, - user_quota_period_seconds: 3600, - user_quota_bytes: 64_000_000, - }, - api: Api { - default_torrent_page_size: 10, - max_torrent_page_size: 30, - }, - } - } -} - #[derive(Debug)] pub struct Configuration { - pub settings: RwLock, + pub settings: RwLock, pub config_path: Option, } impl Default for Configuration { - fn default() -> Self { - Self { - settings: RwLock::new(AppConfiguration::default()), + fn default() -> Configuration { + Configuration { + settings: RwLock::new(TorrustBackend::default()), config_path: None, } } @@ -165,6 +211,11 @@ impl Default for Configuration { impl Configuration { /// Loads the configuration from the configuration file. + /// + /// # Errors + /// + /// This function will return an error no configuration in the `CONFIG_PATH` exists, and a new file is is created. + /// This function will return an error if the `config` is not a valid `TorrustConfig` document. pub async fn load_from_file(config_path: &str) -> Result { let config_builder = Config::builder(); @@ -183,7 +234,7 @@ impl Configuration { )); } - let torrust_config: AppConfiguration = match config.try_deserialize() { + let torrust_config: TorrustBackend = match config.try_deserialize() { Ok(data) => Ok(data), Err(e) => Err(ConfigError::Message(format!("Errors while processing config: {}.", e))), }?; @@ -207,7 +258,7 @@ impl Configuration { let config_builder = Config::builder() .add_source(File::from_str(&config_toml, FileFormat::Toml)) .build()?; - let torrust_config: AppConfiguration = config_builder.try_deserialize()?; + let torrust_config: TorrustBackend = config_builder.try_deserialize()?; Ok(Configuration { settings: RwLock::new(torrust_config), config_path: None, @@ -219,6 +270,7 @@ impl Configuration { } } + /// Returns the save to file of this [`Configuration`]. pub async fn save_to_file(&self, config_path: &str) { let settings = self.settings.read().await; @@ -229,13 +281,17 @@ impl Configuration { fs::write(config_path, toml_string).expect("Could not write to file!"); } - /// Updates the settings and saves them to the configuration file. + /// Update the settings file based upon a supplied `new_settings`. + /// + /// # Errors + /// + /// Todo: Make an error if the save fails. /// /// # Panics /// /// Will panic if the configuration file path is not defined. That happens /// when the configuration was loaded from the environment variable. - pub async fn update_settings(&self, new_settings: AppConfiguration) { + pub async fn update_settings(&self, new_settings: TorrustBackend) -> Result<(), ()> { match &self.config_path { Some(config_path) => { let mut settings = self.settings.write().await; @@ -244,6 +300,8 @@ impl Configuration { drop(settings); let _ = self.save_to_file(config_path).await; + + Ok(()) } None => panic!( "Cannot update settings when the config file path is not defined. For example: when it's loaded from env var." diff --git a/src/routes/settings.rs b/src/routes/settings.rs index e2b6849f..65dd0716 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -1,7 +1,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use crate::common::WebAppData; -use crate::config::AppConfiguration; +use crate::config; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::OkResponse; @@ -27,7 +27,7 @@ pub async fn get_settings(req: HttpRequest, app_data: WebAppData) -> ServiceResu return Err(ServiceError::Unauthorized); } - let settings: tokio::sync::RwLockReadGuard = app_data.cfg.settings.read().await; + let settings: tokio::sync::RwLockReadGuard = app_data.cfg.settings.read().await; Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) } @@ -57,7 +57,7 @@ pub async fn get_site_name(app_data: WebAppData) -> ServiceResult, + payload: web::Json, app_data: WebAppData, ) -> ServiceResult { // check for user diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index 4e4f1643..604297f4 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -3,9 +3,8 @@ pub mod responses; use serde::{Deserialize, Serialize}; use torrust_index_backend::config::{ - Api as DomainApi, AppConfiguration as DomainSettings, Auth as DomainAuth, Database as DomainDatabase, - ImageCache as DomainImageCache, Mail as DomainMail, Network as DomainNetwork, Tracker as DomainTracker, - Website as DomainWebsite, + Api as DomainApi, Auth as DomainAuth, Database as DomainDatabase, ImageCache as DomainImageCache, Mail as DomainMail, + Network as DomainNetwork, TorrustBackend as DomainSettings, Tracker as DomainTracker, Website as DomainWebsite, }; #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index 55cbb355..251f0481 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -2,12 +2,12 @@ use std::net::SocketAddr; use log::info; use tokio::sync::{oneshot, RwLock}; -use torrust_index_backend::app; -use torrust_index_backend::config::{AppConfiguration, Configuration}; +use torrust_index_backend::config::Configuration; +use torrust_index_backend::{app, config}; /// It launches the app and provides a way to stop it. pub struct AppStarter { - configuration: AppConfiguration, + configuration: config::TorrustBackend, config_path: Option, /// The application binary state (started or not): /// - `None`: if the app is not started, @@ -17,7 +17,7 @@ pub struct AppStarter { impl AppStarter { #[must_use] - pub fn with_custom_configuration(configuration: AppConfiguration, config_path: Option) -> Self { + pub fn with_custom_configuration(configuration: config::TorrustBackend, config_path: Option) -> Self { Self { configuration, config_path, @@ -75,7 +75,7 @@ impl AppStarter { } #[must_use] - pub fn server_configuration(&self) -> AppConfiguration { + pub fn server_configuration(&self) -> config::TorrustBackend { self.configuration.clone() } diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index 943497ee..e619e191 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -1,5 +1,6 @@ use tempfile::TempDir; -use torrust_index_backend::config::{AppConfiguration, FREE_PORT}; +use torrust_index_backend::config; +use torrust_index_backend::config::FREE_PORT; use super::app_starter::AppStarter; use crate::common::random; @@ -44,7 +45,7 @@ impl TestEnv { /// Provides the whole server configuration. #[must_use] - pub fn server_configuration(&self) -> AppConfiguration { + pub fn server_configuration(&self) -> config::TorrustBackend { self.app_starter.server_configuration() } @@ -67,8 +68,8 @@ impl Default for TestEnv { } /// Provides a configuration with ephemeral data for testing. -fn ephemeral(temp_dir: &TempDir) -> AppConfiguration { - let mut configuration = AppConfiguration::default(); +fn ephemeral(temp_dir: &TempDir) -> config::TorrustBackend { + let mut configuration = config::TorrustBackend::default(); // Ephemeral API port configuration.net.port = FREE_PORT; From 7b28120af76500806c1c8c166f562be1bcebd388 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 19:30:57 +0200 Subject: [PATCH 148/357] dev: fix clippy warnings for: src/console/commands/import_tracker_statistics.rs --- src/console/commands/import_tracker_statistics.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index ae0c51cc..4c12bf66 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -53,9 +53,14 @@ fn print_usage() { } pub async fn run_importer() { - import(&parse_args().unwrap()).await; + import(&parse_args().expect("unable to parse command arguments")).await; } +/// Import Command Arguments +/// +/// # Panics +/// +/// Panics if `Configuration::load_from_file` has any error. pub async fn import(_args: &Arguments) { println!("Importing statistics from linked tracker ..."); @@ -81,5 +86,8 @@ pub async fn import(_args: &Arguments) { let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); - tracker_statistics_importer.import_all_torrents_statistics().await.unwrap(); + tracker_statistics_importer + .import_all_torrents_statistics() + .await + .expect("variable `tracker_service` is unable to `update_torrents`"); } From ebc360ef816b3168dc9293bbfa1ddf2fa4aed72e Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 19:44:25 +0200 Subject: [PATCH 149/357] dev: fix clippy warnings for: src/databases/database.rs --- src/app.rs | 4 +- .../commands/import_tracker_statistics.rs | 4 +- src/databases/database.rs | 84 ++++--- src/databases/mysql.rs | 216 +++++++++--------- src/databases/sqlite.rs | 214 ++++++++--------- src/errors.rs | 26 +-- src/tracker/statistics_importer.rs | 4 +- .../databases/sqlite_v1_0_0.rs | 6 +- .../databases/sqlite_v2_0_0.rs | 20 +- tests/databases/mod.rs | 5 +- tests/databases/tests.rs | 7 +- tests/e2e/contexts/torrent/asserts.rs | 4 +- tests/e2e/contexts/user/steps.rs | 4 +- 13 files changed, 301 insertions(+), 297 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3d1f818f..701aa64c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use crate::bootstrap::logging; use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; use crate::config::Configuration; -use crate::databases::database::connect_database; +use crate::databases::database; use crate::mailer::MailerService; use crate::routes; use crate::tracker::service::Service; @@ -43,7 +43,7 @@ pub async fn run(configuration: Configuration) -> Running { // Build app dependencies - let database = Arc::new(connect_database(&database_connect_url).await.expect("Database error.")); + let database = Arc::new(database::connect(&database_connect_url).await.expect("Database error.")); let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); let tracker_statistics_importer = diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 4c12bf66..eb31bb3c 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -8,7 +8,7 @@ use text_colorizer::Colorize; use crate::bootstrap::config::init_configuration; use crate::bootstrap::logging; -use crate::databases::database::connect_database; +use crate::databases::database; use crate::tracker::service::Service; use crate::tracker::statistics_importer::StatisticsImporter; @@ -77,7 +77,7 @@ pub async fn import(_args: &Arguments) { eprintln!("Tracker url: {}", tracker_url.green()); let database = Arc::new( - connect_database(&settings.database.connect_url) + database::connect(&settings.database.connect_url) .await .expect("Database error."), ); diff --git a/src/databases/database.rs b/src/databases/database.rs index 915b52ce..778c3dfe 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -13,7 +13,7 @@ use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; /// Database drivers. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] -pub enum DatabaseDriver { +pub enum Driver { Sqlite3, Mysql, } @@ -50,7 +50,7 @@ pub enum Sorting { /// Database errors. #[derive(Debug)] -pub enum DatabaseError { +pub enum Error { Error, UnrecognizedDatabaseDriver, // when the db path does not start with sqlite or mysql UsernameTaken, @@ -64,7 +64,11 @@ pub enum DatabaseError { } /// Connect to a database. -pub async fn connect_database(db_path: &str) -> Result, DatabaseError> { +/// +/// # Errors +/// +/// This function will return an `Error::UnrecognizedDatabaseDriver` if unable to match database type. +pub async fn connect(db_path: &str) -> Result, Error> { match &db_path.chars().collect::>() as &[char] { ['s', 'q', 'l', 'i', 't', 'e', ..] => { let db = SqliteDatabase::new(db_path).await; @@ -74,7 +78,7 @@ pub async fn connect_database(db_path: &str) -> Result, Databa let db = MysqlDatabase::new(db_path).await; Ok(Box::new(db)) } - _ => Err(DatabaseError::UnrecognizedDatabaseDriver), + _ => Err(Error::UnrecognizedDatabaseDriver), } } @@ -82,58 +86,58 @@ pub async fn connect_database(db_path: &str) -> Result, Databa #[async_trait] pub trait Database: Sync + Send { /// Return current database driver. - fn get_database_driver(&self) -> DatabaseDriver; + fn get_database_driver(&self) -> Driver; /// Add new user and return the newly inserted `user_id`. - async fn insert_user_and_get_id(&self, username: &str, email: &str, password: &str) -> Result; + async fn insert_user_and_get_id(&self, username: &str, email: &str, password: &str) -> Result; /// Get `User` from `user_id`. - async fn get_user_from_id(&self, user_id: i64) -> Result; + async fn get_user_from_id(&self, user_id: i64) -> Result; /// Get `UserAuthentication` from `user_id`. - async fn get_user_authentication_from_id(&self, user_id: i64) -> Result; + async fn get_user_authentication_from_id(&self, user_id: i64) -> Result; /// Get `UserProfile` from `username`. - async fn get_user_profile_from_username(&self, username: &str) -> Result; + async fn get_user_profile_from_username(&self, username: &str) -> Result; /// Get `UserCompact` from `user_id`. - async fn get_user_compact_from_id(&self, user_id: i64) -> Result; + async fn get_user_compact_from_id(&self, user_id: i64) -> Result; /// Get a user's `TrackerKey`. async fn get_user_tracker_key(&self, user_id: i64) -> Option; /// Get total user count. - async fn count_users(&self) -> Result; + async fn count_users(&self) -> Result; /// Ban user with `user_id`, `reason` and `date_expiry`. - async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), DatabaseError>; + async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), Error>; /// Grant a user the administrator role. - async fn grant_admin_role(&self, user_id: i64) -> Result<(), DatabaseError>; + async fn grant_admin_role(&self, user_id: i64) -> Result<(), Error>; /// Verify a user's email with `user_id`. - async fn verify_email(&self, user_id: i64) -> Result<(), DatabaseError>; + async fn verify_email(&self, user_id: i64) -> Result<(), Error>; /// Link a `TrackerKey` to a certain user with `user_id`. - async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), DatabaseError>; + async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), Error>; /// Delete user and all related user data with `user_id`. - async fn delete_user(&self, user_id: i64) -> Result<(), DatabaseError>; + async fn delete_user(&self, user_id: i64) -> Result<(), Error>; /// Add a new category and return `category_id`. - async fn insert_category_and_get_id(&self, category_name: &str) -> Result; + async fn insert_category_and_get_id(&self, category_name: &str) -> Result; /// Get `Category` from `category_id`. - async fn get_category_from_id(&self, category_id: i64) -> Result; + async fn get_category_from_id(&self, category_id: i64) -> Result; /// Get `Category` from `category_name`. - async fn get_category_from_name(&self, category_name: &str) -> Result; + async fn get_category_from_name(&self, category_name: &str) -> Result; /// Get all categories as `Vec`. - async fn get_categories(&self) -> Result, DatabaseError>; + async fn get_categories(&self) -> Result, Error>; /// Delete category with `category_name`. - async fn delete_category(&self, category_name: &str) -> Result<(), DatabaseError>; + async fn delete_category(&self, category_name: &str) -> Result<(), Error>; /// Get results of a torrent search in a paginated and sorted form as `TorrentsResponse` from `search`, `categories`, `sort`, `offset` and `page_size`. async fn get_torrents_search_sorted_paginated( @@ -143,7 +147,7 @@ pub trait Database: Sync + Send { sort: &Sorting, offset: u64, page_size: u8, - ) -> Result; + ) -> Result; /// Add new torrent and return the newly inserted `torrent_id` with `torrent`, `uploader_id`, `category_id`, `title` and `description`. async fn insert_torrent_and_get_id( @@ -153,10 +157,10 @@ pub trait Database: Sync + Send { category_id: i64, title: &str, description: &str, - ) -> Result; + ) -> Result; /// Get `Torrent` from `InfoHash`. - async fn get_torrent_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_from_infohash(&self, infohash: &InfoHash) -> Result { let torrent_info = self.get_torrent_info_from_infohash(infohash).await?; let torrent_files = self.get_torrent_files_from_id(torrent_info.torrent_id).await?; @@ -171,7 +175,7 @@ pub trait Database: Sync + Send { } /// Get `Torrent` from `torrent_id`. - async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { + async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; @@ -186,44 +190,38 @@ pub trait Database: Sync + Send { } /// Get torrent's info as `DbTorrentInfo` from `torrent_id`. - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; /// Get torrent's info as `DbTorrentInfo` from torrent `InfoHash`. - async fn get_torrent_info_from_infohash(&self, info_hash: &InfoHash) -> Result; + async fn get_torrent_info_from_infohash(&self, info_hash: &InfoHash) -> Result; /// Get all torrent's files as `Vec` from `torrent_id`. - async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError>; + async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, Error>; /// Get all torrent's announce urls as `Vec>` from `torrent_id`. - async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, DatabaseError>; + async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, Error>; /// Get `TorrentListing` from `torrent_id`. - async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result; + async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result; /// Get `TorrentListing` from `InfoHash`. - async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result; + async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result; /// Get all torrents as `Vec`. - async fn get_all_torrents_compact(&self) -> Result, DatabaseError>; + async fn get_all_torrents_compact(&self) -> Result, Error>; /// Update a torrent's title with `torrent_id` and `title`. - async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), DatabaseError>; + async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), Error>; /// Update a torrent's description with `torrent_id` and `description`. - async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), DatabaseError>; + async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), Error>; /// Update the seeders and leechers info for a torrent with `torrent_id`, `tracker_url`, `seeders` and `leechers`. - async fn update_tracker_info( - &self, - torrent_id: i64, - tracker_url: &str, - seeders: i64, - leechers: i64, - ) -> Result<(), DatabaseError>; + async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &str, seeders: i64, leechers: i64) -> Result<(), Error>; /// Delete a torrent with `torrent_id`. - async fn delete_torrent(&self, torrent_id: i64) -> Result<(), DatabaseError>; + async fn delete_torrent(&self, torrent_id: i64) -> Result<(), Error>; /// DELETES ALL DATABASE ROWS, ONLY CALL THIS IF YOU KNOW WHAT YOU'RE DOING! - async fn delete_all_database_rows(&self) -> Result<(), DatabaseError>; + async fn delete_all_database_rows(&self) -> Result<(), Error>; } diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 38e07e8b..d4bf4789 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -3,7 +3,8 @@ use chrono::NaiveDateTime; use sqlx::mysql::MySqlPoolOptions; use sqlx::{query, query_as, Acquire, MySqlPool}; -use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; +use crate::databases::database; +use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; @@ -35,23 +36,23 @@ impl MysqlDatabase { #[async_trait] impl Database for MysqlDatabase { - fn get_database_driver(&self) -> DatabaseDriver { - DatabaseDriver::Mysql + fn get_database_driver(&self) -> Driver { + Driver::Mysql } - async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { + async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { // open pool connection - let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; // start db transaction - let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; // create the user account and get the user id let user_id = query("INSERT INTO torrust_users (date_registered) VALUES (UTC_TIMESTAMP())") .execute(&mut tx) .await .map(|v| v.last_insert_id()) - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; // add password hash for account let insert_user_auth_result = query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") @@ -59,7 +60,7 @@ impl Database for MysqlDatabase { .bind(password_hash) .execute(&mut tx) .await - .map_err(|_| DatabaseError::Error); + .map_err(|_| database::Error::Error); // rollback transaction on error if let Err(e) = insert_user_auth_result { @@ -77,14 +78,14 @@ impl Database for MysqlDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("username") { - DatabaseError::UsernameTaken + database::Error::UsernameTaken } else if err.message().contains("email") { - DatabaseError::EmailTaken + database::Error::EmailTaken } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error + _ => database::Error::Error }); // commit or rollback transaction and return user_id on success @@ -100,36 +101,36 @@ impl Database for MysqlDatabase { } } - async fn get_user_from_id(&self, user_id: i64) -> Result { + async fn get_user_from_id(&self, user_id: i64) -> Result { query_as::<_, User>("SELECT * FROM torrust_users WHERE user_id = ?") .bind(user_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } - async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { + async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { query_as::<_, UserAuthentication>("SELECT * FROM torrust_user_authentication WHERE user_id = ?") .bind(user_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } - async fn get_user_profile_from_username(&self, username: &str) -> Result { + 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 = ?"#) .bind(username) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } - async fn get_user_compact_from_id(&self, user_id: i64) -> Result { + 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) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } async fn get_user_tracker_key(&self, user_id: i64) -> Option { @@ -147,15 +148,15 @@ impl Database for MysqlDatabase { .ok() } - async fn count_users(&self) -> Result { + async fn count_users(&self) -> Result { query_as("SELECT COUNT(*) FROM torrust_users") .fetch_one(&self.pool) .await .map(|(v,)| v) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), DatabaseError> { + async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), database::Error> { // date needs to be in ISO 8601 format let date_expiry_string = date_expiry.format("%Y-%m-%d %H:%M:%S").to_string(); @@ -166,40 +167,40 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn grant_admin_role(&self, user_id: i64) -> Result<(), DatabaseError> { + async fn grant_admin_role(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_users SET administrator = TRUE WHERE user_id = ?") .bind(user_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::UserNotFound) + Err(database::Error::UserNotFound) } }) } - async fn verify_email(&self, user_id: i64) -> Result<(), DatabaseError> { + async fn verify_email(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_user_profiles SET email_verified = TRUE WHERE user_id = ?") .bind(user_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::UserNotFound) + Err(database::Error::UserNotFound) } }) } - async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), DatabaseError> { + async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), database::Error> { let key = tracker_key.key.clone(); query("INSERT INTO torrust_tracker_keys (user_id, tracker_key, date_expiry) VALUES (?, ?, ?)") @@ -209,25 +210,25 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn delete_user(&self, user_id: i64) -> Result<(), DatabaseError> { + async fn delete_user(&self, user_id: i64) -> Result<(), database::Error> { query("DELETE FROM torrust_users WHERE user_id = ?") .bind(user_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::UserNotFound) + Err(database::Error::UserNotFound) } }) } - async fn insert_category_and_get_id(&self, category_name: &str) -> Result { + async fn insert_category_and_get_id(&self, category_name: &str) -> Result { query("INSERT INTO torrust_categories (name) VALUES (?)") .bind(category_name) .execute(&self.pool) @@ -236,49 +237,49 @@ impl Database for MysqlDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("UNIQUE") { - DatabaseError::CategoryAlreadyExists + database::Error::CategoryAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error, + _ => database::Error::Error, }) } - async fn get_category_from_id(&self, category_id: i64) -> Result { + async fn get_category_from_id(&self, category_id: i64) -> Result { query_as::<_, Category>("SELECT category_id, name, (SELECT COUNT(*) FROM torrust_torrents WHERE torrust_torrents.category_id = torrust_categories.category_id) AS num_torrents FROM torrust_categories WHERE category_id = ?") .bind(category_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::CategoryNotFound) + .map_err(|_| database::Error::CategoryNotFound) } - async fn get_category_from_name(&self, category_name: &str) -> Result { + async fn get_category_from_name(&self, category_name: &str) -> Result { query_as::<_, Category>("SELECT category_id, name, (SELECT COUNT(*) FROM torrust_torrents WHERE torrust_torrents.category_id = torrust_categories.category_id) AS num_torrents FROM torrust_categories WHERE name = ?") .bind(category_name) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::CategoryNotFound) + .map_err(|_| database::Error::CategoryNotFound) } - async fn get_categories(&self) -> Result, DatabaseError> { + async fn get_categories(&self) -> Result, database::Error> { query_as::<_, Category>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn delete_category(&self, category_name: &str) -> Result<(), DatabaseError> { + async fn delete_category(&self, category_name: &str) -> Result<(), database::Error> { query("DELETE FROM torrust_categories WHERE name = ?") .bind(category_name) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::CategoryNotFound) + Err(database::Error::CategoryNotFound) } }) } @@ -291,7 +292,7 @@ impl Database for MysqlDatabase { sort: &Sorting, offset: u64, limit: u8, - ) -> Result { + ) -> Result { let title = match search { None => "%".to_string(), Some(v) => format!("%{}%", v), @@ -351,12 +352,12 @@ impl Database for MysqlDatabase { let count_query = format!("SELECT COUNT(*) as count FROM ({}) AS count_table", query_string); - let count_result: Result = query_as(&count_query) + let count_result: Result = query_as(&count_query) .bind(title.clone()) .fetch_one(&self.pool) .await .map(|(v,)| v) - .map_err(|_| DatabaseError::Error); + .map_err(|_| database::Error::Error); let count = count_result?; @@ -368,7 +369,7 @@ impl Database for MysqlDatabase { .bind(limit) .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; Ok(TorrentsResponse { total: count as u32, @@ -383,20 +384,20 @@ impl Database for MysqlDatabase { category_id: i64, title: &str, description: &str, - ) -> Result { + ) -> Result { let info_hash = torrent.info_hash(); // open pool connection - let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; // start db transaction - let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { (bytes_to_hex(pieces.as_ref()), false) } else { - let root_hash = torrent.info.root_hash.as_ref().ok_or(DatabaseError::Error)?; + let root_hash = torrent.info.root_hash.as_ref().ok_or(database::Error::Error)?; (root_hash.to_string(), true) }; @@ -419,14 +420,14 @@ impl Database for MysqlDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("info_hash") { - DatabaseError::TorrentAlreadyExists + database::Error::TorrentAlreadyExists } else if err.message().contains("title") { - DatabaseError::TorrentTitleAlreadyExists + database::Error::TorrentTitleAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error + _ => database::Error::Error })?; let insert_torrent_files_result = if let Some(length) = torrent.info.length { @@ -437,7 +438,7 @@ impl Database for MysqlDatabase { .execute(&mut tx) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } else { let files = torrent.info.files.as_ref().unwrap(); @@ -451,7 +452,7 @@ impl Database for MysqlDatabase { .bind(path) .execute(&mut tx) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; } Ok(()) @@ -463,7 +464,8 @@ impl Database for MysqlDatabase { return Err(e); } - let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(announce_urls) = &torrent.announce_list { + let insert_torrent_announce_urls_result: Result<(), database::Error> = if let Some(announce_urls) = &torrent.announce_list + { // flatten the nested vec (this will however remove the) let announce_urls = announce_urls.iter().flatten().collect::>(); @@ -474,7 +476,7 @@ impl Database for MysqlDatabase { .execute(&mut tx) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; } Ok(()) @@ -487,7 +489,7 @@ impl Database for MysqlDatabase { .execute(&mut tx) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) }; // rollback transaction on error @@ -506,14 +508,14 @@ impl Database for MysqlDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("info_hash") { - DatabaseError::TorrentAlreadyExists + database::Error::TorrentAlreadyExists } else if err.message().contains("title") { - DatabaseError::TorrentTitleAlreadyExists + database::Error::TorrentTitleAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error, + _ => database::Error::Error, }); // commit or rollback transaction and return user_id on success @@ -529,33 +531,33 @@ impl Database for MysqlDatabase { } } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) .bind(torrent_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { + async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, database::Error> { let db_torrent_files = query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") .bind(torrent_id) .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound)?; + .map_err(|_| database::Error::TorrentNotFound)?; let torrent_files: Vec = db_torrent_files .into_iter() @@ -574,16 +576,16 @@ impl Database for MysqlDatabase { Ok(torrent_files) } - async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, DatabaseError> { + async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, database::Error> { query_as::<_, DbTorrentAnnounceUrl>("SELECT tracker_url FROM torrust_torrent_announce_urls WHERE torrent_id = ?") .bind(torrent_id) .fetch_all(&self.pool) .await .map(|v| v.iter().map(|a| vec![a.tracker_url.to_string()]).collect()) - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { + async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -598,10 +600,10 @@ impl Database for MysqlDatabase { .bind(torrent_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -616,17 +618,17 @@ impl Database for MysqlDatabase { .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_all_torrents_compact(&self) -> Result, DatabaseError> { + async fn get_all_torrents_compact(&self) -> Result, database::Error> { query_as::<_, TorrentCompact>("SELECT torrent_id, info_hash FROM torrust_torrents") .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), DatabaseError> { + async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), database::Error> { query("UPDATE torrust_torrent_info SET title = ? WHERE torrent_id = ?") .bind(title) .bind(torrent_id) @@ -635,34 +637,34 @@ impl Database for MysqlDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("UNIQUE") { - DatabaseError::TorrentTitleAlreadyExists + database::Error::TorrentTitleAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error, + _ => database::Error::Error, }) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::TorrentNotFound) + Err(database::Error::TorrentNotFound) } }) } - async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), DatabaseError> { + async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), database::Error> { query("UPDATE torrust_torrent_info SET description = ? WHERE torrent_id = ?") .bind(description) .bind(torrent_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::TorrentNotFound) + Err(database::Error::TorrentNotFound) } }) } @@ -673,7 +675,7 @@ impl Database for MysqlDatabase { tracker_url: &str, seeders: i64, leechers: i64, - ) -> Result<(), DatabaseError> { + ) -> Result<(), database::Error> { query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers) VALUES (?, ?, ?, ?)") .bind(torrent_id) .bind(tracker_url) @@ -682,74 +684,74 @@ impl Database for MysqlDatabase { .execute(&self.pool) .await .map(|_| ()) - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn delete_torrent(&self, torrent_id: i64) -> Result<(), DatabaseError> { + async fn delete_torrent(&self, torrent_id: i64) -> Result<(), database::Error> { query("DELETE FROM torrust_torrents WHERE torrent_id = ?") .bind(torrent_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::TorrentNotFound) + Err(database::Error::TorrentNotFound) } }) } - async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { + async fn delete_all_database_rows(&self) -> Result<(), database::Error> { query("DELETE FROM torrust_categories;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_torrents;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_tracker_keys;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_users;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_authentication;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_bans;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_invitations;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_profiles;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_torrents;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_public_keys;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; Ok(()) } diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 936dd8d5..6cbc02a2 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -3,7 +3,8 @@ use chrono::NaiveDateTime; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query, query_as, Acquire, SqlitePool}; -use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; +use crate::databases::database; +use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; @@ -35,16 +36,16 @@ impl SqliteDatabase { #[async_trait] impl Database for SqliteDatabase { - fn get_database_driver(&self) -> DatabaseDriver { - DatabaseDriver::Sqlite3 + fn get_database_driver(&self) -> Driver { + Driver::Sqlite3 } - async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { + async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { // open pool connection - let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; // start db transaction - let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; // create the user account and get the user id let user_id = @@ -52,7 +53,7 @@ impl Database for SqliteDatabase { .execute(&mut tx) .await .map(|v| v.last_insert_rowid()) - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; // add password hash for account let insert_user_auth_result = query("INSERT INTO torrust_user_authentication (user_id, password_hash) VALUES (?, ?)") @@ -60,7 +61,7 @@ impl Database for SqliteDatabase { .bind(password_hash) .execute(&mut tx) .await - .map_err(|_| DatabaseError::Error); + .map_err(|_| database::Error::Error); // rollback transaction on error if let Err(e) = insert_user_auth_result { @@ -78,14 +79,14 @@ impl Database for SqliteDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("username") { - DatabaseError::UsernameTaken + database::Error::UsernameTaken } else if err.message().contains("email") { - DatabaseError::EmailTaken + database::Error::EmailTaken } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error + _ => database::Error::Error }); // commit or rollback transaction and return user_id on success @@ -101,36 +102,36 @@ impl Database for SqliteDatabase { } } - async fn get_user_from_id(&self, user_id: i64) -> Result { + async fn get_user_from_id(&self, user_id: i64) -> Result { query_as::<_, User>("SELECT * FROM torrust_users WHERE user_id = ?") .bind(user_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } - async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { + async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { query_as::<_, UserAuthentication>("SELECT * FROM torrust_user_authentication WHERE user_id = ?") .bind(user_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } - async fn get_user_profile_from_username(&self, username: &str) -> Result { + async fn get_user_profile_from_username(&self, username: &str) -> Result { query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE username = ?") .bind(username) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } - async fn get_user_compact_from_id(&self, user_id: i64) -> Result { + 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) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::UserNotFound) + .map_err(|_| database::Error::UserNotFound) } async fn get_user_tracker_key(&self, user_id: i64) -> Option { @@ -148,15 +149,15 @@ impl Database for SqliteDatabase { .ok() } - async fn count_users(&self) -> Result { + async fn count_users(&self) -> Result { query_as("SELECT COUNT(*) FROM torrust_users") .fetch_one(&self.pool) .await .map(|(v,)| v) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), DatabaseError> { + async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), database::Error> { // date needs to be in ISO 8601 format let date_expiry_string = date_expiry.format("%Y-%m-%d %H:%M:%S").to_string(); @@ -167,34 +168,34 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn grant_admin_role(&self, user_id: i64) -> Result<(), DatabaseError> { + async fn grant_admin_role(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_users SET administrator = TRUE WHERE user_id = ?") .bind(user_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::UserNotFound) + Err(database::Error::UserNotFound) } }) } - async fn verify_email(&self, user_id: i64) -> Result<(), DatabaseError> { + async fn verify_email(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_user_profiles SET email_verified = TRUE WHERE user_id = ?") .bind(user_id) .execute(&self.pool) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), DatabaseError> { + async fn add_tracker_key(&self, user_id: i64, tracker_key: &TrackerKey) -> Result<(), database::Error> { let key = tracker_key.key.clone(); query("INSERT INTO torrust_tracker_keys (user_id, tracker_key, date_expiry) VALUES ($1, $2, $3)") @@ -204,25 +205,25 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn delete_user(&self, user_id: i64) -> Result<(), DatabaseError> { + async fn delete_user(&self, user_id: i64) -> Result<(), database::Error> { query("DELETE FROM torrust_users WHERE user_id = ?") .bind(user_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::UserNotFound) + Err(database::Error::UserNotFound) } }) } - async fn insert_category_and_get_id(&self, category_name: &str) -> Result { + async fn insert_category_and_get_id(&self, category_name: &str) -> Result { query("INSERT INTO torrust_categories (name) VALUES (?)") .bind(category_name) .execute(&self.pool) @@ -231,49 +232,49 @@ impl Database for SqliteDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("UNIQUE") { - DatabaseError::CategoryAlreadyExists + database::Error::CategoryAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error, + _ => database::Error::Error, }) } - async fn get_category_from_id(&self, category_id: i64) -> Result { + async fn get_category_from_id(&self, category_id: i64) -> Result { query_as::<_, Category>("SELECT category_id, name, (SELECT COUNT(*) FROM torrust_torrents WHERE torrust_torrents.category_id = torrust_categories.category_id) AS num_torrents FROM torrust_categories WHERE category_id = ?") .bind(category_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::CategoryNotFound) + .map_err(|_| database::Error::CategoryNotFound) } - async fn get_category_from_name(&self, category_name: &str) -> Result { + async fn get_category_from_name(&self, category_name: &str) -> Result { query_as::<_, Category>("SELECT category_id, name, (SELECT COUNT(*) FROM torrust_torrents WHERE torrust_torrents.category_id = torrust_categories.category_id) AS num_torrents FROM torrust_categories WHERE name = ?") .bind(category_name) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::CategoryNotFound) + .map_err(|_| database::Error::CategoryNotFound) } - async fn get_categories(&self) -> Result, DatabaseError> { + async fn get_categories(&self) -> Result, database::Error> { query_as::<_, Category>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn delete_category(&self, category_name: &str) -> Result<(), DatabaseError> { + async fn delete_category(&self, category_name: &str) -> Result<(), database::Error> { query("DELETE FROM torrust_categories WHERE name = ?") .bind(category_name) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::CategoryNotFound) + Err(database::Error::CategoryNotFound) } }) } @@ -286,7 +287,7 @@ impl Database for SqliteDatabase { sort: &Sorting, offset: u64, limit: u8, - ) -> Result { + ) -> Result { let title = match search { None => "%".to_string(), Some(v) => format!("%{}%", v), @@ -346,12 +347,12 @@ impl Database for SqliteDatabase { let count_query = format!("SELECT COUNT(*) as count FROM ({}) AS count_table", query_string); - let count_result: Result = query_as(&count_query) + let count_result: Result = query_as(&count_query) .bind(title.clone()) .fetch_one(&self.pool) .await .map(|(v,)| v) - .map_err(|_| DatabaseError::Error); + .map_err(|_| database::Error::Error); let count = count_result?; @@ -363,7 +364,7 @@ impl Database for SqliteDatabase { .bind(limit) .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; Ok(TorrentsResponse { total: count as u32, @@ -378,20 +379,20 @@ impl Database for SqliteDatabase { category_id: i64, title: &str, description: &str, - ) -> Result { + ) -> Result { let info_hash = torrent.info_hash(); // open pool connection - let mut conn = self.pool.acquire().await.map_err(|_| DatabaseError::Error)?; + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; // start db transaction - let mut tx = conn.begin().await.map_err(|_| DatabaseError::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { (bytes_to_hex(pieces.as_ref()), false) } else { - let root_hash = torrent.info.root_hash.as_ref().ok_or(DatabaseError::Error)?; + let root_hash = torrent.info.root_hash.as_ref().ok_or(database::Error::Error)?; (root_hash.to_string(), true) }; @@ -414,14 +415,14 @@ impl Database for SqliteDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("info_hash") { - DatabaseError::TorrentAlreadyExists + database::Error::TorrentAlreadyExists } else if err.message().contains("title") { - DatabaseError::TorrentTitleAlreadyExists + database::Error::TorrentTitleAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error + _ => database::Error::Error })?; let insert_torrent_files_result = if let Some(length) = torrent.info.length { @@ -432,7 +433,7 @@ impl Database for SqliteDatabase { .execute(&mut tx) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } else { let files = torrent.info.files.as_ref().unwrap(); @@ -446,7 +447,7 @@ impl Database for SqliteDatabase { .bind(path) .execute(&mut tx) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; } Ok(()) @@ -458,7 +459,8 @@ impl Database for SqliteDatabase { return Err(e); } - let insert_torrent_announce_urls_result: Result<(), DatabaseError> = if let Some(announce_urls) = &torrent.announce_list { + let insert_torrent_announce_urls_result: Result<(), database::Error> = if let Some(announce_urls) = &torrent.announce_list + { // flatten the nested vec (this will however remove the) let announce_urls = announce_urls.iter().flatten().collect::>(); @@ -469,7 +471,7 @@ impl Database for SqliteDatabase { .execute(&mut tx) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; } Ok(()) @@ -482,7 +484,7 @@ impl Database for SqliteDatabase { .execute(&mut tx) .await .map(|_| ()) - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) }; // rollback transaction on error @@ -501,14 +503,14 @@ impl Database for SqliteDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("info_hash") { - DatabaseError::TorrentAlreadyExists + database::Error::TorrentAlreadyExists } else if err.message().contains("title") { - DatabaseError::TorrentTitleAlreadyExists + database::Error::TorrentTitleAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error, + _ => database::Error::Error, }); // commit or rollback transaction and return user_id on success @@ -524,33 +526,33 @@ impl Database for SqliteDatabase { } } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) .bind(torrent_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { + async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, database::Error> { let db_torrent_files = query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") .bind(torrent_id) .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound)?; + .map_err(|_| database::Error::TorrentNotFound)?; let torrent_files: Vec = db_torrent_files .into_iter() @@ -569,16 +571,16 @@ impl Database for SqliteDatabase { Ok(torrent_files) } - async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, DatabaseError> { + async fn get_torrent_announce_urls_from_id(&self, torrent_id: i64) -> Result>, database::Error> { query_as::<_, DbTorrentAnnounceUrl>("SELECT tracker_url FROM torrust_torrent_announce_urls WHERE torrent_id = ?") .bind(torrent_id) .fetch_all(&self.pool) .await .map(|v| v.iter().map(|a| vec![a.tracker_url.to_string()]).collect()) - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { + async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -593,10 +595,10 @@ impl Database for SqliteDatabase { .bind(torrent_id) .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -611,17 +613,17 @@ impl Database for SqliteDatabase { .bind(infohash.to_string().to_uppercase()) // `info_hash` is stored as uppercase .fetch_one(&self.pool) .await - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn get_all_torrents_compact(&self) -> Result, DatabaseError> { + async fn get_all_torrents_compact(&self) -> Result, database::Error> { query_as::<_, TorrentCompact>("SELECT torrent_id, info_hash FROM torrust_torrents") .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), DatabaseError> { + async fn update_torrent_title(&self, torrent_id: i64, title: &str) -> Result<(), database::Error> { query("UPDATE torrust_torrent_info SET title = $1 WHERE torrent_id = $2") .bind(title) .bind(torrent_id) @@ -630,34 +632,34 @@ impl Database for SqliteDatabase { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("UNIQUE") { - DatabaseError::TorrentTitleAlreadyExists + database::Error::TorrentTitleAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error, + _ => database::Error::Error, }) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::TorrentNotFound) + Err(database::Error::TorrentNotFound) } }) } - async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), DatabaseError> { + async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), database::Error> { query("UPDATE torrust_torrent_info SET description = $1 WHERE torrent_id = $2") .bind(description) .bind(torrent_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::TorrentNotFound) + Err(database::Error::TorrentNotFound) } }) } @@ -668,7 +670,7 @@ impl Database for SqliteDatabase { tracker_url: &str, seeders: i64, leechers: i64, - ) -> Result<(), DatabaseError> { + ) -> Result<(), database::Error> { query("REPLACE INTO torrust_torrent_tracker_stats (torrent_id, tracker_url, seeders, leechers) VALUES ($1, $2, $3, $4)") .bind(torrent_id) .bind(tracker_url) @@ -677,74 +679,74 @@ impl Database for SqliteDatabase { .execute(&self.pool) .await .map(|_| ()) - .map_err(|_| DatabaseError::TorrentNotFound) + .map_err(|_| database::Error::TorrentNotFound) } - async fn delete_torrent(&self, torrent_id: i64) -> Result<(), DatabaseError> { + async fn delete_torrent(&self, torrent_id: i64) -> Result<(), database::Error> { query("DELETE FROM torrust_torrents WHERE torrent_id = ?") .bind(torrent_id) .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) .and_then(|v| { if v.rows_affected() > 0 { Ok(()) } else { - Err(DatabaseError::TorrentNotFound) + Err(database::Error::TorrentNotFound) } }) } - async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { + async fn delete_all_database_rows(&self) -> Result<(), database::Error> { query("DELETE FROM torrust_categories;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_torrents;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_tracker_keys;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_users;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_authentication;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_bans;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_invitations;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_profiles;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_torrents;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; query("DELETE FROM torrust_user_public_keys;") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error)?; + .map_err(|_| database::Error::Error)?; Ok(()) } diff --git a/src/errors.rs b/src/errors.rs index a4eb7943..84fe7c8a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,7 +6,7 @@ use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError}; use derive_more::{Display, Error}; use serde::{Deserialize, Serialize}; -use crate::databases::database::DatabaseError; +use crate::databases::database; pub type ServiceResult = Result; @@ -210,19 +210,19 @@ impl From for ServiceError { } } -impl From for ServiceError { - fn from(e: DatabaseError) -> Self { +impl From for ServiceError { + fn from(e: database::Error) -> Self { match e { - DatabaseError::Error => ServiceError::InternalServerError, - DatabaseError::UsernameTaken => ServiceError::UsernameTaken, - DatabaseError::EmailTaken => ServiceError::EmailTaken, - DatabaseError::UserNotFound => ServiceError::UserNotFound, - DatabaseError::CategoryAlreadyExists => ServiceError::CategoryExists, - DatabaseError::CategoryNotFound => ServiceError::InvalidCategory, - DatabaseError::TorrentNotFound => ServiceError::TorrentNotFound, - DatabaseError::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, - DatabaseError::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists, - DatabaseError::UnrecognizedDatabaseDriver => ServiceError::InternalServerError, + database::Error::Error => ServiceError::InternalServerError, + database::Error::UsernameTaken => ServiceError::UsernameTaken, + database::Error::EmailTaken => ServiceError::EmailTaken, + database::Error::UserNotFound => ServiceError::UserNotFound, + database::Error::CategoryAlreadyExists => ServiceError::CategoryExists, + database::Error::CategoryNotFound => ServiceError::InvalidCategory, + database::Error::TorrentNotFound => ServiceError::TorrentNotFound, + database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, + database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists, + database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError, } } } diff --git a/src/tracker/statistics_importer.rs b/src/tracker/statistics_importer.rs index 76c9d485..c5044c5c 100644 --- a/src/tracker/statistics_importer.rs +++ b/src/tracker/statistics_importer.rs @@ -4,7 +4,7 @@ use log::{error, info}; use super::service::{Service, TorrentInfo}; use crate::config::Configuration; -use crate::databases::database::{Database, DatabaseError}; +use crate::databases::database::{self, Database}; use crate::errors::ServiceError; pub struct StatisticsImporter { @@ -30,7 +30,7 @@ impl StatisticsImporter { /// # Errors /// /// Will return an error if the database query failed. - pub async fn import_all_torrents_statistics(&self) -> Result<(), DatabaseError> { + pub async fn import_all_torrents_statistics(&self) -> Result<(), database::Error> { info!("Importing torrents statistics from tracker ..."); let torrents = self.database.get_all_torrents_compact().await?; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 1f4987c6..8d58d5a4 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query_as, SqlitePool}; -use crate::databases::database::DatabaseError; +use crate::databases::database; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct CategoryRecordV1 { @@ -64,11 +64,11 @@ impl SqliteDatabaseV1_0_0 { Self { pool: db } } - pub async fn get_categories_order_by_id(&self) -> Result, DatabaseError> { + pub async fn get_categories_order_by_id(&self) -> Result, database::Error> { query_as::<_, CategoryRecordV1>("SELECT category_id, name FROM torrust_categories ORDER BY category_id ASC") .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } pub async fn get_users(&self) -> Result, sqlx::Error> { diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 9107356b..d182efaf 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -4,7 +4,7 @@ use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; use sqlx::{query, query_as, SqlitePool}; use super::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; -use crate::databases::database::DatabaseError; +use crate::databases::database; use crate::models::torrent_file::{TorrentFile, TorrentInfo}; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] @@ -79,21 +79,21 @@ impl SqliteDatabaseV2_0_0 { .expect("Could not run database migrations."); } - pub async fn reset_categories_sequence(&self) -> Result { + pub async fn reset_categories_sequence(&self) -> Result { query("DELETE FROM `sqlite_sequence` WHERE `name` = 'torrust_categories'") .execute(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - pub async fn get_categories(&self) -> Result, DatabaseError> { + pub async fn get_categories(&self) -> Result, database::Error> { query_as::<_, CategoryRecordV2>("SELECT tc.category_id, tc.name, COUNT(tt.category_id) as num_torrents FROM torrust_categories tc LEFT JOIN torrust_torrents tt on tc.category_id = tt.category_id GROUP BY tc.name") .fetch_all(&self.pool) .await - .map_err(|_| DatabaseError::Error) + .map_err(|_| database::Error::Error) } - pub async fn insert_category_and_get_id(&self, category_name: &str) -> Result { + pub async fn insert_category_and_get_id(&self, category_name: &str) -> Result { query("INSERT INTO torrust_categories (name) VALUES (?)") .bind(category_name) .execute(&self.pool) @@ -102,12 +102,12 @@ impl SqliteDatabaseV2_0_0 { .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("UNIQUE") { - DatabaseError::CategoryAlreadyExists + database::Error::CategoryAlreadyExists } else { - DatabaseError::Error + database::Error::Error } } - _ => DatabaseError::Error, + _ => database::Error::Error, }) } @@ -257,7 +257,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } - pub async fn delete_all_database_rows(&self) -> Result<(), DatabaseError> { + pub async fn delete_all_database_rows(&self) -> Result<(), database::Error> { query("DELETE FROM torrust_categories").execute(&self.pool).await.unwrap(); query("DELETE FROM torrust_torrents").execute(&self.pool).await.unwrap(); diff --git a/tests/databases/mod.rs b/tests/databases/mod.rs index c5a03519..07607c68 100644 --- a/tests/databases/mod.rs +++ b/tests/databases/mod.rs @@ -1,6 +1,7 @@ use std::future::Future; -use torrust_index_backend::databases::database::{connect_database, Database}; +use torrust_index_backend::databases::database; +use torrust_index_backend::databases::database::Database; mod mysql; mod sqlite; @@ -21,7 +22,7 @@ where // runs all tests pub async fn run_tests(db_path: &str) { - let db_res = connect_database(db_path).await; + let db_res = database::connect(db_path).await; assert!(db_res.is_ok()); diff --git a/tests/databases/tests.rs b/tests/databases/tests.rs index 834fdc47..f500164f 100644 --- a/tests/databases/tests.rs +++ b/tests/databases/tests.rs @@ -1,5 +1,6 @@ use serde_bytes::ByteBuf; -use torrust_index_backend::databases::database::{Database, DatabaseError}; +use torrust_index_backend::databases::database; +use torrust_index_backend::databases::database::Database; use torrust_index_backend::models::torrent::TorrentListing; use torrust_index_backend::models::torrent_file::{Torrent, TorrentInfo}; use torrust_index_backend::models::user::UserProfile; @@ -19,12 +20,12 @@ const TEST_TORRENT_FILE_SIZE: i64 = 128_000; const TEST_TORRENT_SEEDERS: i64 = 437; const TEST_TORRENT_LEECHERS: i64 = 1289; -async fn add_test_user(db: &Box) -> Result { +async fn add_test_user(db: &Box) -> Result { db.insert_user_and_get_id(TEST_USER_USERNAME, TEST_USER_EMAIL, TEST_USER_PASSWORD) .await } -async fn add_test_torrent_category(db: &Box) -> Result { +async fn add_test_torrent_category(db: &Box) -> Result { db.insert_category_and_get_id(TEST_CATEGORY_NAME).await } diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs index c5c9759f..6e191a9d 100644 --- a/tests/e2e/contexts/torrent/asserts.rs +++ b/tests/e2e/contexts/torrent/asserts.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use torrust_index_backend::databases::database::connect_database; +use torrust_index_backend::databases::database; use torrust_index_backend::models::torrent_file::Torrent; use torrust_index_backend::models::tracker_key::TrackerKey; @@ -41,7 +41,7 @@ pub async fn get_user_tracker_key(logged_in_user: &LoggedInUserData, env: &TestE // querying the database. let database = Arc::new( - connect_database(&env.database_connect_url().unwrap()) + database::connect(&env.database_connect_url().unwrap()) .await .expect("database connection to be established."), ); diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/contexts/user/steps.rs index 96627340..c58a5c59 100644 --- a/tests/e2e/contexts/user/steps.rs +++ b/tests/e2e/contexts/user/steps.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use torrust_index_backend::databases::database::connect_database; +use torrust_index_backend::databases::database; use crate::common::client::Client; use crate::common::contexts::user::fixtures::random_user_registration; @@ -12,7 +12,7 @@ pub async fn new_logged_in_admin(env: &TestEnv) -> LoggedInUserData { let user = new_logged_in_user(env).await; let database = Arc::new( - connect_database(&env.database_connect_url().unwrap()) + database::connect(&env.database_connect_url().unwrap()) .await .expect("Database error."), ); From 593ac6f6d41337a26832d047230d9bfe428c6c48 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 20:04:10 +0200 Subject: [PATCH 150/357] dev: fix clippy warnings for: src/databases/mysql.rs --- src/databases/database.rs | 4 ++-- src/databases/mysql.rs | 32 +++++++++++++++++++------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 778c3dfe..43e6c533 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; -use crate::databases::mysql::MysqlDatabase; +use crate::databases::mysql::Mysql; use crate::databases::sqlite::SqliteDatabase; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; @@ -75,7 +75,7 @@ pub async fn connect(db_path: &str) -> Result, Error> { Ok(Box::new(db)) } ['m', 'y', 's', 'q', 'l', ..] => { - let db = MysqlDatabase::new(db_path).await; + let db = Mysql::new(db_path).await; Ok(Box::new(db)) } _ => Err(Error::UnrecognizedDatabaseDriver), diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index d4bf4789..6babecb2 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -14,11 +14,11 @@ use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; use crate::utils::clock::current_time; use crate::utils::hex::bytes_to_hex; -pub struct MysqlDatabase { +pub struct Mysql { pub pool: MySqlPool, } -impl MysqlDatabase { +impl Mysql { pub async fn new(database_url: &str) -> Self { let db = MySqlPoolOptions::new() .connect(database_url) @@ -35,7 +35,7 @@ impl MysqlDatabase { } #[async_trait] -impl Database for MysqlDatabase { +impl Database for Mysql { fn get_database_driver(&self) -> Driver { Driver::Mysql } @@ -92,7 +92,7 @@ impl Database for MysqlDatabase { match insert_user_profile_result { Ok(_) => { let _ = tx.commit().await; - Ok(user_id as i64) + Ok(i64::overflowing_add_unsigned(0, user_id).0) } Err(e) => { let _ = tx.rollback().await; @@ -133,11 +133,16 @@ impl Database for MysqlDatabase { .map_err(|_| database::Error::UserNotFound) } + /// Gets User Tracker Key + /// + /// # Panics + /// + /// Will panic if the input time overflows the `u64` seconds overflows the `i64` type. + /// (this will naturally happen in 292.5 billion years) async fn get_user_tracker_key(&self, user_id: i64) -> Option { const HOUR_IN_SECONDS: i64 = 3600; - // casting current_time() to i64 will overflow in the year 2262 - let current_time_plus_hour = (current_time() as i64) + HOUR_IN_SECONDS; + let current_time_plus_hour = i64::try_from(current_time()).unwrap().saturating_add(HOUR_IN_SECONDS); // get tracker key that is valid for at least one hour from now query_as::<_, TrackerKey>("SELECT tracker_key AS 'key', date_expiry AS valid_until FROM torrust_tracker_keys WHERE user_id = ? AND date_expiry > ? ORDER BY date_expiry DESC") @@ -233,7 +238,7 @@ impl Database for MysqlDatabase { .bind(category_name) .execute(&self.pool) .await - .map(|v| v.last_insert_id() as i64) + .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("UNIQUE") { @@ -325,13 +330,13 @@ impl Database for MysqlDatabase { i += 1; } } - if !category_filters.is_empty() { + if category_filters.is_empty() { + String::new() + } else { format!( "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", category_filters ) - } else { - String::new() } } else { String::new() @@ -365,18 +370,19 @@ impl Database for MysqlDatabase { let res: Vec = sqlx::query_as::<_, TorrentListing>(&query_string) .bind(title) - .bind(offset as i64) + .bind(i64::saturating_add_unsigned(0, offset)) .bind(limit) .fetch_all(&self.pool) .await .map_err(|_| database::Error::Error)?; Ok(TorrentsResponse { - total: count as u32, + total: u32::try_from(count).expect("variable `count` is larger than u32"), results: res, }) } + #[allow(clippy::too_many_lines)] async fn insert_torrent_and_get_id( &self, torrent: &Torrent, @@ -416,7 +422,7 @@ impl Database for MysqlDatabase { .bind(root_hash) .execute(&self.pool) .await - .map(|v| v.last_insert_id() as i64) + .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) .map_err(|e| match e { sqlx::Error::Database(err) => { if err.message().contains("info_hash") { From 578b213e081af25cfb109b5c11f21e8ae6baa93a Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 22:04:45 +0200 Subject: [PATCH 151/357] dev: fix clippy warnings for: src/databases/sqlite.rs --- src/databases/database.rs | 4 ++-- src/databases/sqlite.rs | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 43e6c533..a090ec62 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -3,7 +3,7 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use crate::databases::mysql::Mysql; -use crate::databases::sqlite::SqliteDatabase; +use crate::databases::sqlite::Sqlite; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; @@ -71,7 +71,7 @@ pub enum Error { pub async fn connect(db_path: &str) -> Result, Error> { match &db_path.chars().collect::>() as &[char] { ['s', 'q', 'l', 'i', 't', 'e', ..] => { - let db = SqliteDatabase::new(db_path).await; + let db = Sqlite::new(db_path).await; Ok(Box::new(db)) } ['m', 'y', 's', 'q', 'l', ..] => { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 6cbc02a2..0410a670 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -14,11 +14,11 @@ use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; use crate::utils::clock::current_time; use crate::utils::hex::bytes_to_hex; -pub struct SqliteDatabase { +pub struct Sqlite { pub pool: SqlitePool, } -impl SqliteDatabase { +impl Sqlite { pub async fn new(database_url: &str) -> Self { let db = SqlitePoolOptions::new() .connect(database_url) @@ -35,7 +35,7 @@ impl SqliteDatabase { } #[async_trait] -impl Database for SqliteDatabase { +impl Database for Sqlite { fn get_database_driver(&self) -> Driver { Driver::Sqlite3 } @@ -138,7 +138,7 @@ impl Database for SqliteDatabase { const HOUR_IN_SECONDS: i64 = 3600; // casting current_time() to i64 will overflow in the year 2262 - let current_time_plus_hour = (current_time() as i64) + HOUR_IN_SECONDS; + let current_time_plus_hour = i64::try_from(current_time()).unwrap().saturating_add(HOUR_IN_SECONDS); // get tracker key that is valid for at least one hour from now query_as::<_, TrackerKey>("SELECT tracker_key AS key, date_expiry AS valid_until FROM torrust_tracker_keys WHERE user_id = $1 AND date_expiry > $2 ORDER BY date_expiry DESC") @@ -320,13 +320,13 @@ impl Database for SqliteDatabase { i += 1; } } - if !category_filters.is_empty() { + if category_filters.is_empty() { + String::new() + } else { format!( "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", category_filters ) - } else { - String::new() } } else { String::new() @@ -360,18 +360,19 @@ impl Database for SqliteDatabase { let res: Vec = sqlx::query_as::<_, TorrentListing>(&query_string) .bind(title) - .bind(offset as i64) + .bind(i64::saturating_add_unsigned(0, offset)) .bind(limit) .fetch_all(&self.pool) .await .map_err(|_| database::Error::Error)?; Ok(TorrentsResponse { - total: count as u32, + total: u32::try_from(count).expect("variable `count` is larger than u32"), results: res, }) } + #[allow(clippy::too_many_lines)] async fn insert_torrent_and_get_id( &self, torrent: &Torrent, From 7f79fa94a7c55a91266173006905c7d52ef04012 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 22:12:44 +0200 Subject: [PATCH 152/357] dev: fix clippy warnings for: src/errors.rs --- src/errors.rs | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index 84fe7c8a..12601e3c 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -131,6 +131,7 @@ pub struct ErrorToResponse { impl ResponseError for ServiceError { fn status_code(&self) -> StatusCode { + #[allow(clippy::match_same_arms)] match self { ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN, ServiceError::EmailInvalid => StatusCode::BAD_REQUEST, @@ -139,47 +140,34 @@ impl ResponseError for ServiceError { ServiceError::UsernameNotFound => StatusCode::NOT_FOUND, ServiceError::UserNotFound => StatusCode::NOT_FOUND, ServiceError::AccountNotFound => StatusCode::NOT_FOUND, - ServiceError::ProfanityError => StatusCode::BAD_REQUEST, ServiceError::BlacklistError => StatusCode::BAD_REQUEST, ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST, - ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST, ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST, ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST, - ServiceError::UsernameTaken => StatusCode::BAD_REQUEST, ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST, ServiceError::EmailTaken => StatusCode::BAD_REQUEST, ServiceError::EmailNotVerified => StatusCode::FORBIDDEN, - ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED, ServiceError::TokenExpired => StatusCode::UNAUTHORIZED, ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED, - ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST, - ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST, ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST, ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, - ServiceError::BadRequest => StatusCode::BAD_REQUEST, - ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, - ServiceError::Unauthorized => StatusCode::FORBIDDEN, - ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, - - ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, - ServiceError::CategoryExists => StatusCode::BAD_REQUEST, - - _ => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::EmailMissing => StatusCode::NOT_FOUND, + ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -212,6 +200,7 @@ impl From for ServiceError { impl From for ServiceError { fn from(e: database::Error) -> Self { + #[allow(clippy::match_same_arms)] match e { database::Error::Error => ServiceError::InternalServerError, database::Error::UsernameTaken => ServiceError::UsernameTaken, From 3e7a9173f92de404bd9e7415bfab62452d443124 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 22:14:12 +0200 Subject: [PATCH 153/357] dev: fix clippy warnings for: src/lib.rs --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 6db3f410..a2b7d173 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,7 +33,7 @@ where let mut acc = vec![]; for s in s.as_ref().split(',') { let item = s.trim().parse::().map_err(|_| ())?; - acc.push(item) + acc.push(item); } if acc.is_empty() { Ok(None) From b737f101fa5b2049896168a1c50648923bf22c03 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Tue, 25 Apr 2023 22:23:01 +0200 Subject: [PATCH 154/357] dev: fix clippy warnings for: src/mailer.rs --- src/app.rs | 5 ++--- src/common.rs | 6 +++--- src/mailer.rs | 18 +++++++++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index 701aa64c..aabd6138 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,10 +12,9 @@ use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; use crate::config::Configuration; use crate::databases::database; -use crate::mailer::MailerService; -use crate::routes; use crate::tracker::service::Service; use crate::tracker::statistics_importer::StatisticsImporter; +use crate::{mailer, routes}; pub struct Running { pub api_server: Server, @@ -48,7 +47,7 @@ pub async fn run(configuration: Configuration) -> Running { let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); - let mailer_service = Arc::new(MailerService::new(cfg.clone()).await); + let mailer_service = Arc::new(mailer::Service::new(cfg.clone()).await); let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); // Build app container diff --git a/src/common.rs b/src/common.rs index 25759f71..0cbb011b 100644 --- a/src/common.rs +++ b/src/common.rs @@ -4,7 +4,7 @@ use crate::auth::AuthorizationService; use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; -use crate::mailer::MailerService; +use crate::mailer; use crate::tracker::service::Service; use crate::tracker::statistics_importer::StatisticsImporter; @@ -18,7 +18,7 @@ pub struct AppData { pub auth: Arc, pub tracker_service: Arc, pub tracker_statistics_importer: Arc, - pub mailer: Arc, + pub mailer: Arc, pub image_cache_manager: Arc, } @@ -29,7 +29,7 @@ impl AppData { auth: Arc, tracker_service: Arc, tracker_statistics_importer: Arc, - mailer: Arc, + mailer: Arc, image_cache_manager: Arc, ) -> AppData { AppData { diff --git a/src/mailer.rs b/src/mailer.rs index 258106d2..f871b0df 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -11,7 +11,7 @@ use crate::config::Configuration; use crate::errors::ServiceError; use crate::utils::clock::current_time; -pub struct MailerService { +pub struct Service { cfg: Arc, mailer: Arc, } @@ -30,8 +30,8 @@ struct VerifyTemplate { verification_url: String, } -impl MailerService { - pub async fn new(cfg: Arc) -> MailerService { +impl Service { + pub async fn new(cfg: Arc) -> Service { let mailer = Arc::new(Self::get_mailer(&cfg).await); Self { cfg, mailer } @@ -57,6 +57,11 @@ impl MailerService { } } + /// Send Verification Email + /// + /// # Errors + /// + /// This function will return an error if unable to send an email. pub async fn send_verification_mail( &self, to: &str, @@ -96,10 +101,13 @@ impl MailerService { .singlepart( SinglePart::builder() .header(lettre::message::header::ContentType::TEXT_HTML) - .body(ctx.render_once().unwrap()), + .body( + ctx.render_once() + .expect("value `ctx` must have some internal error passed into it"), + ), ), ) - .unwrap(); + .expect("the `multipart` builder had an error"); match self.mailer.send(mail).await { Ok(_res) => Ok(()), From eb3dd1182ff991d516a5127df4c8ba7a7df08df7 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 12:46:41 +0200 Subject: [PATCH 155/357] dev: fix clippy warnings for: src/models/response.rs --- src/models/response.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/models/response.rs b/src/models/response.rs index 58ec31fb..8340c4f0 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -8,16 +8,19 @@ pub enum OkResponses { TokenResponse(TokenResponse), } +#[allow(clippy::module_name_repetitions)] #[derive(Serialize, Deserialize, Debug)] pub struct OkResponse { pub data: T, } +#[allow(clippy::module_name_repetitions)] #[derive(Serialize, Deserialize, Debug)] pub struct ErrorResponse { pub errors: Vec, } +#[allow(clippy::module_name_repetitions)] #[derive(Serialize, Deserialize, Debug)] pub struct TokenResponse { pub token: String, @@ -25,11 +28,13 @@ pub struct TokenResponse { pub admin: bool, } +#[allow(clippy::module_name_repetitions)] #[derive(Serialize, Deserialize, Debug)] pub struct NewTorrentResponse { pub torrent_id: i64, } +#[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct TorrentResponse { pub torrent_id: i64, @@ -72,6 +77,7 @@ impl TorrentResponse { } } +#[allow(clippy::module_name_repetitions)] #[derive(Serialize, Deserialize, Debug, sqlx::FromRow)] pub struct TorrentsResponse { pub total: u32, From 269cd289fb1c0267a4cffcb3a368ed1f1efdf95b Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 18:25:15 +0200 Subject: [PATCH 156/357] dev: fix clippy warnings for: src/models/torrent_file.rs --- src/models/torrent_file.rs | 53 ++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index befc6161..c6565158 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -41,7 +41,7 @@ pub struct TorrentInfo { impl TorrentInfo { /// torrent file can only hold a pieces key or a root hash key: - /// http://www.bittorrent.org/beps/bep_0030.html + /// [BEP 39](http://www.bittorrent.org/beps/bep_0030.html) #[must_use] pub fn get_pieces_as_string(&self) -> String { match &self.pieces { @@ -54,7 +54,9 @@ impl TorrentInfo { pub fn get_root_hash_as_i64(&self) -> i64 { match &self.root_hash { None => 0i64, - Some(root_hash) => root_hash.parse::().unwrap(), + Some(root_hash) => root_hash + .parse::() + .expect("variable `root_hash` cannot be converted into a `i64`"), } } @@ -100,13 +102,7 @@ impl Torrent { torrent_files: Vec, torrent_announce_urls: Vec>, ) -> Self { - let private = if let Some(private_i64) = torrent_info.private { - // must fit in a byte - let private = if (0..256).contains(&private_i64) { private_i64 } else { 0 }; - Some(private as u8) - } else { - None - }; + let private = u8::try_from(torrent_info.private.unwrap_or(0)).ok(); // the info part of the torrent file let mut info = TorrentInfo { @@ -125,20 +121,27 @@ impl Torrent { if torrent_info.root_hash > 0 { info.root_hash = Some(torrent_info.pieces); } else { - let pieces = hex_to_bytes(&torrent_info.pieces).unwrap(); + let pieces = hex_to_bytes(&torrent_info.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); info.pieces = Some(ByteBuf::from(pieces)); } // either set the single file or the multiple files information if torrent_files.len() == 1 { - // can safely unwrap because we know there is 1 element - let torrent_file = torrent_files.first().unwrap(); + let torrent_file = torrent_files + .first() + .expect("vector `torrent_files` should have at least one element"); info.md5sum = torrent_file.md5sum.clone(); info.length = Some(torrent_file.length); - let path = if torrent_file.path.first().as_ref().unwrap().is_empty() { + let path = if torrent_file + .path + .first() + .as_ref() + .expect("the vector for the `path` should have at least one element") + .is_empty() + { None } else { Some(torrent_file.path.clone()) @@ -177,7 +180,7 @@ impl Torrent { #[must_use] pub fn calculate_info_hash_as_bytes(&self) -> [u8; 20] { - let info_bencoded = ser::to_bytes(&self.info).unwrap(); + let info_bencoded = ser::to_bytes(&self.info).expect("variable `info` was not able to be serialized."); let mut hasher = Sha1::new(); hasher.update(info_bencoded); let sum_hex = hasher.finalize(); @@ -193,10 +196,9 @@ impl Torrent { #[must_use] pub fn file_size(&self) -> i64 { - if self.info.length.is_some() { - self.info.length.unwrap() - } else { - match &self.info.files { + match self.info.length { + Some(length) => length, + None => match &self.info.files { None => 0, Some(files) => { let mut file_size = 0; @@ -205,22 +207,16 @@ impl Torrent { } file_size } - } + }, } } #[must_use] pub fn announce_urls(&self) -> Vec { - if self.announce_list.is_none() { - return vec![self.announce.clone().unwrap()]; + match &self.announce_list { + Some(list) => list.clone().into_iter().flatten().collect::>(), + None => vec![self.announce.clone().expect("variable `announce` should not be None")], } - - self.announce_list - .clone() - .unwrap() - .into_iter() - .flatten() - .collect::>() } #[must_use] @@ -234,6 +230,7 @@ impl Torrent { } } +#[allow(clippy::module_name_repetitions)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct DbTorrentFile { pub path: Option, From a0947d07429206a372ef18d044f26f96ffe85114 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 18:26:37 +0200 Subject: [PATCH 157/357] dev: fix clippy warnings for: src/models/torrent.rs --- src/models/torrent.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 9063c7f3..75e0d805 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::models::torrent_file::Torrent; use crate::routes::torrent::CreateTorrent; +#[allow(clippy::module_name_repetitions)] #[allow(dead_code)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] pub struct TorrentListing { @@ -18,6 +19,7 @@ pub struct TorrentListing { pub leechers: i64, } +#[allow(clippy::module_name_repetitions)] #[derive(Debug)] pub struct TorrentRequest { pub fields: CreateTorrent, From f1b36638a18087ad892c3e3f8f3620495a5e7531 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 18:28:53 +0200 Subject: [PATCH 158/357] dev: fix clippy warnings for: src/models/user.rs --- src/models/user.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/models/user.rs b/src/models/user.rs index 9a500d4d..f808c87a 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -8,12 +8,14 @@ pub struct User { pub administrator: bool, } +#[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserAuthentication { pub user_id: i64, pub password_hash: String, } +#[allow(clippy::module_name_repetitions)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserProfile { pub user_id: i64, @@ -24,6 +26,7 @@ pub struct UserProfile { pub avatar: String, } +#[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserCompact { pub user_id: i64, @@ -31,6 +34,7 @@ pub struct UserCompact { pub administrator: bool, } +#[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserFull { pub user_id: i64, @@ -44,6 +48,7 @@ pub struct UserFull { pub avatar: String, } +#[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserClaims { pub user: UserCompact, From 21493b0ab59e5654b2daa79999161f9582901a96 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 18:44:49 +0200 Subject: [PATCH 159/357] dev: fix clippy warnings for: src/routes/about.rs --- src/routes/about.rs | 20 ++++++++++++++++---- src/routes/root.rs | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/routes/about.rs b/src/routes/about.rs index 2a632c85..5fc1ade7 100644 --- a/src/routes/about.rs +++ b/src/routes/about.rs @@ -6,8 +6,8 @@ use crate::errors::ServiceResult; pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/about") - .service(web::resource("").route(web::get().to(get_about))) - .service(web::resource("/license").route(web::get().to(get_license))), + .service(web::resource("").route(web::get().to(get))) + .service(web::resource("/license").route(web::get().to(license))), ); } @@ -29,7 +29,13 @@ const ABOUT: &str = r#" "#; -pub async fn get_about() -> ServiceResult { +/// Get About Section HTML +/// +/// # Errors +/// +/// This function will not return an error. +#[allow(clippy::unused_async)] +pub async fn get() -> ServiceResult { Ok(HttpResponse::build(StatusCode::OK) .content_type("text/html; charset=utf-8") .body(ABOUT)) @@ -63,7 +69,13 @@ const LICENSE: &str = r#" "#; -pub async fn get_license() -> ServiceResult { +/// Get the License in HTML +/// +/// # Errors +/// +/// This function will not return an error. +#[allow(clippy::unused_async)] +pub async fn license() -> ServiceResult { Ok(HttpResponse::build(StatusCode::OK) .content_type("text/html; charset=utf-8") .body(LICENSE)) diff --git a/src/routes/root.rs b/src/routes/root.rs index 69f11fd6..a11526dd 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -3,5 +3,5 @@ use actix_web::web; use crate::routes::about; pub fn init_routes(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("/").service(web::resource("").route(web::get().to(about::get_about)))); + cfg.service(web::scope("/").service(web::resource("").route(web::get().to(about::get)))); } From fd75fd483f5a3bb8949a450f49d2eb30e2962cca Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 18:50:27 +0200 Subject: [PATCH 160/357] dev: fix clippy warnings for: src/routes/category.rs --- src/routes/category.rs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/routes/category.rs b/src/routes/category.rs index 823c267e..855dfb38 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -9,14 +9,19 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/category").service( web::resource("") - .route(web::get().to(get_categories)) - .route(web::post().to(add_category)) - .route(web::delete().to(delete_category)), + .route(web::get().to(get)) + .route(web::post().to(add)) + .route(web::delete().to(delete)), ), ); } -pub async fn get_categories(app_data: WebAppData) -> ServiceResult { +/// Gets the Categories +/// +/// # Errors +/// +/// This function will return an error if there is a database error. +pub async fn get(app_data: WebAppData) -> ServiceResult { let categories = app_data.database.get_categories().await?; Ok(HttpResponse::Ok().json(OkResponse { data: categories })) @@ -28,7 +33,13 @@ pub struct Category { pub icon: Option, } -pub async fn add_category(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { +/// Adds a New Category +/// +/// # Errors +/// +/// This function will return an error if unable to get user. +/// This function will return an error if unable to insert into the database the new category. +pub async fn add(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { // check for user let user = app_data.auth.get_user_compact_from_request(&req).await?; @@ -44,11 +55,13 @@ pub async fn add_category(req: HttpRequest, payload: web::Json, app_da })) } -pub async fn delete_category( - req: HttpRequest, - payload: web::Json, - app_data: WebAppData, -) -> ServiceResult { +/// Deletes a Category +/// +/// # Errors +/// +/// This function will return an error if unable to get user. +/// This function will return an error if unable to delete the category from the database. +pub async fn delete(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { // code-review: why do we need to send the whole category object to delete it? // And we should use the ID instead of the name, because the name could change // or we could add support for multiple languages. From 7277e4ede463ea606cabce1f5e7ddc02a40fb54c Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 18:53:34 +0200 Subject: [PATCH 161/357] dev: fix clippy warnings for: src/routes/mod.rs --- src/app.rs | 2 +- src/routes/about.rs | 2 +- src/routes/category.rs | 2 +- src/routes/mod.rs | 16 ++++++++-------- src/routes/proxy.rs | 2 +- src/routes/root.rs | 2 +- src/routes/settings.rs | 2 +- src/routes/torrent.rs | 2 +- src/routes/user.rs | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index aabd6138..3bc00b58 100644 --- a/src/app.rs +++ b/src/app.rs @@ -91,7 +91,7 @@ pub async fn run(configuration: Configuration) -> Running { .wrap(Cors::permissive()) .app_data(web::Data::new(app_data.clone())) .wrap(middleware::Logger::default()) - .configure(routes::init_routes) + .configure(routes::init) }) .bind((ip, net_port)) .expect("can't bind server to socket address"); diff --git a/src/routes/about.rs b/src/routes/about.rs index 5fc1ade7..c5b81d2d 100644 --- a/src/routes/about.rs +++ b/src/routes/about.rs @@ -3,7 +3,7 @@ use actix_web::{web, HttpResponse, Responder}; use crate::errors::ServiceResult; -pub fn init_routes(cfg: &mut web::ServiceConfig) { +pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/about") .service(web::resource("").route(web::get().to(get))) diff --git a/src/routes/category.rs b/src/routes/category.rs index 855dfb38..865d233d 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -5,7 +5,7 @@ use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::OkResponse; -pub fn init_routes(cfg: &mut web::ServiceConfig) { +pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/category").service( web::resource("") diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 946e776f..ce833698 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,12 +8,12 @@ pub mod settings; pub mod torrent; pub mod user; -pub fn init_routes(cfg: &mut web::ServiceConfig) { - user::init_routes(cfg); - torrent::init_routes(cfg); - category::init_routes(cfg); - settings::init_routes(cfg); - about::init_routes(cfg); - proxy::init_routes(cfg); - root::init_routes(cfg); +pub fn init(cfg: &mut web::ServiceConfig) { + user::init(cfg); + torrent::init(cfg); + category::init(cfg); + settings::init(cfg); + about::init(cfg); + proxy::init(cfg); + root::init(cfg); } diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs index 443900df..0863a08c 100644 --- a/src/routes/proxy.rs +++ b/src/routes/proxy.rs @@ -26,7 +26,7 @@ const ERROR_IMAGE_TOO_BIG_TEXT: &str = "Image is too big."; const ERROR_IMAGE_USER_QUOTA_MET_TEXT: &str = "Image proxy quota met."; const ERROR_IMAGE_UNAUTHENTICATED_TEXT: &str = "Sign in to see image."; -pub fn init_routes(cfg: &mut web::ServiceConfig) { +pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(web::scope("/proxy").service(web::resource("/image/{url}").route(web::get().to(get_proxy_image)))); load_error_images(); diff --git a/src/routes/root.rs b/src/routes/root.rs index a11526dd..ffeb1ed4 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -2,6 +2,6 @@ use actix_web::web; use crate::routes::about; -pub fn init_routes(cfg: &mut web::ServiceConfig) { +pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(web::scope("/").service(web::resource("").route(web::get().to(about::get)))); } diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 65dd0716..9c19e503 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -5,7 +5,7 @@ use crate::config; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::OkResponse; -pub fn init_routes(cfg: &mut web::ServiceConfig) { +pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/settings") .service( diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index ffba1724..32ad44df 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -17,7 +17,7 @@ use crate::models::torrent::TorrentRequest; use crate::utils::parse_torrent; use crate::AsCSV; -pub fn init_routes(cfg: &mut web::ServiceConfig) { +pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/torrent") .service(web::resource("/upload").route(web::post().to(upload_torrent))) diff --git a/src/routes/user.rs b/src/routes/user.rs index 7917e778..7b195030 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -16,7 +16,7 @@ use crate::models::user::UserAuthentication; use crate::utils::clock::current_time; use crate::utils::regex::validate_email_address; -pub fn init_routes(cfg: &mut web::ServiceConfig) { +pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/user") .service(web::resource("/register").route(web::post().to(register))) From c20608f86f3c4a962ac9f733fd844543e8b62b03 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 18:58:49 +0200 Subject: [PATCH 162/357] dev: fix clippy warnings for: src/routes/settings.rs --- src/routes/settings.rs | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 9c19e503..a5c34e04 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -8,17 +8,18 @@ use crate::models::response::OkResponse; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/settings") - .service( - web::resource("") - .route(web::get().to(get_settings)) - .route(web::post().to(update_settings_handler)), - ) - .service(web::resource("/name").route(web::get().to(get_site_name))) - .service(web::resource("/public").route(web::get().to(get_public_settings))), + .service(web::resource("").route(web::get().to(get)).route(web::post().to(update))) + .service(web::resource("/name").route(web::get().to(site_name))) + .service(web::resource("/public").route(web::get().to(get_public))), ); } -pub async fn get_settings(req: HttpRequest, app_data: WebAppData) -> ServiceResult { +/// Get Settings +/// +/// # Errors +/// +/// This function will return an error if unable to get user from database. +pub async fn get(req: HttpRequest, app_data: WebAppData) -> ServiceResult { // check for user let user = app_data.auth.get_user_compact_from_request(&req).await?; @@ -32,13 +33,23 @@ pub async fn get_settings(req: HttpRequest, app_data: WebAppData) -> ServiceResu Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) } -pub async fn get_public_settings(app_data: WebAppData) -> ServiceResult { +/// Get Public Settings +/// +/// # Errors +/// +/// This function should not return an error. +pub async fn get_public(app_data: WebAppData) -> ServiceResult { let public_settings = app_data.cfg.get_public().await; Ok(HttpResponse::Ok().json(OkResponse { data: public_settings })) } -pub async fn get_site_name(app_data: WebAppData) -> ServiceResult { +/// Get Name of Website +/// +/// # Errors +/// +/// This function should not return an error. +pub async fn site_name(app_data: WebAppData) -> ServiceResult { let settings = app_data.cfg.settings.read().await; Ok(HttpResponse::Ok().json(OkResponse { @@ -55,7 +66,7 @@ pub async fn get_site_name(app_data: WebAppData) -> ServiceResult, app_data: WebAppData, From 33c321877e6a511d172b2e67941f527d1487384e Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 26 Apr 2023 20:50:32 +0200 Subject: [PATCH 163/357] dev: fix clippy warnings for: src/routes/torrent.rs --- src/models/torrent.rs | 4 +- src/routes/torrent.rs | 85 +++++++++++++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 75e0d805..2ecbf984 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::models::torrent_file::Torrent; -use crate::routes::torrent::CreateTorrent; +use crate::routes::torrent::Create; #[allow(clippy::module_name_repetitions)] #[allow(dead_code)] @@ -22,6 +22,6 @@ pub struct TorrentListing { #[allow(clippy::module_name_repetitions)] #[derive(Debug)] pub struct TorrentRequest { - pub fields: CreateTorrent, + pub fields: Create, pub torrent: Torrent, } diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 32ad44df..0f121fd4 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -20,42 +20,47 @@ use crate::AsCSV; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/torrent") - .service(web::resource("/upload").route(web::post().to(upload_torrent))) + .service(web::resource("/upload").route(web::post().to(upload))) .service(web::resource("/download/{info_hash}").route(web::get().to(download_torrent_handler))) .service( web::resource("/{info_hash}") - .route(web::get().to(get_torrent_handler)) - .route(web::put().to(update_torrent_handler)) - .route(web::delete().to(delete_torrent_handler)), + .route(web::get().to(get)) + .route(web::put().to(update)) + .route(web::delete().to(delete)), ), ); cfg.service(web::scope("/torrents").service(web::resource("").route(web::get().to(get_torrents_handler)))); } #[derive(FromRow)] -pub struct TorrentCount { +pub struct Count { pub count: i32, } #[derive(Debug, Deserialize)] -pub struct CreateTorrent { +pub struct Create { pub title: String, pub description: String, pub category: String, } -impl CreateTorrent { +impl Create { + /// Returns the verify of this [`Create`]. + /// + /// # Errors + /// + /// This function will return an `BadRequest` error if the `title` or the `category` is empty. pub fn verify(&self) -> Result<(), ServiceError> { - if !self.title.is_empty() && !self.category.is_empty() { - return Ok(()); + if self.title.is_empty() || self.category.is_empty() { + Err(ServiceError::BadRequest) + } else { + Ok(()) } - - Err(ServiceError::BadRequest) } } #[derive(Debug, Deserialize)] -pub struct TorrentSearch { +pub struct Search { page_size: Option, page: Option, sort: Option, @@ -65,12 +70,21 @@ pub struct TorrentSearch { } #[derive(Debug, Deserialize)] -pub struct TorrentUpdate { +pub struct Update { title: Option, description: Option, } -pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { +/// Upload a Torrent to the Index +/// +/// # Errors +/// +/// This function will return an error if unable to get the user from the database. +/// This function will return an error if unable to get torrent request from payload. +/// This function will return an error if unable to get the category from the database. +/// This function will return an error if unable to insert the torrent into the database. +/// This function will return an error if unable to add the torrent to the whitelist. +pub async fn upload(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; // get torrent and fields from request @@ -166,7 +180,17 @@ pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Ok(HttpResponse::Ok().content_type("application/x-bittorrent").body(buffer)) } -pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { +/// Get Torrent from the Index +/// +/// # Errors +/// +/// This function will return an error if unable to get torrent ID. +/// This function will return an error if unable to get torrent listing from id. +/// This function will return an error if unable to get torrent category from id. +/// This function will return an error if unable to get torrent files from id. +/// This function will return an error if unable to get torrent info from id. +/// This function will return an error if unable to get torrent announce url(s) from id. +pub async fn get(req: HttpRequest, app_data: WebAppData) -> ServiceResult { // optional let user = app_data.auth.get_user_compact_from_request(&req).await; @@ -251,11 +275,16 @@ pub async fn get_torrent_handler(req: HttpRequest, app_data: WebAppData) -> Serv Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } -pub async fn update_torrent_handler( - req: HttpRequest, - payload: web::Json, - app_data: WebAppData, -) -> ServiceResult { +/// Update a Torrent in the Index +/// +/// # Errors +/// +/// This function will return an error if unable to get user. +/// This function will return an error if unable to get torrent id from request. +/// This function will return an error if unable to get listing from id. +/// This function will return an `ServiceError::Unauthorized` if user is not a owner or an administrator. +/// This function will return an error if unable to update the torrent tile or description. +pub async fn update(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; let infohash = get_torrent_infohash_from_request(&req)?; @@ -293,7 +322,15 @@ pub async fn update_torrent_handler( Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } -pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { +/// Delete a Torrent from the Index +/// +/// # Errors +/// +/// This function will return an error if unable to get the user. +/// This function will return an `ServiceError::Unauthorized` if the user is not an administrator. +/// This function will return an error if unable to get the torrent listing from it's ID. +/// This function will return an error if unable to delete the torrent from the database. +pub async fn delete(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator @@ -327,7 +364,7 @@ pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> S /// # Errors /// /// Returns a `ServiceError::DatabaseError` if the database query fails. -pub async fn get_torrents_handler(params: Query, app_data: WebAppData) -> ServiceResult { +pub async fn get_torrents_handler(params: Query, app_data: WebAppData) -> ServiceResult { let settings = app_data.cfg.settings.read().await; let sort = params.sort.unwrap_or(Sorting::UploadedDesc); @@ -405,7 +442,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result Result Date: Wed, 26 Apr 2023 21:37:33 +0200 Subject: [PATCH 164/357] dev: fix clippy warnings for: src/routes/user.rs --- src/routes/user.rs | 69 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/routes/user.rs b/src/routes/user.rs index 7b195030..d07f7310 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -22,7 +22,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { .service(web::resource("/register").route(web::post().to(register))) .service(web::resource("/login").route(web::post().to(login))) // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. - .service(web::resource("/ban/{user}").route(web::delete().to(ban_user))) + .service(web::resource("/ban/{user}").route(web::delete().to(ban))) .service(web::resource("/token/verify").route(web::post().to(verify_token))) .service(web::resource("/token/renew").route(web::post().to(renew_token))) .service(web::resource("/email/verify/{token}").route(web::get().to(verify_email))), @@ -48,6 +48,19 @@ pub struct Token { pub token: String, } +/// Register a User in the Index +/// +/// # Errors +/// +/// This function will return a `ServiceError::EmailMissing` if email is required, but missing. +/// This function will return a `ServiceError::EmailInvalid` if supplied email is badly formatted. +/// This function will return a `ServiceError::PasswordsDontMatch` if the supplied passwords do not match. +/// This function will return a `ServiceError::PasswordTooShort` if the supplied password is too short. +/// This function will return a `ServiceError::PasswordTooLong` if the supplied password is too long. +/// This function will return a `ServiceError::UsernameInvalid` if the supplied username is badly formatted. +/// This function will return an error if unable to successfully hash the password. +/// This function will return an error if unable to insert user into the database. +/// This function will return a `ServiceError::FailedToSendVerificationEmail` if unable to send the required verification email. pub async fn register(req: HttpRequest, mut payload: web::Json, app_data: WebAppData) -> ServiceResult { info!("registering user: {}", payload.username); @@ -60,7 +73,7 @@ pub async fn register(req: HttpRequest, mut payload: web::Json, app_da } } EmailOnSignup::None => payload.email = None, - _ => {} + EmailOnSignup::Optional => {} } if let Some(email) = &payload.email { @@ -108,13 +121,13 @@ pub async fn register(req: HttpRequest, mut payload: web::Json, app_da let _ = app_data.database.grant_admin_role(user_id).await; } - let conn_info = req.connection_info(); + let conn_info = req.connection_info().clone(); if settings.mail.email_verification_enabled && payload.email.is_some() { let mail_res = app_data .mailer .send_verification_mail( - payload.email.as_ref().unwrap(), + payload.email.as_ref().expect("variable `email` is checked above"), &payload.username, user_id, format!("{}://{}", conn_info.scheme(), conn_info.host()).as_str(), @@ -130,6 +143,15 @@ pub async fn register(req: HttpRequest, mut payload: web::Json, app_da Ok(HttpResponse::Ok()) } +/// Login user to Index +/// +/// # Errors +/// +/// This function will return a `ServiceError::WrongPasswordOrUsername` if unable to get user profile. +/// This function will return a `ServiceError::InternalServerError` if unable to get user authentication data from the user id. +/// This function will return an error if unable to verify the password. +/// This function will return a `ServiceError::EmailNotVerified` if the email should be, but is not verified. +/// This function will return an error if unable to get the user data from the database. pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceResult { // get the user profile from database let user_profile = app_data @@ -172,6 +194,11 @@ pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceRe } /// Verify if the user supplied and the database supplied passwords match +/// +/// # Errors +/// +/// This function will return an error if unable to parse password hash from the stored user authentication value. +/// This function will return a `ServiceError::WrongPasswordOrUsername` if unable to match the password with either `argon2id` or `pbkdf2-sha256`. pub fn verify_password(password: &[u8], user_authentication: &UserAuthentication) -> Result<(), ServiceError> { // wrap string of the hashed password into a PasswordHash struct for verification let parsed_hash = PasswordHash::new(&user_authentication.password_hash)?; @@ -195,6 +222,11 @@ pub fn verify_password(password: &[u8], user_authentication: &UserAuthentication } } +/// Verify a supplied JWT. +/// +/// # Errors +/// +/// This function will return an error if unable to verify the supplied payload as a valid jwt. pub async fn verify_token(payload: web::Json, app_data: WebAppData) -> ServiceResult { // verify if token is valid let _claims = app_data.auth.verify_jwt(&payload.token).await?; @@ -204,14 +236,20 @@ pub async fn verify_token(payload: web::Json, app_data: WebAppData) -> Se })) } +/// Renew a supplied JWT. +/// +/// # Errors +/// +/// This function will return an error if unable to verify the supplied payload as a valid jwt. +/// This function will return an error if unable to get user data from the database. pub async fn renew_token(payload: web::Json, app_data: WebAppData) -> ServiceResult { + const ONE_WEEK_IN_SECONDS: u64 = 604_800; + // verify if token is valid let claims = app_data.auth.verify_jwt(&payload.token).await?; let user_compact = app_data.database.get_user_compact_from_id(claims.user.user_id).await?; - const ONE_WEEK_IN_SECONDS: u64 = 604_800; - // renew token if it is valid for less than one week let token = match claims.exp - current_time() { x if x < ONE_WEEK_IN_SECONDS => app_data.auth.sign_jwt(user_compact.clone()).await, @@ -229,7 +267,10 @@ pub async fn renew_token(payload: web::Json, app_data: WebAppData) -> Ser pub async fn verify_email(req: HttpRequest, app_data: WebAppData) -> String { let settings = app_data.cfg.settings.read().await; - let token = req.match_info().get("token").unwrap(); + let token = match req.match_info().get("token").ok_or(ServiceError::InternalServerError) { + Ok(token) => token, + Err(err) => return err.to_string(), + }; let token_data = match decode::( token, @@ -255,8 +296,16 @@ pub async fn verify_email(req: HttpRequest, app_data: WebAppData) -> String { String::from("Email verified, you can close this page.") } -// TODO: add reason and date_expiry parameters to request -pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult { +/// Ban a user from the Index +/// +/// TODO: add reason and `date_expiry` parameters to request +/// +/// # Errors +/// +/// This function will return a `ServiceError::InternalServerError` if unable get user from the request. +/// This function will return an error if unable to get user profile from supplied username. +/// This function will return an error if unable to ser the ban of the user in the database. +pub async fn ban(req: HttpRequest, app_data: WebAppData) -> ServiceResult { debug!("banning user"); let user = app_data.auth.get_user_compact_from_request(&req).await?; @@ -266,7 +315,7 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult Date: Wed, 26 Apr 2023 22:16:18 +0200 Subject: [PATCH 165/357] dev: fix clippy warnings for: src/tracker.rs --- src/app.rs | 5 ++--- src/common.rs | 8 +++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3bc00b58..2322d139 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,9 +12,8 @@ use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; use crate::config::Configuration; use crate::databases::database; -use crate::tracker::service::Service; use crate::tracker::statistics_importer::StatisticsImporter; -use crate::{mailer, routes}; +use crate::{mailer, routes, tracker}; pub struct Running { pub api_server: Server, @@ -44,7 +43,7 @@ pub async fn run(configuration: Configuration) -> Running { let database = Arc::new(database::connect(&database_connect_url).await.expect("Database error.")); let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); - let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); + let tracker_service = Arc::new(tracker::service::Service::new(cfg.clone(), database.clone()).await); let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); let mailer_service = Arc::new(mailer::Service::new(cfg.clone()).await); diff --git a/src/common.rs b/src/common.rs index 0cbb011b..51861fae 100644 --- a/src/common.rs +++ b/src/common.rs @@ -4,10 +4,8 @@ use crate::auth::AuthorizationService; use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; -use crate::mailer; -use crate::tracker::service::Service; use crate::tracker::statistics_importer::StatisticsImporter; - +use crate::{mailer, tracker}; pub type Username = String; pub type WebAppData = actix_web::web::Data>; @@ -16,7 +14,7 @@ pub struct AppData { pub cfg: Arc, pub database: Arc>, pub auth: Arc, - pub tracker_service: Arc, + pub tracker_service: Arc, pub tracker_statistics_importer: Arc, pub mailer: Arc, pub image_cache_manager: Arc, @@ -27,7 +25,7 @@ impl AppData { cfg: Arc, database: Arc>, auth: Arc, - tracker_service: Arc, + tracker_service: Arc, tracker_statistics_importer: Arc, mailer: Arc, image_cache_manager: Arc, From da91f979fd58d80f3ceb347a276dcc49b3f3a7ab Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 27 Apr 2023 16:04:34 +0200 Subject: [PATCH 166/357] dev: fix remaning clippy warnings --- src/auth.rs | 6 ++-- src/bin/upgrade.rs | 4 +-- .../commands/import_tracker_statistics.rs | 2 +- src/databases/database.rs | 32 +++++++++++++------ src/databases/mysql.rs | 24 +++++++------- src/databases/sqlite.rs | 24 +++++++------- src/mailer.rs | 4 +-- src/models/torrent_file.rs | 8 ++--- src/routes/user.rs | 4 +-- .../databases/sqlite_v1_0_0.rs | 2 ++ .../databases/sqlite_v2_0_0.rs | 3 ++ .../transferrers/category_transferrer.rs | 1 + .../transferrers/torrent_transferrer.rs | 4 +++ .../transferrers/tracker_key_transferrer.rs | 1 + .../transferrers/user_transferrer.rs | 1 + .../from_v1_0_0_to_v2_0_0/upgrader.rs | 2 +- src/utils/clock.rs | 2 +- src/utils/hex.rs | 9 ++++-- src/utils/parse_torrent.rs | 10 ++++++ src/utils/regex.rs | 5 +-- tests/databases/mod.rs | 14 ++++---- tests/databases/tests.rs | 10 +++--- .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 3 ++ .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 2 ++ .../category_transferrer_tester.rs | 2 ++ .../torrent_transferrer_tester.rs | 2 ++ .../tracker_key_transferrer_tester.rs | 2 ++ .../user_transferrer_tester.rs | 1 + .../from_v1_0_0_to_v2_0_0/upgrader.rs | 2 +- 29 files changed, 118 insertions(+), 68 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 21432ac8..a8fbf76b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,7 +7,7 @@ use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; use crate::models::user::{UserClaims, UserCompact}; -use crate::utils::clock::current_time; +use crate::utils::clock; pub struct AuthorizationService { cfg: Arc, @@ -26,7 +26,7 @@ impl AuthorizationService { // create JWT that expires in two weeks let key = settings.auth.secret_key.as_bytes(); // TODO: create config option for setting the token validity in seconds - let exp_date = current_time() + 1_209_600; // two weeks from now + let exp_date = clock::now() + 1_209_600; // two weeks from now let claims = UserClaims { user, exp: exp_date }; @@ -47,7 +47,7 @@ impl AuthorizationService { &Validation::new(Algorithm::HS256), ) { Ok(token_data) => { - if token_data.claims.exp < current_time() { + if token_data.claims.exp < clock::now() { return Err(ServiceError::TokenExpired); } Ok(token_data.claims) diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index 874f0fad..8fb1ee0c 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -2,9 +2,9 @@ //! It updates the application from version v1.0.0 to v2.0.0. //! You can execute it with: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads` -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run_upgrader; +use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run; #[actix_web::main] async fn main() { - run_upgrader().await; + run().await; } diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index eb31bb3c..a322eef2 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -79,7 +79,7 @@ pub async fn import(_args: &Arguments) { let database = Arc::new( database::connect(&settings.database.connect_url) .await - .expect("Database error."), + .expect("unable to connect to db"), ); let tracker_service = Arc::new(Service::new(cfg.clone(), database.clone()).await); diff --git a/src/databases/database.rs b/src/databases/database.rs index a090ec62..ccbd4bf6 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -63,31 +63,43 @@ pub enum Error { TorrentTitleAlreadyExists, } -/// Connect to a database. +/// Get the Driver of the Database from the Connection String /// /// # Errors /// /// This function will return an `Error::UnrecognizedDatabaseDriver` if unable to match database type. -pub async fn connect(db_path: &str) -> Result, Error> { +pub fn get_driver(db_path: &str) -> Result { match &db_path.chars().collect::>() as &[char] { - ['s', 'q', 'l', 'i', 't', 'e', ..] => { - let db = Sqlite::new(db_path).await; - Ok(Box::new(db)) - } - ['m', 'y', 's', 'q', 'l', ..] => { - let db = Mysql::new(db_path).await; - Ok(Box::new(db)) - } + ['s', 'q', 'l', 'i', 't', 'e', ..] => Ok(Driver::Sqlite3), + ['m', 'y', 's', 'q', 'l', ..] => Ok(Driver::Mysql), _ => Err(Error::UnrecognizedDatabaseDriver), } } +/// Connect to a database. +/// +/// # Errors +/// +/// This function will return an `Error::UnrecognizedDatabaseDriver` if unable to match database type. +pub async fn connect(db_path: &str) -> Result, Error> { + let db_driver = self::get_driver(db_path)?; + + Ok(match db_driver { + self::Driver::Sqlite3 => Box::new(Sqlite::new(db_path).await), + self::Driver::Mysql => Box::new(Mysql::new(db_path).await), + }) +} + /// Trait for database implementations. #[async_trait] pub trait Database: Sync + Send { /// Return current database driver. fn get_database_driver(&self) -> Driver; + async fn new(db_path: &str) -> Self + where + Self: Sized; + /// Add new user and return the newly inserted `user_id`. async fn insert_user_and_get_id(&self, username: &str, email: &str, password: &str) -> Result; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 6babecb2..029ec7c1 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -11,15 +11,20 @@ use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; -use crate::utils::clock::current_time; -use crate::utils::hex::bytes_to_hex; +use crate::utils::clock; +use crate::utils::hex::from_bytes; pub struct Mysql { pub pool: MySqlPool, } -impl Mysql { - pub async fn new(database_url: &str) -> Self { +#[async_trait] +impl Database for Mysql { + fn get_database_driver(&self) -> Driver { + Driver::Mysql + } + + async fn new(database_url: &str) -> Self { let db = MySqlPoolOptions::new() .connect(database_url) .await @@ -32,13 +37,6 @@ impl Mysql { Self { pool: db } } -} - -#[async_trait] -impl Database for Mysql { - fn get_database_driver(&self) -> Driver { - Driver::Mysql - } async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { // open pool connection @@ -142,7 +140,7 @@ impl Database for Mysql { async fn get_user_tracker_key(&self, user_id: i64) -> Option { const HOUR_IN_SECONDS: i64 = 3600; - let current_time_plus_hour = i64::try_from(current_time()).unwrap().saturating_add(HOUR_IN_SECONDS); + let current_time_plus_hour = i64::try_from(clock::now()).unwrap().saturating_add(HOUR_IN_SECONDS); // get tracker key that is valid for at least one hour from now query_as::<_, TrackerKey>("SELECT tracker_key AS 'key', date_expiry AS valid_until FROM torrust_tracker_keys WHERE user_id = ? AND date_expiry > ? ORDER BY date_expiry DESC") @@ -401,7 +399,7 @@ impl Database for Mysql { // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { - (bytes_to_hex(pieces.as_ref()), false) + (from_bytes(pieces.as_ref()), false) } else { let root_hash = torrent.info.root_hash.as_ref().ok_or(database::Error::Error)?; (root_hash.to_string(), true) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 0410a670..e7792b44 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -11,15 +11,20 @@ use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; -use crate::utils::clock::current_time; -use crate::utils::hex::bytes_to_hex; +use crate::utils::clock; +use crate::utils::hex::from_bytes; pub struct Sqlite { pub pool: SqlitePool, } -impl Sqlite { - pub async fn new(database_url: &str) -> Self { +#[async_trait] +impl Database for Sqlite { + fn get_database_driver(&self) -> Driver { + Driver::Sqlite3 + } + + async fn new(database_url: &str) -> Self { let db = SqlitePoolOptions::new() .connect(database_url) .await @@ -32,13 +37,6 @@ impl Sqlite { Self { pool: db } } -} - -#[async_trait] -impl Database for Sqlite { - fn get_database_driver(&self) -> Driver { - Driver::Sqlite3 - } async fn insert_user_and_get_id(&self, username: &str, email: &str, password_hash: &str) -> Result { // open pool connection @@ -138,7 +136,7 @@ impl Database for Sqlite { const HOUR_IN_SECONDS: i64 = 3600; // casting current_time() to i64 will overflow in the year 2262 - let current_time_plus_hour = i64::try_from(current_time()).unwrap().saturating_add(HOUR_IN_SECONDS); + let current_time_plus_hour = i64::try_from(clock::now()).unwrap().saturating_add(HOUR_IN_SECONDS); // get tracker key that is valid for at least one hour from now query_as::<_, TrackerKey>("SELECT tracker_key AS key, date_expiry AS valid_until FROM torrust_tracker_keys WHERE user_id = $1 AND date_expiry > $2 ORDER BY date_expiry DESC") @@ -391,7 +389,7 @@ impl Database for Sqlite { // torrent file can only hold a pieces key or a root hash key: http://www.bittorrent.org/beps/bep_0030.html let (pieces, root_hash): (String, bool) = if let Some(pieces) = &torrent.info.pieces { - (bytes_to_hex(pieces.as_ref()), false) + (from_bytes(pieces.as_ref()), false) } else { let root_hash = torrent.info.root_hash.as_ref().ok_or(database::Error::Error)?; (root_hash.to_string(), true) diff --git a/src/mailer.rs b/src/mailer.rs index f871b0df..64c2826e 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::config::Configuration; use crate::errors::ServiceError; -use crate::utils::clock::current_time; +use crate::utils::clock; pub struct Service { cfg: Arc, @@ -137,7 +137,7 @@ impl Service { let claims = VerifyClaims { iss: String::from("email-verification"), sub: user_id, - exp: current_time() + 315_569_260, // 10 years from now + exp: clock::now() + 315_569_260, // 10 years from now }; let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index c6565158..e3c0a49f 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -4,7 +4,7 @@ use serde_bytes::ByteBuf; use sha1::{Digest, Sha1}; use crate::config::Configuration; -use crate::utils::hex::{bytes_to_hex, hex_to_bytes}; +use crate::utils::hex::{from_bytes, into_bytes}; #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TorrentNode(String, i64); @@ -46,7 +46,7 @@ impl TorrentInfo { pub fn get_pieces_as_string(&self) -> String { match &self.pieces { None => String::new(), - Some(byte_buf) => bytes_to_hex(byte_buf.as_ref()), + Some(byte_buf) => from_bytes(byte_buf.as_ref()), } } @@ -121,7 +121,7 @@ impl Torrent { if torrent_info.root_hash > 0 { info.root_hash = Some(torrent_info.pieces); } else { - let pieces = hex_to_bytes(&torrent_info.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); + let pieces = into_bytes(&torrent_info.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); info.pieces = Some(ByteBuf::from(pieces)); } @@ -191,7 +191,7 @@ impl Torrent { #[must_use] pub fn info_hash(&self) -> String { - bytes_to_hex(&self.calculate_info_hash_as_bytes()) + from_bytes(&self.calculate_info_hash_as_bytes()) } #[must_use] diff --git a/src/routes/user.rs b/src/routes/user.rs index d07f7310..42083614 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -13,7 +13,7 @@ use crate::errors::{ServiceError, ServiceResult}; use crate::mailer::VerifyClaims; use crate::models::response::{OkResponse, TokenResponse}; use crate::models::user::UserAuthentication; -use crate::utils::clock::current_time; +use crate::utils::clock; use crate::utils::regex::validate_email_address; pub fn init(cfg: &mut web::ServiceConfig) { @@ -251,7 +251,7 @@ pub async fn renew_token(payload: web::Json, app_data: WebAppData) -> Ser let user_compact = app_data.database.get_user_compact_from_id(claims.user.user_id).await?; // renew token if it is valid for less than one week - let token = match claims.exp - current_time() { + let token = match claims.exp - clock::now() { x if x < ONE_WEEK_IN_SECONDS => app_data.auth.sign_jwt(user_compact.clone()).await, _ => payload.token.clone(), }; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 8d58d5a4..ae15a037 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -1,3 +1,5 @@ +#![allow(clippy::missing_errors_doc)] + use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query_as, SqlitePool}; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index d182efaf..d054ca1c 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -1,3 +1,5 @@ +#![allow(clippy::missing_errors_doc)] + use chrono::{DateTime, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; @@ -257,6 +259,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } + #[allow(clippy::missing_panics_doc)] pub async fn delete_all_database_rows(&self) -> Result<(), database::Error> { query("DELETE FROM torrust_categories").execute(&self.pool).await.unwrap(); diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs index 795c9f34..4226a944 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{CategoryRecordV2, SqliteDatabaseV2_0_0}; +#[allow(clippy::missing_panics_doc)] pub async fn transfer_categories(source_database: Arc, target_database: Arc) { println!("Transferring categories ..."); diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs index 89b4fdd7..5e6f9656 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs @@ -1,3 +1,5 @@ +#![allow(clippy::missing_errors_doc)] + use std::sync::Arc; use std::{error, fs}; @@ -6,6 +8,8 @@ use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteData use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{SqliteDatabaseV2_0_0, TorrentRecordV2}; use crate::utils::parse_torrent::decode_torrent; +#[allow(clippy::missing_panics_doc)] +#[allow(clippy::too_many_lines)] pub async fn transfer_torrents( source_database: Arc, target_database: Arc, diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs index 1e475d8e..88e8a1a2 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +#[allow(clippy::missing_panics_doc)] pub async fn transfer_tracker_keys(source_database: Arc, target_database: Arc) { println!("Transferring tracker keys ..."); diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs index 76791d32..ca127f5a 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +#[allow(clippy::missing_panics_doc)] pub async fn transfer_users( source_database: Arc, target_database: Arc, diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 6e8901f5..d724ffb7 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -67,7 +67,7 @@ fn parse_args() -> Arguments { } } -pub async fn run_upgrader() { +pub async fn run() { let now = datetime_iso_8601(); upgrade(&parse_args(), &now).await; } diff --git a/src/utils/clock.rs b/src/utils/clock.rs index 6ba681a5..4c4f0bf0 100644 --- a/src/utils/clock.rs +++ b/src/utils/clock.rs @@ -1,4 +1,4 @@ #[must_use] -pub fn current_time() -> u64 { +pub fn now() -> u64 { u64::try_from(chrono::prelude::Utc::now().timestamp()).expect("timestamp should be positive") } diff --git a/src/utils/hex.rs b/src/utils/hex.rs index 3cc9f57e..be8e82f5 100644 --- a/src/utils/hex.rs +++ b/src/utils/hex.rs @@ -2,7 +2,7 @@ use std::fmt::Write; use std::num::ParseIntError; #[must_use] -pub fn bytes_to_hex(bytes: &[u8]) -> String { +pub fn from_bytes(bytes: &[u8]) -> String { let mut s = String::with_capacity(2 * bytes.len()); for byte in bytes { @@ -12,7 +12,12 @@ pub fn bytes_to_hex(bytes: &[u8]) -> String { s } -pub fn hex_to_bytes(s: &str) -> Result, ParseIntError> { +/// Encodes a String into Hex Bytes +/// +/// # Errors +/// +/// This function will return an error if unable to encode into Hex +pub fn into_bytes(s: &str) -> Result, ParseIntError> { (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16)) diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index d15e5c82..9ac4b44f 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -4,6 +4,11 @@ use serde_bencode::{de, Error}; use crate::models::torrent_file::Torrent; +/// Decode a Torrent from Bencoded Bytes +/// +/// # Errors +/// +/// This function will return an error if unable to parse bytes into torrent. pub fn decode_torrent(bytes: &[u8]) -> Result> { match de::from_bytes::(bytes) { Ok(torrent) => Ok(torrent), @@ -14,6 +19,11 @@ pub fn decode_torrent(bytes: &[u8]) -> Result> { } } +/// Encode a Torrent into Bencoded Bytes +/// +/// # Errors +/// +/// This function will return an error if unable to bencode torrent. pub fn encode_torrent(torrent: &Torrent) -> Result, Error> { match serde_bencode::to_bytes(torrent) { Ok(bencode_bytes) => Ok(bencode_bytes), diff --git a/src/utils/regex.rs b/src/utils/regex.rs index 22df6176..f423fdaf 100644 --- a/src/utils/regex.rs +++ b/src/utils/regex.rs @@ -2,7 +2,8 @@ use regex::Regex; #[must_use] pub fn validate_email_address(email_address_to_be_checked: &str) -> bool { - let email_regex = Regex::new(r"^([a-z\d_+]([a-z\d_+.]*[a-z\d_+])?)@([a-z\d]+([\-.][a-z\d]+)*\.[a-z]{2,6})").unwrap(); + let email_regex = Regex::new(r"^([a-z\d_+]([a-z\d_+.]*[a-z\d_+])?)@([a-z\d]+([\-.][a-z\d]+)*\.[a-z]{2,6})") + .expect("regex failed to compile"); email_regex.is_match(email_address_to_be_checked) } @@ -27,6 +28,6 @@ mod tests { assert!(validate_email_address("test@torrust.com")); - assert!(validate_email_address("t@torrust.org")) + assert!(validate_email_address("t@torrust.org")); } } diff --git a/tests/databases/mod.rs b/tests/databases/mod.rs index 07607c68..22d83c5e 100644 --- a/tests/databases/mod.rs +++ b/tests/databases/mod.rs @@ -8,9 +8,9 @@ mod sqlite; mod tests; // used to run tests with a clean database -async fn run_test<'a, T, F>(db_fn: T, db: &'a Box) +async fn run_test<'a, T, F, DB: Database + ?Sized>(db_fn: T, db: &'a DB) where - T: FnOnce(&'a Box) -> F + 'a, + T: FnOnce(&'a DB) -> F + 'a, F: Future, { // cleanup database before testing @@ -26,9 +26,11 @@ pub async fn run_tests(db_path: &str) { assert!(db_res.is_ok()); - let db = db_res.unwrap(); + let db_boxed = db_res.unwrap(); - run_test(tests::it_can_add_a_user, &db).await; - run_test(tests::it_can_add_a_torrent_category, &db).await; - run_test(tests::it_can_add_a_torrent_and_tracker_stats_to_that_torrent, &db).await; + let db: &dyn Database = db_boxed.as_ref(); + + run_test(tests::it_can_add_a_user, db).await; + run_test(tests::it_can_add_a_torrent_category, db).await; + run_test(tests::it_can_add_a_torrent_and_tracker_stats_to_that_torrent, db).await; } diff --git a/tests/databases/tests.rs b/tests/databases/tests.rs index f500164f..98c24d60 100644 --- a/tests/databases/tests.rs +++ b/tests/databases/tests.rs @@ -20,16 +20,16 @@ const TEST_TORRENT_FILE_SIZE: i64 = 128_000; const TEST_TORRENT_SEEDERS: i64 = 437; const TEST_TORRENT_LEECHERS: i64 = 1289; -async fn add_test_user(db: &Box) -> Result { +async fn add_test_user(db: &T) -> Result { db.insert_user_and_get_id(TEST_USER_USERNAME, TEST_USER_EMAIL, TEST_USER_PASSWORD) .await } -async fn add_test_torrent_category(db: &Box) -> Result { +async fn add_test_torrent_category(db: &T) -> Result { db.insert_category_and_get_id(TEST_CATEGORY_NAME).await } -pub async fn it_can_add_a_user(db: &Box) { +pub async fn it_can_add_a_user(db: &T) { let add_test_user_result = add_test_user(db).await; assert!(add_test_user_result.is_ok()); @@ -57,7 +57,7 @@ pub async fn it_can_add_a_user(db: &Box) { ); } -pub async fn it_can_add_a_torrent_category(db: &Box) { +pub async fn it_can_add_a_torrent_category(db: &T) { let add_test_torrent_category_result = add_test_torrent_category(db).await; assert!(add_test_torrent_category_result.is_ok()); @@ -71,7 +71,7 @@ pub async fn it_can_add_a_torrent_category(db: &Box) { assert_eq!(category.name, TEST_CATEGORY_NAME.to_string()); } -pub async fn it_can_add_a_torrent_and_tracker_stats_to_that_torrent(db: &Box) { +pub async fn it_can_add_a_torrent_and_tracker_stats_to_that_torrent(db: &T) { // set pre-conditions let user_id = add_test_user(db).await.expect("add_test_user failed."); let torrent_category_id = add_test_torrent_category(db) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index fa1adc92..2b8dd1c4 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -1,3 +1,5 @@ +#![allow(clippy::missing_errors_doc)] + use std::fs; use sqlx::sqlite::SqlitePoolOptions; @@ -63,6 +65,7 @@ impl SqliteDatabaseV1_0_0 { .map(|v| v.last_insert_rowid()) } + #[allow(clippy::missing_panics_doc)] pub async fn delete_all_categories(&self) -> Result<(), sqlx::Error> { query("DELETE FROM torrust_categories").execute(&self.pool).await.unwrap(); Ok(()) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index 8d863c10..eff4187e 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -1,3 +1,5 @@ +#![allow(clippy::missing_errors_doc)] + use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query_as, SqlitePool}; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/category_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/category_transferrer_tester.rs index b39f9419..86a9464c 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/category_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/category_transferrer_tester.rs @@ -39,6 +39,7 @@ impl CategoryTester { self.test_data.categories[0].category_id } + #[allow(clippy::missing_panics_doc)] /// Table `torrust_categories` pub async fn load_data_into_source_db(&self) { // Delete categories added by migrations @@ -50,6 +51,7 @@ impl CategoryTester { } } + #[allow(clippy::missing_panics_doc)] /// Table `torrust_categories` pub async fn assert_data_in_target_db(&self) { for categories in &self.test_data.categories { diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs index e6816c41..0ee1e123 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs @@ -77,12 +77,14 @@ impl TorrentTester { } } + #[allow(clippy::missing_panics_doc)] pub async fn load_data_into_source_db(&self) { for torrent in &self.test_data.torrents { self.source_database.insert_torrent(torrent).await.unwrap(); } } + #[allow(clippy::missing_panics_doc)] pub async fn assert_data_in_target_db(&self, upload_path: &str) { for torrent in &self.test_data.torrents { let filepath = self.torrent_file_path(upload_path, torrent.torrent_id); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/tracker_key_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/tracker_key_transferrer_tester.rs index 74c13db2..0c212720 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/tracker_key_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/tracker_key_transferrer_tester.rs @@ -31,6 +31,7 @@ impl TrackerKeyTester { } } + #[allow(clippy::missing_panics_doc)] pub async fn load_data_into_source_db(&self) { self.source_database .insert_tracker_key(&self.test_data.tracker_key) @@ -38,6 +39,7 @@ impl TrackerKeyTester { .unwrap(); } + #[allow(clippy::missing_panics_doc)] /// Table `torrust_tracker_keys` pub async fn assert_data_in_target_db(&self) { let imported_key = self diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/user_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/user_transferrer_tester.rs index 2d52a683..6ba97ef8 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/user_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/user_transferrer_tester.rs @@ -42,6 +42,7 @@ impl UserTester { } } + #[allow(clippy::missing_panics_doc)] pub async fn load_data_into_source_db(&self) { self.source_database.insert_user(&self.test_data.user).await.unwrap(); } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 5256274d..750a19e1 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -1,6 +1,6 @@ //! You can run this test with: //! -//! //! ```text +//! ```text //! cargo test upgrades_data_from_version_v1_0_0_to_v2_0_0 //! ``` //! From f0d3da52e1e744287dd081359341411c83642d7a Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 10 May 2023 10:17:51 +0200 Subject: [PATCH 167/357] dev: fix remaning clippy warnings 2 --- project-words.txt | 1 + src/cache/image/manager.rs | 57 +++-- src/cache/mod.rs | 222 +++++++++++++++++- src/routes/proxy.rs | 5 + .../torrent_transferrer_tester.rs | 4 +- 5 files changed, 262 insertions(+), 27 deletions(-) diff --git a/project-words.txt b/project-words.txt index a76aa985..289364ad 100644 --- a/project-words.txt +++ b/project-words.txt @@ -21,6 +21,7 @@ hexlify httpseeds imagoodboy imdl +indexmap infohash jsonwebtoken leechers diff --git a/src/cache/image/manager.rs b/src/cache/image/manager.rs index bfef1589..9e8d814c 100644 --- a/src/cache/image/manager.rs +++ b/src/cache/image/manager.rs @@ -5,7 +5,7 @@ use std::time::{Duration, SystemTime}; use bytes::Bytes; use tokio::sync::RwLock; -use crate::cache::cache::BytesCache; +use crate::cache::BytesCache; use crate::config::Configuration; use crate::models::user::UserCompact; @@ -21,10 +21,10 @@ type UserQuotas = HashMap; #[must_use] pub fn now_in_secs() -> u64 { - match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { - Ok(n) => n.as_secs(), - Err(_) => panic!("SystemTime before UNIX EPOCH!"), - } + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("SystemTime before UNIX EPOCH!") + .as_secs() } #[derive(Clone)] @@ -48,14 +48,19 @@ impl ImageCacheQuota { } } - pub fn add_usage(&mut self, amount: usize) -> Result<(), ()> { + /// Add Usage Quota + /// + /// # Errors + /// + /// This function will return a `Error::UserQuotaMet` if user quota has been met. + pub fn add_usage(&mut self, amount: usize) -> Result<(), Error> { // Check if quota needs to be reset. if now_in_secs() - self.date_start_secs > self.period_secs { self.reset(); } if self.is_reached() { - return Err(()); + return Err(Error::UserQuotaMet); } self.usage = self.usage.saturating_add(amount); @@ -92,7 +97,7 @@ impl ImageCacheService { let reqwest_client = reqwest::Client::builder() .timeout(Duration::from_millis(settings.image_cache.max_request_timeout_ms)) .build() - .unwrap(); + .expect("unable to build client request"); drop(settings); @@ -106,33 +111,37 @@ impl ImageCacheService { /// Get an image from the url and insert it into the cache if it isn't cached already. /// Unauthenticated users can only get already cached images. + /// + /// # Errors + /// + /// Return a `Error::Unauthenticated` if the user has not been authenticated. pub async fn get_image_by_url(&self, url: &str, opt_user: Option) -> Result { if let Some(entry) = self.image_cache.read().await.get(url).await { return Ok(entry.bytes); } - if opt_user.is_none() { - return Err(Error::Unauthenticated); - } - - let user = opt_user.unwrap(); + match opt_user { + None => Err(Error::Unauthenticated), - self.check_user_quota(&user).await?; + Some(user) => { + self.check_user_quota(&user).await?; - let image_bytes = self.get_image_from_url_as_bytes(url).await?; + let image_bytes = self.get_image_from_url_as_bytes(url).await?; - self.check_image_size(&image_bytes).await?; + self.check_image_size(&image_bytes).await?; - // These two functions could be executed after returning the image to the client, - // but than we would need a dedicated task or thread that executes these functions. - // This can be problematic if a task is spawned after every user request. - // Since these functions execute very fast, I don't see a reason to further optimize this. - // For now. - self.update_image_cache(url, &image_bytes).await?; + // These two functions could be executed after returning the image to the client, + // but than we would need a dedicated task or thread that executes these functions. + // This can be problematic if a task is spawned after every user request. + // Since these functions execute very fast, I don't see a reason to further optimize this. + // For now. + self.update_image_cache(url, &image_bytes).await?; - self.update_user_quota(&user, image_bytes.len()).await?; + self.update_user_quota(&user, image_bytes.len()).await?; - Ok(image_bytes) + Ok(image_bytes) + } + } } async fn get_image_from_url_as_bytes(&self, url: &str) -> Result { diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 3afdefbc..1696cdb8 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,2 +1,222 @@ -pub mod cache; pub mod image; + +use bytes::Bytes; +use indexmap::IndexMap; + +#[derive(Debug)] +pub enum Error { + EntrySizeLimitExceedsTotalCapacity, + BytesExceedEntrySizeLimit, + CacheCapacityIsTooSmall, +} + +#[derive(Debug, Clone)] +pub struct BytesCacheEntry { + pub bytes: Bytes, +} + +// Individual entry destined for the byte cache. +impl BytesCacheEntry { + pub fn new(bytes: Bytes) -> Self { + Self { bytes } + } +} +#[allow(clippy::module_name_repetitions)] +pub struct BytesCache { + bytes_table: IndexMap, + total_capacity: usize, + entry_size_limit: usize, +} + +impl BytesCache { + #[must_use] + pub fn new() -> Self { + Self { + bytes_table: IndexMap::new(), + total_capacity: 0, + entry_size_limit: 0, + } + } + + // With a total capacity in bytes. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + let mut new = Self::new(); + + new.total_capacity = capacity; + + new + } + + // With a limit for individual entry sizes. + #[must_use] + pub fn with_entry_size_limit(entry_size_limit: usize) -> Self { + let mut new = Self::new(); + + new.entry_size_limit = entry_size_limit; + + new + } + + /// Helper to create a new bytes cache with both an individual entry and size limit. + /// + /// # Errors + /// + /// This function will return `Error::EntrySizeLimitExceedsTotalCapacity` if the specified size is too large. + /// + pub fn with_capacity_and_entry_size_limit(capacity: usize, entry_size_limit: usize) -> Result { + if entry_size_limit > capacity { + return Err(Error::EntrySizeLimitExceedsTotalCapacity); + } + + let mut new = Self::new(); + + new.total_capacity = capacity; + new.entry_size_limit = entry_size_limit; + + Ok(new) + } + + #[allow(clippy::unused_async)] + pub async fn get(&self, key: &str) -> Option { + self.bytes_table.get(key).cloned() + } + + // Return the amount of entries in the map. + #[allow(clippy::unused_async)] + pub async fn len(&self) -> usize { + self.bytes_table.len() + } + + #[allow(clippy::unused_async)] + pub async fn is_empty(&self) -> bool { + self.bytes_table.is_empty() + } + + // Size of all the entry bytes combined. + #[must_use] + pub fn total_size(&self) -> usize { + let mut size: usize = 0; + + for (_, entry) in self.bytes_table.iter() { + size += entry.bytes.len(); + } + + size + } + + /// Adds a image to the cache. + /// + /// # Errors + /// + /// This function will return an error if there is not enough free size. + /// + // Insert bytes using key. + // TODO: Freed space might need to be reserved. Hold and pass write lock between functions? + // For TO DO above: semaphore: Arc, might be a solution. + #[allow(clippy::unused_async)] + pub async fn set(&mut self, key: String, bytes: Bytes) -> Result, Error> { + if bytes.len() > self.entry_size_limit { + return Err(Error::BytesExceedEntrySizeLimit); + } + + // Remove the old entry so that a new entry will be added as last in the queue. + let _ = self.bytes_table.shift_remove(&key); + + let bytes_cache_entry = BytesCacheEntry::new(bytes); + + self.free_size(bytes_cache_entry.bytes.len())?; + + Ok(self.bytes_table.insert(key, bytes_cache_entry)) + } + + // Free space. Size amount in bytes. + fn free_size(&mut self, size: usize) -> Result<(), Error> { + // Size may not exceed the total capacity of the bytes cache. + if size > self.total_capacity { + return Err(Error::CacheCapacityIsTooSmall); + } + + let cache_size = self.total_size(); + let size_to_be_freed = size.saturating_sub(self.total_capacity - cache_size); + let mut size_freed: usize = 0; + + while size_freed < size_to_be_freed { + let oldest_entry = self + .pop() + .expect("bytes cache has no more entries, yet there isn't enough space."); + + size_freed += oldest_entry.bytes.len(); + } + + Ok(()) + } + + // Remove and return the oldest entry. + pub fn pop(&mut self) -> Option { + self.bytes_table.shift_remove_index(0).map(|(_, entry)| entry) + } +} + +impl Default for BytesCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use crate::cache::BytesCache; + + #[tokio::test] + async fn set_bytes_cache_with_capacity_and_entry_size_limit_should_succeed() { + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(6, 6).unwrap(); + let bytes: Bytes = Bytes::from("abcdef"); + + assert!(bytes_cache.set("1".to_string(), bytes).await.is_ok()); + } + + #[tokio::test] + async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_allow_adding_new_entries_if_the_limit_is_not_exceeded( + ) { + let bytes: Bytes = Bytes::from("abcdef"); + + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(bytes.len() * 2, bytes.len()).unwrap(); + + // Add first entry (6 bytes) + assert!(bytes_cache.set("key1".to_string(), bytes.clone()).await.is_ok()); + + // Add second entry (6 bytes) + assert!(bytes_cache.set("key2".to_string(), bytes).await.is_ok()); + + // Both entries were added because we did not reach the limit + assert_eq!(bytes_cache.len().await, 2); + } + + #[tokio::test] + async fn given_a_bytes_cache_with_a_capacity_and_entry_size_limit_it_should_not_allow_adding_new_entries_if_the_capacity_is_exceeded( + ) { + let bytes: Bytes = Bytes::from("abcdef"); + + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(bytes.len() * 2 - 1, bytes.len()).unwrap(); + + // Add first entry (6 bytes) + assert!(bytes_cache.set("key1".to_string(), bytes.clone()).await.is_ok()); + + // Add second entry (6 bytes) + assert!(bytes_cache.set("key2".to_string(), bytes).await.is_ok()); + + // Only one entry is in the cache, because otherwise the total capacity would have been exceeded + assert_eq!(bytes_cache.len().await, 1); + } + + #[tokio::test] + async fn set_bytes_cache_with_capacity_and_entry_size_limit_should_fail() { + let mut bytes_cache = BytesCache::with_capacity_and_entry_size_limit(6, 5).unwrap(); + let bytes: Bytes = Bytes::from("abcdef"); + + assert!(bytes_cache.set("1".to_string(), bytes).await.is_err()); + } +} diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs index 0863a08c..c61b9326 100644 --- a/src/routes/proxy.rs +++ b/src/routes/proxy.rs @@ -53,6 +53,11 @@ fn load_error_images() { }); } +/// Get the proxy image. +/// +/// # Errors +/// +/// This function will return `Ok` only for now. pub async fn get_proxy_image(req: HttpRequest, app_data: WebAppData, path: web::Path) -> ServiceResult { // Check for optional user. let opt_user = app_data.auth.get_user_compact_from_request(&req).await.ok(); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs index 0ee1e123..ecc3511c 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs @@ -87,7 +87,7 @@ impl TorrentTester { #[allow(clippy::missing_panics_doc)] pub async fn assert_data_in_target_db(&self, upload_path: &str) { for torrent in &self.test_data.torrents { - let filepath = self.torrent_file_path(upload_path, torrent.torrent_id); + let filepath = Self::torrent_file_path(upload_path, torrent.torrent_id); let torrent_file = read_torrent_from_file(&filepath).unwrap(); @@ -98,7 +98,7 @@ impl TorrentTester { } } - pub fn torrent_file_path(&self, upload_path: &str, torrent_id: i64) -> String { + pub fn torrent_file_path(upload_path: &str, torrent_id: i64) -> String { format!("{}/{}.torrent", &upload_path, &torrent_id) } From c71949f18dac0df9d93c48f335b104d7a38a06b4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 10 May 2023 15:40:53 +0100 Subject: [PATCH 168/357] feat: enable pedantic clippy on CI --- .github/workflows/develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index f1a0247c..dd7a40f5 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -18,7 +18,7 @@ jobs: - name: Check run: cargo check --all-targets - name: Clippy - run: cargo clippy --all-targets + run: cargo clippy --all-targets -- -D clippy::pedantic - name: Install torrent edition tool (needed for testing) run: cargo install imdl - name: Unit and integration tests From 5bd233a6ab3359950fe9b85797e03df14bf2e3ec Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 10 May 2023 16:01:55 +0100 Subject: [PATCH 169/357] feat: add env var CARGO_INCREMENTAL before running clippy The command: ``` cargo clippy --all-targets -- -D clippy::pedantic ``` shows more errors when the CARGO_INCREMENTAL env var is 0. For unknown reason. --- .github/workflows/develop.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index dd7a40f5..2ee931b2 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -18,7 +18,9 @@ jobs: - name: Check run: cargo check --all-targets - name: Clippy - run: cargo clippy --all-targets -- -D clippy::pedantic + run: cargo clippy --version && cargo clippy --all-targets -- -D clippy::pedantic + env: + CARGO_INCREMENTAL: 0 - name: Install torrent edition tool (needed for testing) run: cargo install imdl - name: Unit and integration tests From 83ec1d6b31e0b5cc0a6e150767f1ab2fd05774ff Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 15 May 2023 14:33:20 +0100 Subject: [PATCH 170/357] dev: fix clippy warnings with env var CARGO_INCREMENTAL=0 Some more clippy erros came up when you use that env var value. --- src/app.rs | 4 ++-- src/bootstrap/config.rs | 4 ++-- src/config.rs | 2 +- src/databases/mysql.rs | 18 +++++++----------- src/databases/sqlite.rs | 18 +++++++----------- src/mailer.rs | 11 +++++------ src/routes/user.rs | 2 +- .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 4 ++-- .../transferrers/category_transferrer.rs | 2 +- tests/e2e/config.rs | 7 ++----- tests/e2e/contexts/torrent/asserts.rs | 4 ++-- tests/e2e/contexts/torrent/contract.rs | 13 +++++-------- tests/e2e/contexts/torrent/steps.rs | 2 +- .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 8 ++++---- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 2 +- 15 files changed, 43 insertions(+), 58 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2322d139..85e82072 100644 --- a/src/app.rs +++ b/src/app.rs @@ -99,10 +99,10 @@ pub async fn run(configuration: Configuration) -> Running { let running_server = server.run(); - let starting_message = format!("Listening on http://{}", socket_address); + let starting_message = format!("Listening on http://{socket_address}"); info!("{}", starting_message); // Logging could be disabled or redirected to file. So print to stdout too. - println!("{}", starting_message); + println!("{starting_message}"); Running { api_server: running_server, diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 2edbd0fb..f8acdc73 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -25,13 +25,13 @@ use crate::config::Configuration; /// Will panic if configuration is not found or cannot be parsed pub async fn init_configuration() -> Configuration { if env::var(ENV_VAR_CONFIG).is_ok() { - println!("Loading configuration from env var `{}`", ENV_VAR_CONFIG); + println!("Loading configuration from env var `{ENV_VAR_CONFIG}`"); Configuration::load_from_env_var(ENV_VAR_CONFIG).unwrap() } else { let config_path = env::var(ENV_VAR_CONFIG_PATH).unwrap_or_else(|_| ENV_VAR_DEFAULT_CONFIG_PATH.to_string()); - println!("Loading configuration from config file `{}`", config_path); + println!("Loading configuration from config file `{config_path}`"); match Configuration::load_from_file(&config_path).await { Ok(config) => config, diff --git a/src/config.rs b/src/config.rs index 5a95ff2f..06259d53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -236,7 +236,7 @@ impl Configuration { let torrust_config: TorrustBackend = match config.try_deserialize() { Ok(data) => Ok(data), - Err(e) => Err(ConfigError::Message(format!("Errors while processing config: {}.", e))), + Err(e) => Err(ConfigError::Message(format!("Errors while processing config: {e}."))), }?; Ok(Configuration { diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 029ec7c1..d566a1de 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -298,7 +298,7 @@ impl Database for Mysql { ) -> Result { let title = match search { None => "%".to_string(), - Some(v) => format!("%{}%", v), + Some(v) => format!("%{v}%"), }; let sort_query: String = match sort { @@ -322,7 +322,7 @@ impl Database for Mysql { if let Ok(sanitized_category) = self.get_category_from_name(category).await { let mut str = format!("tc.name = '{}'", sanitized_category.name); if i > 0 { - str = format!(" OR {}", str); + str = format!(" OR {str}"); } category_filters.push_str(&str); i += 1; @@ -331,10 +331,7 @@ impl Database for Mysql { if category_filters.is_empty() { String::new() } else { - format!( - "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", - category_filters - ) + format!("INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({category_filters}) ") } } else { String::new() @@ -344,16 +341,15 @@ impl Database for Mysql { "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers - FROM torrust_torrents tt {} + FROM torrust_torrents tt {category_filter_query} INNER JOIN torrust_user_profiles tp ON tt.uploader_id = tp.user_id INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE title LIKE ? - GROUP BY tt.torrent_id", - category_filter_query + GROUP BY tt.torrent_id" ); - let count_query = format!("SELECT COUNT(*) as count FROM ({}) AS count_table", query_string); + let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table"); let count_result: Result = query_as(&count_query) .bind(title.clone()) @@ -364,7 +360,7 @@ impl Database for Mysql { let count = count_result?; - query_string = format!("{} ORDER BY {} LIMIT ?, ?", query_string, sort_query); + query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?"); let res: Vec = sqlx::query_as::<_, TorrentListing>(&query_string) .bind(title) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index e7792b44..70c6ac0a 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -288,7 +288,7 @@ impl Database for Sqlite { ) -> Result { let title = match search { None => "%".to_string(), - Some(v) => format!("%{}%", v), + Some(v) => format!("%{v}%"), }; let sort_query: String = match sort { @@ -312,7 +312,7 @@ impl Database for Sqlite { if let Ok(sanitized_category) = self.get_category_from_name(category).await { let mut str = format!("tc.name = '{}'", sanitized_category.name); if i > 0 { - str = format!(" OR {}", str); + str = format!(" OR {str}"); } category_filters.push_str(&str); i += 1; @@ -321,10 +321,7 @@ impl Database for Sqlite { if category_filters.is_empty() { String::new() } else { - format!( - "INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({}) ", - category_filters - ) + format!("INNER JOIN torrust_categories tc ON tt.category_id = tc.category_id AND ({category_filters}) ") } } else { String::new() @@ -334,16 +331,15 @@ impl Database for Sqlite { "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers - FROM torrust_torrents tt {} + FROM torrust_torrents tt {category_filter_query} INNER JOIN torrust_user_profiles tp ON tt.uploader_id = tp.user_id INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE title LIKE ? - GROUP BY tt.torrent_id", - category_filter_query + GROUP BY tt.torrent_id" ); - let count_query = format!("SELECT COUNT(*) as count FROM ({}) AS count_table", query_string); + let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table"); let count_result: Result = query_as(&count_query) .bind(title.clone()) @@ -354,7 +350,7 @@ impl Database for Sqlite { let count = count_result?; - query_string = format!("{} ORDER BY {} LIMIT ?, ?", query_string, sort_query); + query_string = format!("{query_string} ORDER BY {sort_query} LIMIT ?, ?"); let res: Vec = sqlx::query_as::<_, TorrentListing>(&query_string) .bind(title) diff --git a/src/mailer.rs b/src/mailer.rs index 64c2826e..42f5aad9 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -74,14 +74,13 @@ impl Service { let mail_body = format!( r#" - Welcome to Torrust, {}! + Welcome to Torrust, {username}! Please click the confirmation link below to verify your account. - {} + {verification_url} If this account wasn't made by you, you can ignore this email. - "#, - username, verification_url + "# ); let ctx = VerifyTemplate { @@ -112,7 +111,7 @@ impl Service { match self.mailer.send(mail).await { Ok(_res) => Ok(()), Err(e) => { - eprintln!("Failed to send email: {}", e); + eprintln!("Failed to send email: {e}"); Err(ServiceError::FailedToSendVerificationEmail) } } @@ -147,7 +146,7 @@ impl Service { base_url = cfg_base_url; } - format!("{}/user/email/verify/{}", base_url, token) + format!("{base_url}/user/email/verify/{token}") } } diff --git a/src/routes/user.rs b/src/routes/user.rs index 42083614..02a8780b 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -333,7 +333,7 @@ pub async fn ban(req: HttpRequest, app_data: WebAppData) -> ServiceResult Arc { - let source_database_connect_url = format!("sqlite://{}?mode=ro", db_filename); + let source_database_connect_url = format!("sqlite://{db_filename}?mode=ro"); Arc::new(SqliteDatabaseV1_0_0::new(&source_database_connect_url).await) } pub async fn new_db(db_filename: &str) -> Arc { - let target_database_connect_url = format!("sqlite://{}?mode=rwc", db_filename); + let target_database_connect_url = format!("sqlite://{db_filename}?mode=rwc"); Arc::new(SqliteDatabaseV2_0_0::new(&target_database_connect_url).await) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs index 4226a944..269f26b8 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -11,7 +11,7 @@ pub async fn transfer_categories(source_database: Arc, tar println!("[v1] categories: {:?}", &source_categories); let result = target_database.reset_categories_sequence().await.unwrap(); - println!("[v2] reset categories sequence result: {:?}", result); + println!("[v2] reset categories sequence result: {result:?}"); for cat in &source_categories { println!("[v2] adding category {:?} with id {:?} ...", &cat.name, &cat.category_id); diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index f0c20397..f3179f43 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -25,14 +25,11 @@ pub const ENV_VAR_E2E_DEFAULT_CONFIG_PATH: &str = "./config-idx-back.local.toml" /// Will panic if configuration is not found or cannot be parsed pub async fn init_shared_env_configuration() -> Configuration { if env::var(ENV_VAR_E2E_CONFIG).is_ok() { - println!("Loading configuration for E2E env from env var `{}`", ENV_VAR_E2E_CONFIG); + println!("Loading configuration for E2E env from env var `{ENV_VAR_E2E_CONFIG}`"); Configuration::load_from_env_var(ENV_VAR_E2E_CONFIG).unwrap() } else { - println!( - "Loading configuration for E2E env from config file `{}`", - ENV_VAR_E2E_DEFAULT_CONFIG_PATH - ); + println!("Loading configuration for E2E env from config file `{ENV_VAR_E2E_DEFAULT_CONFIG_PATH}`"); match Configuration::load_from_file(ENV_VAR_E2E_DEFAULT_CONFIG_PATH).await { Ok(config) => config, diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs index 6e191a9d..f8a31714 100644 --- a/tests/e2e/contexts/torrent/asserts.rs +++ b/tests/e2e/contexts/torrent/asserts.rs @@ -17,7 +17,7 @@ pub async fn expected_torrent(mut uploaded_torrent: Torrent, env: &TestEnv, down // by the backend. For some of them it makes sense (`announce` and `announce_list`), // for others it does not. - let tracker_url = format!("{}", env.server_settings().unwrap().tracker.url); + let tracker_url = env.server_settings().unwrap().tracker.url.to_string(); let tracker_key = match downloader { Some(logged_in_user) => get_user_tracker_key(logged_in_user, env).await, @@ -65,7 +65,7 @@ pub fn build_announce_url(tracker_url: &str, tracker_key: &Option) - if let Some(key) = &tracker_key { format!("{tracker_url}/{}", key.key) } else { - format!("{tracker_url}") + tracker_url.to_string() } } diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 4ee2bfe3..4e4fa638 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -107,11 +107,7 @@ mod for_guests { // When we request more torrents than the page size limit let response = client .get_torrents(Query::with_params( - [QueryParam::new( - "page_size", - &format!("{}", (max_torrent_page_size + 1).to_string()), - )] - .to_vec(), + [QueryParam::new("page_size", &format!("{}", (max_torrent_page_size + 1)))].to_vec(), )) .await; @@ -172,7 +168,7 @@ mod for_guests { let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); - let tracker_url = format!("{}", env.server_settings().unwrap().tracker.url); + let tracker_url = env.server_settings().unwrap().tracker.url; let encoded_tracker_url = urlencoding::encode(&tracker_url); let expected_torrent = TorrentDetails { @@ -396,7 +392,7 @@ mod for_authenticated_users { // Upload the second torrent with the same infohash as the first one. // We need to change the title otherwise the torrent will be rejected // because of the duplicate title. - first_torrent_clone.index_info.title = format!("{}-clone", first_torrent_title); + first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); let response = client.upload_torrent(form.into()).await; @@ -430,7 +426,8 @@ mod for_authenticated_users { let tracker_key = get_user_tracker_key(&downloader, &env) .await .expect("uploader should have a valid tracker key"); - let tracker_url = format!("{}", env.server_settings().unwrap().tracker.url); + + let tracker_url = env.server_settings().unwrap().tracker.url; assert_eq!( torrent.announce.unwrap(), diff --git a/tests/e2e/contexts/torrent/steps.rs b/tests/e2e/contexts/torrent/steps.rs index a88d4aef..57a9f0ba 100644 --- a/tests/e2e/contexts/torrent/steps.rs +++ b/tests/e2e/contexts/torrent/steps.rs @@ -23,7 +23,7 @@ pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexI let res = serde_json::from_str::(&response.body); if res.is_err() { - println!("Error deserializing response: {:?}", res); + println!("Error deserializing response: {res:?}"); } TorrentListedInIndex::from(torrent.clone(), res.unwrap().data.torrent_id) diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index 2b8dd1c4..f839ae41 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -14,7 +14,7 @@ pub struct SqliteDatabaseV1_0_0 { impl SqliteDatabaseV1_0_0 { pub async fn db_connection(database_file: &str) -> Self { - let connect_url = format!("sqlite://{}?mode=rwc", database_file); + let connect_url = format!("sqlite://{database_file}?mode=rwc"); Self::new(&connect_url).await } @@ -28,7 +28,7 @@ impl SqliteDatabaseV1_0_0 { /// Execute migrations for database in version v1.0.0 pub async fn migrate(&self, fixtures_dir: &str) { - let migrations_dir = format!("{}database/v1.0.0/migrations/", fixtures_dir); + let migrations_dir = format!("{fixtures_dir}database/v1.0.0/migrations/"); let migrations = vec![ "20210831113004_torrust_users.sql", @@ -47,13 +47,13 @@ impl SqliteDatabaseV1_0_0 { } async fn run_migration_from_file(&self, migration_file_path: &str) { - println!("Executing migration: {:?}", migration_file_path); + println!("Executing migration: {migration_file_path:?}"); let sql = fs::read_to_string(migration_file_path).expect("Should have been able to read the file"); let res = sqlx::query(&sql).execute(&self.pool).await; - println!("Migration result {:?}", res); + println!("Migration result {res:?}"); } pub async fn insert_category(&self, category: &CategoryRecordV1) -> Result { diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs index eff4187e..70903c47 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs @@ -72,7 +72,7 @@ pub struct SqliteDatabaseV2_0_0 { impl SqliteDatabaseV2_0_0 { pub async fn db_connection(database_file: &str) -> Self { - let connect_url = format!("sqlite://{}?mode=rwc", database_file); + let connect_url = format!("sqlite://{database_file}?mode=rwc"); Self::new(&connect_url).await } From 07943f1d58f722637ada2cb97ac21732f6b71f0e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 10 May 2023 16:27:56 +0100 Subject: [PATCH 171/357] docs: add adr for lowercase infohashes --- adrs/20230510152112_lowercas_infohashes.md | 20 +++++++++++++++++++ adrs/README.md | 23 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 adrs/20230510152112_lowercas_infohashes.md create mode 100644 adrs/README.md diff --git a/adrs/20230510152112_lowercas_infohashes.md b/adrs/20230510152112_lowercas_infohashes.md new file mode 100644 index 00000000..f03bb12b --- /dev/null +++ b/adrs/20230510152112_lowercas_infohashes.md @@ -0,0 +1,20 @@ +# Lowercase infohashes + +## Description + +We use both uppercase and lowercase infohashes. This is a problem because +we have to check both cases. For example, we have to convert to uppercase before +inserting into the database or querying the database. + +The database and API URLs use uppercase infohashes, and they are case-sensitive. + +## Agreement + +We agree on use lowercase infohashes everywhere and try to convert then as soon +as possible from the input. + +There is no specific reason to use lowercase infohashes, but we have to choose +one of them. We decided to use lowercase because the infohash is a hash, and +hashes are usually lowercase. + +We will change them progressively. diff --git a/adrs/README.md b/adrs/README.md new file mode 100644 index 00000000..85986fc3 --- /dev/null +++ b/adrs/README.md @@ -0,0 +1,23 @@ +# Architectural Decision Records (ADRs) + +This directory contains the architectural decision records (ADRs) for the +project. ADRs are a way to document the architectural decisions made in the +project. + +More info: . + +## How to add a new record + +For the prefix: + +```s +date -u +"%Y%m%d%H%M%S" +``` + +Then you can create a new markdown file with the following format: + +```s +20230510152112_title.md +``` + +For the time being, we are not following any specific template. From 25016e004a712d36441947aa712c36b287ee7fdb Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 16 May 2023 13:14:03 +0100 Subject: [PATCH 172/357] feat: [#146] return infohash after successfully uploading a torrent --- src/models/response.rs | 1 + src/routes/torrent.rs | 8 ++++++-- tests/common/contexts/torrent/responses.rs | 1 + tests/e2e/contexts/torrent/contract.rs | 13 ++++++------- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/models/response.rs b/src/models/response.rs index 8340c4f0..cbcb3c90 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -32,6 +32,7 @@ pub struct TokenResponse { #[derive(Serialize, Deserialize, Debug)] pub struct NewTorrentResponse { pub torrent_id: i64, + pub info_hash: String, } #[allow(clippy::module_name_repetitions)] diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 0f121fd4..b9018bcd 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -133,7 +133,10 @@ pub async fn upload(req: HttpRequest, payload: Multipart, app_data: WebAppData) // respond with the newly uploaded torrent id Ok(HttpResponse::Ok().json(OkResponse { - data: NewTorrentResponse { torrent_id }, + data: NewTorrentResponse { + torrent_id, + info_hash: torrent_request.torrent.info_hash(), + }, })) } @@ -348,12 +351,13 @@ pub async fn delete(req: HttpRequest, app_data: WebAppData) -> ServiceResult Date: Tue, 16 May 2023 14:00:39 +0100 Subject: [PATCH 173/357] chore: normalize infohash -> info_hash --- src/databases/database.rs | 8 +++--- src/databases/mysql.rs | 8 +++--- src/databases/sqlite.rs | 8 +++--- src/models/info_hash.rs | 14 +++++----- src/routes/torrent.rs | 22 ++++++++-------- tests/common/client.rs | 12 ++++----- tests/common/contexts/torrent/fixtures.rs | 2 +- tests/e2e/contexts/torrent/contract.rs | 32 +++++++++++------------ 8 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index ccbd4bf6..2bc68865 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -172,8 +172,8 @@ pub trait Database: Sync + Send { ) -> Result; /// Get `Torrent` from `InfoHash`. - async fn get_torrent_from_infohash(&self, infohash: &InfoHash) -> Result { - let torrent_info = self.get_torrent_info_from_infohash(infohash).await?; + async fn get_torrent_from_info_hash(&self, info_hash: &InfoHash) -> Result { + let torrent_info = self.get_torrent_info_from_info_hash(info_hash).await?; let torrent_files = self.get_torrent_files_from_id(torrent_info.torrent_id).await?; @@ -205,7 +205,7 @@ pub trait Database: Sync + Send { async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; /// Get torrent's info as `DbTorrentInfo` from torrent `InfoHash`. - async fn get_torrent_info_from_infohash(&self, info_hash: &InfoHash) -> Result; + async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result; /// Get all torrent's files as `Vec` from `torrent_id`. async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, Error>; @@ -217,7 +217,7 @@ pub trait Database: Sync + Send { async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result; /// Get `TorrentListing` from `InfoHash`. - async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result; + async fn get_torrent_listing_from_info_hash(&self, info_hash: &InfoHash) -> Result; /// Get all torrents as `Vec`. async fn get_all_torrents_compact(&self) -> Result, Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index d566a1de..7985d752 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -541,11 +541,11 @@ impl Database for Mysql { .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) - .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string + .bind(info_hash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) @@ -603,7 +603,7 @@ impl Database for Mysql { .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_listing_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -615,7 +615,7 @@ impl Database for Mysql { WHERE tt.info_hash = ? GROUP BY torrent_id" ) - .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string + .bind(info_hash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 70c6ac0a..c2678e1c 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -531,11 +531,11 @@ impl Database for Sqlite { .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_info_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) - .bind(infohash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string + .bind(info_hash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) @@ -593,7 +593,7 @@ impl Database for Sqlite { .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_listing_from_infohash(&self, infohash: &InfoHash) -> Result { + async fn get_torrent_listing_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, TorrentListing>( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, @@ -605,7 +605,7 @@ impl Database for Sqlite { WHERE tt.info_hash = ? GROUP BY ts.torrent_id" ) - .bind(infohash.to_string().to_uppercase()) // `info_hash` is stored as uppercase + .bind(info_hash.to_string().to_uppercase()) // `info_hash` is stored as uppercase .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs index 7392c791..3925d4a4 100644 --- a/src/models/info_hash.rs +++ b/src/models/info_hash.rs @@ -6,11 +6,11 @@ //! See [BEP 3. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) //! for the official specification. //! -//! This modules provides a type that can be used to represent infohashes. +//! This modules provides a type that can be used to represent info-hashes. //! //! > **NOTICE**: It only supports Info Hash v1. //! -//! Typically infohashes are represented as hex strings, but internally they are +//! Typically info-hashes are represented as hex strings, but internally they are //! a 20-byte array. //! //! # Calculating the info-hash of a torrent file @@ -109,7 +109,7 @@ //! } //! ``` //! -//! The infohash is the [SHA1](https://en.wikipedia.org/wiki/SHA-1) hash +//! The info-hash is the [SHA1](https://en.wikipedia.org/wiki/SHA-1) hash //! of the `info` attribute. That is, the SHA1 hash of: //! //! ```text @@ -217,14 +217,14 @@ impl std::convert::From<[u8; 20]> for InfoHash { /// Errors that can occur when converting from a `Vec` to an `InfoHash`. #[derive(Error, Debug)] pub enum ConversionError { - /// Not enough bytes for infohash. An infohash is 20 bytes. - #[error("not enough bytes for infohash: {message} {location}")] + /// Not enough bytes for info-hash. An info-hash is 20 bytes. + #[error("not enough bytes for info-hash: {message} {location}")] NotEnoughBytes { location: &'static Location<'static>, message: String, }, - /// Too many bytes for infohash. An infohash is 20 bytes. - #[error("too many bytes for infohash: {message} {location}")] + /// Too many bytes for info-hash. An info-hash is 20 bytes. + #[error("too many bytes for info-hash: {message} {location}")] TooManyBytes { location: &'static Location<'static>, message: String, diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index b9018bcd..e670c27d 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -144,14 +144,14 @@ pub async fn upload(req: HttpRequest, payload: Multipart, app_data: WebAppData) /// /// # Errors /// -/// Returns `ServiceError::BadRequest` if the torrent infohash is invalid. +/// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let info_hash = get_torrent_infohash_from_request(&req)?; + let info_hash = get_torrent_info_hash_from_request(&req)?; // optional let user = app_data.auth.get_user_compact_from_request(&req).await; - let mut torrent = app_data.database.get_torrent_from_infohash(&info_hash).await?; + let mut torrent = app_data.database.get_torrent_from_info_hash(&info_hash).await?; let settings = app_data.cfg.settings.read().await; @@ -199,9 +199,9 @@ pub async fn get(req: HttpRequest, app_data: WebAppData) -> ServiceResult ServiceResult ServiceResult, app_data: WebAppData) -> ServiceResult { let user = app_data.auth.get_user_compact_from_request(&req).await?; - let infohash = get_torrent_infohash_from_request(&req)?; + let info_hash = get_torrent_info_hash_from_request(&req)?; - let torrent_listing = app_data.database.get_torrent_listing_from_infohash(&infohash).await?; + let torrent_listing = app_data.database.get_torrent_listing_from_info_hash(&info_hash).await?; // check if user is owner or administrator if torrent_listing.uploader != user.username && !user.administrator { @@ -341,10 +341,10 @@ pub async fn delete(req: HttpRequest, app_data: WebAppData) -> ServiceResult, app_data: WebAppData) - Ok(HttpResponse::Ok().json(OkResponse { data: torrents_response })) } -fn get_torrent_infohash_from_request(req: &HttpRequest) -> Result { +fn get_torrent_info_hash_from_request(req: &HttpRequest) -> Result { match req.match_info().get("info_hash") { None => Err(ServiceError::BadRequest), Some(info_hash) => match InfoHash::from_str(info_hash) { diff --git a/tests/common/client.rs b/tests/common/client.rs index 113e2f5a..0135a517 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -93,17 +93,17 @@ impl Client { self.http_client.get("torrents", params).await } - pub async fn get_torrent(&self, infohash: &InfoHash) -> TextResponse { - self.http_client.get(&format!("torrent/{infohash}"), Query::empty()).await + pub async fn get_torrent(&self, info_hash: &InfoHash) -> TextResponse { + self.http_client.get(&format!("torrent/{info_hash}"), Query::empty()).await } - pub async fn delete_torrent(&self, infohash: &InfoHash) -> TextResponse { - self.http_client.delete(&format!("torrent/{infohash}")).await + pub async fn delete_torrent(&self, info_hash: &InfoHash) -> TextResponse { + self.http_client.delete(&format!("torrent/{info_hash}")).await } - pub async fn update_torrent(&self, infohash: &InfoHash, update_torrent_form: UpdateTorrentFrom) -> TextResponse { + pub async fn update_torrent(&self, info_hash: &InfoHash, update_torrent_form: UpdateTorrentFrom) -> TextResponse { self.http_client - .put(&format!("torrent/{infohash}"), &update_torrent_form) + .put(&format!("torrent/{info_hash}"), &update_torrent_form) .await } diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index 34146adf..e4ce70f1 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -92,7 +92,7 @@ impl TestTorrent { } } - pub fn infohash(&self) -> InfoHash { + pub fn info_hash(&self) -> InfoHash { self.file_info.info_hash.clone() } } diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 54ee3646..929e0cea 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -150,7 +150,7 @@ mod for_guests { } #[tokio::test] - async fn it_should_allow_guests_to_get_torrent_details_searching_by_infohash() { + async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { let mut env = TestEnv::new(); env.start().await; @@ -164,7 +164,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.get_torrent(&test_torrent.infohash()).await; + let response = client.get_torrent(&test_torrent.info_hash()).await; let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); @@ -213,7 +213,7 @@ mod for_guests { } #[tokio::test] - async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_infohash() { + async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { let mut env = TestEnv::new(); env.start().await; @@ -227,7 +227,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.download_torrent(&test_torrent.infohash()).await; + let response = client.download_torrent(&test_torrent.info_hash()).await; let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); let uploaded_torrent = @@ -272,7 +272,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.delete_torrent(&test_torrent.infohash()).await; + let response = client.delete_torrent(&test_torrent.info_hash()).await; assert_eq!(response.status, 401); } @@ -305,7 +305,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let test_torrent = random_torrent(); - let infohash = test_torrent.infohash().clone(); + let info_hash = test_torrent.info_hash().clone(); let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); @@ -315,7 +315,7 @@ mod for_authenticated_users { assert_eq!( uploaded_torrent_response.data.info_hash.to_lowercase(), - infohash.to_lowercase() + info_hash.to_lowercase() ); assert!(response.is_json_and_ok()); } @@ -369,7 +369,7 @@ mod for_authenticated_users { } #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_infohash_that_already_exists() { + async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { let mut env = TestEnv::new(); env.start().await; @@ -388,7 +388,7 @@ mod for_authenticated_users { let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); let _response = client.upload_torrent(form.into()).await; - // Upload the second torrent with the same infohash as the first one. + // Upload the second torrent with the same info-hash as the first one. // We need to change the title otherwise the torrent will be rejected // because of the duplicate title. first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); @@ -417,7 +417,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); // When the user downloads the torrent - let response = client.download_torrent(&test_torrent.infohash()).await; + let response = client.download_torrent(&test_torrent.info_hash()).await; let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); @@ -456,7 +456,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - let response = client.delete_torrent(&test_torrent.infohash()).await; + let response = client.delete_torrent(&test_torrent.info_hash()).await; assert_eq!(response.status, 403); } @@ -484,7 +484,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.infohash(), + &test_torrent.info_hash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -524,7 +524,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.infohash(), + &test_torrent.info_hash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -551,7 +551,7 @@ mod for_authenticated_users { use crate::e2e::environment::TestEnv; #[tokio::test] - async fn it_should_allow_admins_to_delete_torrents_searching_by_infohash() { + async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { let mut env = TestEnv::new(); env.start().await; @@ -566,7 +566,7 @@ mod for_authenticated_users { let admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); - let response = client.delete_torrent(&test_torrent.infohash()).await; + let response = client.delete_torrent(&test_torrent.info_hash()).await; let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); @@ -595,7 +595,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.infohash(), + &test_torrent.info_hash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), From 57642ea2784f0e8ba912da2c6369d760eee921a5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 16 May 2023 14:23:51 +0100 Subject: [PATCH 174/357] feat!: [#143] move config option torrent_info_update_interval from database section to a new section: ``` [tracker_statistics_importer] torrent_info_update_interval = 3600 ``` --- config-idx-back.local.toml | 3 ++- config.local.toml | 4 +++- src/app.rs | 4 ++-- src/config.rs | 16 ++++++++++++++-- tests/common/contexts/settings/mod.rs | 20 +++++++++++++++++--- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/config-idx-back.local.toml b/config-idx-back.local.toml index 8cd29e90..5f93c0e6 100644 --- a/config-idx-back.local.toml +++ b/config-idx-back.local.toml @@ -20,7 +20,6 @@ secret_key = "MaxVerstappenWC2021" [database] connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc" # SQLite #connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL -torrent_info_update_interval = 3600 [mail] email_verification_enabled = false @@ -42,3 +41,5 @@ user_quota_bytes = 64000000 default_torrent_page_size = 10 max_torrent_page_size = 30 +[tracker_statistics_importer] +torrent_info_update_interval = 3600 diff --git a/config.local.toml b/config.local.toml index 0e17b607..5a11fbea 100644 --- a/config.local.toml +++ b/config.local.toml @@ -19,7 +19,6 @@ secret_key = "MaxVerstappenWC2021" [database] connect_url = "sqlite://data.db?mode=rwc" -torrent_info_update_interval = 3600 [mail] email_verification_enabled = false @@ -40,3 +39,6 @@ user_quota_bytes = 64000000 [api] default_torrent_page_size = 10 max_torrent_page_size = 30 + +[tracker_statistics_importer] +torrent_info_update_interval = 3600 diff --git a/src/app.rs b/src/app.rs index 85e82072..3dadfb5f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,7 +32,7 @@ pub async fn run(configuration: Configuration) -> Running { let settings = cfg.settings.read().await; let database_connect_url = settings.database.connect_url.clone(); - let database_torrent_info_update_interval = settings.database.torrent_info_update_interval; + let torrent_info_update_interval = settings.tracker_statistics_importer.torrent_info_update_interval; let net_port = settings.net.port; // IMPORTANT: drop settings before starting server to avoid read locks that @@ -67,7 +67,7 @@ pub async fn run(configuration: Configuration) -> Running { let weak_tracker_statistics_importer = Arc::downgrade(&tracker_statistics_importer); let tracker_statistics_importer_handle = tokio::spawn(async move { - let interval = std::time::Duration::from_secs(database_torrent_info_update_interval); + let interval = std::time::Duration::from_secs(torrent_info_update_interval); let mut interval = tokio::time::interval(interval); interval.tick().await; // first tick is immediate... loop { diff --git a/src/config.rs b/src/config.rs index 06259d53..f6e5589d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -108,14 +108,12 @@ impl Default for Auth { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Database { pub connect_url: String, - pub torrent_info_update_interval: u64, } impl Default for Database { fn default() -> Self { Self { connect_url: "sqlite://data.db?mode=rwc".to_string(), - torrent_info_update_interval: 3600, } } } @@ -170,6 +168,19 @@ impl Default for Api { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackerStatisticsImporter { + pub torrent_info_update_interval: u64, +} + +impl Default for TrackerStatisticsImporter { + fn default() -> Self { + Self { + torrent_info_update_interval: 3600, + } + } +} + impl Default for ImageCache { fn default() -> Self { Self { @@ -192,6 +203,7 @@ pub struct TorrustBackend { pub mail: Mail, pub image_cache: ImageCache, pub api: Api, + pub tracker_statistics_importer: TrackerStatisticsImporter, } #[derive(Debug)] diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index 604297f4..8bd0c5f2 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -4,7 +4,8 @@ pub mod responses; use serde::{Deserialize, Serialize}; use torrust_index_backend::config::{ Api as DomainApi, Auth as DomainAuth, Database as DomainDatabase, ImageCache as DomainImageCache, Mail as DomainMail, - Network as DomainNetwork, TorrustBackend as DomainSettings, Tracker as DomainTracker, Website as DomainWebsite, + Network as DomainNetwork, TorrustBackend as DomainSettings, Tracker as DomainTracker, + TrackerStatisticsImporter as DomainTrackerStatisticsImporter, Website as DomainWebsite, }; #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] @@ -17,6 +18,7 @@ pub struct Settings { pub mail: Mail, pub image_cache: ImageCache, pub api: Api, + pub tracker_statistics_importer: TrackerStatisticsImporter, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] @@ -50,7 +52,6 @@ pub struct Auth { #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] pub struct Database { pub connect_url: String, - pub torrent_info_update_interval: u64, } #[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] @@ -79,6 +80,11 @@ pub struct Api { pub max_torrent_page_size: u8, } +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct TrackerStatisticsImporter { + pub torrent_info_update_interval: u64, +} + impl From for Settings { fn from(settings: DomainSettings) -> Self { Settings { @@ -90,6 +96,7 @@ impl From for Settings { mail: Mail::from(settings.mail), image_cache: ImageCache::from(settings.image_cache), api: Api::from(settings.api), + tracker_statistics_importer: TrackerStatisticsImporter::from(settings.tracker_statistics_importer), } } } @@ -136,7 +143,6 @@ impl From for Database { fn from(database: DomainDatabase) -> Self { Self { connect_url: database.connect_url, - torrent_info_update_interval: database.torrent_info_update_interval, } } } @@ -175,3 +181,11 @@ impl From for Api { } } } + +impl From for TrackerStatisticsImporter { + fn from(tracker_statistics_importer: DomainTrackerStatisticsImporter) -> Self { + Self { + torrent_info_update_interval: tracker_statistics_importer.torrent_info_update_interval, + } + } +} From 260452f72485ed3374e9ba56ad654e9d535f9dcf Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 16 May 2023 16:24:57 +0100 Subject: [PATCH 175/357] ci: [#152] add workflow to publish on crates.io --- .github/workflows/publish_crate.yml | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/publish_crate.yml diff --git a/.github/workflows/publish_crate.yml b/.github/workflows/publish_crate.yml new file mode 100644 index 00000000..4d5d0772 --- /dev/null +++ b/.github/workflows/publish_crate.yml @@ -0,0 +1,57 @@ +name: Publish crate + +on: + push: + tags: + - "v*" + +jobs: + check-secret: + runs-on: ubuntu-latest + environment: crates-io-torrust + outputs: + publish: ${{ steps.check.outputs.publish }} + steps: + - id: check + env: + CRATES_TOKEN: "${{ secrets.CRATES_TOKEN }}" + if: "${{ env.CRATES_TOKEN != '' }}" + run: echo "publish=true" >> $GITHUB_OUTPUT + + test: + needs: check-secret + if: needs.check-secret.outputs.publish == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - name: Run Tests + run: cargo test + + publish: + needs: test + if: needs.check-secret.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: crates-io-torrust + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Publish workspace packages + run: | + cargo publish -p torrust-tracker-located-error + cargo publish -p torrust-tracker-primitives + cargo publish -p torrust-tracker-configuration + cargo publish -p torrust-tracker-test-helpers + cargo publish -p torrust-tracker + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_TOKEN }} From 75ec0a61903796097f9dcc59950ad2400ba7a840 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 16 May 2023 16:35:24 +0100 Subject: [PATCH 176/357] feat: release 2.0.0-alpha.1 --- Cargo.lock | 2 +- Cargo.toml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4daab711..c04ca2b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "torrust-index-backend" -version = "2.0.0-dev.1" +version = "2.0.0-alpha.1" dependencies = [ "actix-cors", "actix-multipart", diff --git a/Cargo.toml b/Cargo.toml index 0d92bcdb..ade5ac7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "torrust-index-backend" -version = "2.0.0-dev.1" +description = "The backend (API) for the Torrust Index project." +license-file = "COPYRIGHT" +version = "2.0.0-alpha.1" authors = ["Mick van Dijke ", "Wesley Bijleveld "] +repository = "https://github.com/torrust/torrust-index-backend" edition = "2021" default-run = "main" From 7c0792014fe592a3cef673f8441fb1823761427e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 17 May 2023 12:11:58 +0100 Subject: [PATCH 177/357] ci: install imdl binary Unit tests have a dependency with the binary: `imdl`. It's used to create and parse torrents. Before running tests with `cargo test` you have to install with: `cargo install imdl`. --- .github/workflows/publish_crate.yml | 2 ++ .github/workflows/publish_docker_image.yml | 2 ++ .github/workflows/release.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/publish_crate.yml b/.github/workflows/publish_crate.yml index 4d5d0772..1d104d72 100644 --- a/.github/workflows/publish_crate.yml +++ b/.github/workflows/publish_crate.yml @@ -29,6 +29,8 @@ jobs: toolchain: stable components: llvm-tools-preview - uses: Swatinem/rust-cache@v2 + - name: Install torrent edition tool (needed for testing) + run: cargo install imdl - name: Run Tests run: cargo test diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml index f36bb714..5317a4f4 100644 --- a/.github/workflows/publish_docker_image.yml +++ b/.github/workflows/publish_docker_image.yml @@ -35,6 +35,8 @@ jobs: toolchain: stable components: llvm-tools-preview - uses: Swatinem/rust-cache@v2 + - name: Install torrent edition tool (needed for testing) + run: cargo install imdl - name: Run Tests run: cargo test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10c62fb8..87f43d34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,8 @@ jobs: run: sleep 15s shell: bash - uses: Swatinem/rust-cache@v1 + - name: Install torrent edition tool (needed for testing) + run: cargo install imdl - name: Run tests run: cargo test - name: Stop databases From 02c4a7eaceeabe7b0f8a1eff9a3bcbd80f6bf2ac Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 17 May 2023 13:13:18 +0100 Subject: [PATCH 178/357] feat!: add API version prefix in API URLs BREAKING CHANGE: API urls have the prefix `/v1/...` --- src/routes/about.rs | 3 +- src/routes/category.rs | 3 +- src/routes/mod.rs | 2 ++ src/routes/proxy.rs | 5 ++- src/routes/root.rs | 3 +- src/routes/settings.rs | 3 +- src/routes/torrent.rs | 7 ++-- src/routes/user.rs | 3 +- tests/common/client.rs | 59 +++++++++++++++++---------------- tests/common/connection_info.rs | 7 ++-- 10 files changed, 57 insertions(+), 38 deletions(-) diff --git a/src/routes/about.rs b/src/routes/about.rs index c5b81d2d..d6968af1 100644 --- a/src/routes/about.rs +++ b/src/routes/about.rs @@ -2,10 +2,11 @@ use actix_web::http::StatusCode; use actix_web::{web, HttpResponse, Responder}; use crate::errors::ServiceResult; +use crate::routes::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/about") + web::scope(&format!("/{API_VERSION}/about")) .service(web::resource("").route(web::get().to(get))) .service(web::resource("/license").route(web::get().to(license))), ); diff --git a/src/routes/category.rs b/src/routes/category.rs index 865d233d..c1c09f01 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -4,10 +4,11 @@ use serde::{Deserialize, Serialize}; use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::OkResponse; +use crate::routes::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/category").service( + web::scope(&format!("/{API_VERSION}/category")).service( web::resource("") .route(web::get().to(get)) .route(web::post().to(add)) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index ce833698..f9b0f2cd 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,6 +8,8 @@ pub mod settings; pub mod torrent; pub mod user; +pub const API_VERSION: &str = "v1"; + pub fn init(cfg: &mut web::ServiceConfig) { user::init(cfg); torrent::init(cfg); diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs index c61b9326..0226c628 100644 --- a/src/routes/proxy.rs +++ b/src/routes/proxy.rs @@ -8,6 +8,7 @@ use text_to_png::TextRenderer; use crate::cache::image::manager::Error; use crate::common::WebAppData; use crate::errors::ServiceResult; +use crate::routes::API_VERSION; static ERROR_IMAGE_LOADER: Once = Once::new(); @@ -27,7 +28,9 @@ const ERROR_IMAGE_USER_QUOTA_MET_TEXT: &str = "Image proxy quota met."; const ERROR_IMAGE_UNAUTHENTICATED_TEXT: &str = "Sign in to see image."; pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("/proxy").service(web::resource("/image/{url}").route(web::get().to(get_proxy_image)))); + cfg.service( + web::scope(&format!("/{API_VERSION}/proxy")).service(web::resource("/image/{url}").route(web::get().to(get_proxy_image))), + ); load_error_images(); } diff --git a/src/routes/root.rs b/src/routes/root.rs index ffeb1ed4..29004dd8 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -1,7 +1,8 @@ use actix_web::web; -use crate::routes::about; +use crate::routes::{about, API_VERSION}; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(web::scope("/").service(web::resource("").route(web::get().to(about::get)))); + cfg.service(web::scope(&format!("/{API_VERSION}")).service(web::resource("").route(web::get().to(about::get)))); } diff --git a/src/routes/settings.rs b/src/routes/settings.rs index a5c34e04..c9dba928 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -4,10 +4,11 @@ use crate::common::WebAppData; use crate::config; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::OkResponse; +use crate::routes::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/settings") + web::scope(&format!("/{API_VERSION}/settings")) .service(web::resource("").route(web::get().to(get)).route(web::post().to(update))) .service(web::resource("/name").route(web::get().to(site_name))) .service(web::resource("/public").route(web::get().to(get_public))), diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index e670c27d..0dc13881 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -14,12 +14,13 @@ use crate::errors::{ServiceError, ServiceResult}; use crate::models::info_hash::InfoHash; use crate::models::response::{NewTorrentResponse, OkResponse, TorrentResponse}; use crate::models::torrent::TorrentRequest; +use crate::routes::API_VERSION; use crate::utils::parse_torrent; use crate::AsCSV; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/torrent") + web::scope(&format!("/{API_VERSION}/torrent")) .service(web::resource("/upload").route(web::post().to(upload))) .service(web::resource("/download/{info_hash}").route(web::get().to(download_torrent_handler))) .service( @@ -29,7 +30,9 @@ pub fn init(cfg: &mut web::ServiceConfig) { .route(web::delete().to(delete)), ), ); - cfg.service(web::scope("/torrents").service(web::resource("").route(web::get().to(get_torrents_handler)))); + cfg.service( + web::scope(&format!("/{API_VERSION}/torrents")).service(web::resource("").route(web::get().to(get_torrents_handler))), + ); } #[derive(FromRow)] diff --git a/src/routes/user.rs b/src/routes/user.rs index 02a8780b..4ca0cf60 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -13,12 +13,13 @@ use crate::errors::{ServiceError, ServiceResult}; use crate::mailer::VerifyClaims; use crate::models::response::{OkResponse, TokenResponse}; use crate::models::user::UserAuthentication; +use crate::routes::API_VERSION; use crate::utils::clock; use crate::utils::regex::validate_email_address; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/user") + web::scope(&format!("/{API_VERSION}/user")) .service(web::resource("/register").route(web::post().to(register))) .service(web::resource("/login").route(web::post().to(login))) // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. diff --git a/tests/common/client.rs b/tests/common/client.rs index 0135a517..67bae6bc 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -19,12 +19,16 @@ impl Client { // todo: forms in POST requests can be passed by reference. It's already // changed for the `update_settings` method. + fn base_path() -> String { + "/v1".to_string() + } + pub fn unauthenticated(bind_address: &str) -> Self { - Self::new(ConnectionInfo::anonymous(bind_address)) + Self::new(ConnectionInfo::anonymous(bind_address, &Self::base_path())) } pub fn authenticated(bind_address: &str, token: &str) -> Self { - Self::new(ConnectionInfo::new(bind_address, token)) + Self::new(ConnectionInfo::new(bind_address, &Self::base_path(), token)) } pub fn new(connection_info: ConnectionInfo) -> Self { @@ -42,25 +46,25 @@ impl Client { // Context: about pub async fn about(&self) -> TextResponse { - self.http_client.get("about", Query::empty()).await + self.http_client.get("/about", Query::empty()).await } pub async fn license(&self) -> TextResponse { - self.http_client.get("about/license", Query::empty()).await + self.http_client.get("/about/license", Query::empty()).await } // Context: category pub async fn get_categories(&self) -> TextResponse { - self.http_client.get("category", Query::empty()).await + self.http_client.get("/category", Query::empty()).await } pub async fn add_category(&self, add_category_form: AddCategoryForm) -> TextResponse { - self.http_client.post("category", &add_category_form).await + self.http_client.post("/category", &add_category_form).await } pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> TextResponse { - self.http_client.delete_with_body("category", &delete_category_form).await + self.http_client.delete_with_body("/category", &delete_category_form).await } // Context: root @@ -72,86 +76,82 @@ impl Client { // Context: settings pub async fn get_public_settings(&self) -> TextResponse { - self.http_client.get("settings/public", Query::empty()).await + self.http_client.get("/settings/public", Query::empty()).await } pub async fn get_site_name(&self) -> TextResponse { - self.http_client.get("settings/name", Query::empty()).await + self.http_client.get("/settings/name", Query::empty()).await } pub async fn get_settings(&self) -> TextResponse { - self.http_client.get("settings", Query::empty()).await + self.http_client.get("/settings", Query::empty()).await } pub async fn update_settings(&self, update_settings_form: &UpdateSettings) -> TextResponse { - self.http_client.post("settings", &update_settings_form).await + self.http_client.post("/settings", &update_settings_form).await } // Context: torrent pub async fn get_torrents(&self, params: Query) -> TextResponse { - self.http_client.get("torrents", params).await + self.http_client.get("/torrents", params).await } pub async fn get_torrent(&self, info_hash: &InfoHash) -> TextResponse { - self.http_client.get(&format!("torrent/{info_hash}"), Query::empty()).await + self.http_client.get(&format!("/torrent/{info_hash}"), Query::empty()).await } pub async fn delete_torrent(&self, info_hash: &InfoHash) -> TextResponse { - self.http_client.delete(&format!("torrent/{info_hash}")).await + self.http_client.delete(&format!("/torrent/{info_hash}")).await } pub async fn update_torrent(&self, info_hash: &InfoHash, update_torrent_form: UpdateTorrentFrom) -> TextResponse { self.http_client - .put(&format!("torrent/{info_hash}"), &update_torrent_form) + .put(&format!("/torrent/{info_hash}"), &update_torrent_form) .await } pub async fn upload_torrent(&self, form: multipart::Form) -> TextResponse { - self.http_client.post_multipart("torrent/upload", form).await + self.http_client.post_multipart("/torrent/upload", form).await } pub async fn download_torrent(&self, info_hash: &InfoHash) -> responses::BinaryResponse { self.http_client - .get_binary(&format!("torrent/download/{info_hash}"), Query::empty()) + .get_binary(&format!("/torrent/download/{info_hash}"), Query::empty()) .await } // Context: user pub async fn register_user(&self, registration_form: RegistrationForm) -> TextResponse { - self.http_client.post("user/register", ®istration_form).await + self.http_client.post("/user/register", ®istration_form).await } pub async fn login_user(&self, registration_form: LoginForm) -> TextResponse { - self.http_client.post("user/login", ®istration_form).await + self.http_client.post("/user/login", ®istration_form).await } pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> TextResponse { - self.http_client.post("user/token/verify", &token_verification_form).await + self.http_client.post("/user/token/verify", &token_verification_form).await } pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> TextResponse { - self.http_client.post("user/token/renew", &token_verification_form).await + self.http_client.post("/user/token/renew", &token_verification_form).await } pub async fn ban_user(&self, username: Username) -> TextResponse { - self.http_client.delete(&format!("user/ban/{}", &username.value)).await + self.http_client.delete(&format!("/user/ban/{}", &username.value)).await } } /// Generic HTTP Client struct Http { connection_info: ConnectionInfo, - base_path: String, } impl Http { pub fn new(connection_info: ConnectionInfo) -> Self { - Self { - connection_info, - base_path: "/".to_string(), - } + Self { connection_info } } pub async fn get(&self, path: &str, params: Query) -> TextResponse { @@ -307,6 +307,9 @@ impl Http { } fn base_url(&self, path: &str) -> String { - format!("http://{}{}{path}", &self.connection_info.bind_address, &self.base_path) + format!( + "http://{}{}{path}", + &self.connection_info.bind_address, &self.connection_info.base_path + ) } } diff --git a/tests/common/connection_info.rs b/tests/common/connection_info.rs index 0b08f026..3f6c919e 100644 --- a/tests/common/connection_info.rs +++ b/tests/common/connection_info.rs @@ -1,20 +1,23 @@ #[derive(Clone)] pub struct ConnectionInfo { pub bind_address: String, + pub base_path: String, pub token: Option, } impl ConnectionInfo { - pub fn new(bind_address: &str, token: &str) -> Self { + pub fn new(bind_address: &str, base_path: &str, token: &str) -> Self { Self { bind_address: bind_address.to_string(), + base_path: base_path.to_string(), token: Some(token.to_string()), } } - pub fn anonymous(bind_address: &str) -> Self { + pub fn anonymous(bind_address: &str, base_path: &str) -> Self { Self { bind_address: bind_address.to_string(), + base_path: base_path.to_string(), token: None, } } From baa4c7ec1d9eb407825eae093da4f497ed1709c9 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 17 May 2023 15:29:25 +0100 Subject: [PATCH 179/357] refactor: [#157] extract service: about Decoupling services from actix-web framework. --- src/lib.rs | 1 + src/routes/about.rs | 51 +++---------------------------------- src/services/about.rs | 59 +++++++++++++++++++++++++++++++++++++++++++ src/services/mod.rs | 1 + 4 files changed, 64 insertions(+), 48 deletions(-) create mode 100644 src/services/about.rs create mode 100644 src/services/mod.rs diff --git a/src/lib.rs b/src/lib.rs index a2b7d173..ae8132b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod errors; pub mod mailer; pub mod models; pub mod routes; +pub mod services; pub mod tracker; pub mod upgrades; pub mod utils; diff --git a/src/routes/about.rs b/src/routes/about.rs index d6968af1..2a32a74e 100644 --- a/src/routes/about.rs +++ b/src/routes/about.rs @@ -3,6 +3,7 @@ use actix_web::{web, HttpResponse, Responder}; use crate::errors::ServiceResult; use crate::routes::API_VERSION; +use crate::services::about::{index_page, license_page}; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( @@ -12,24 +13,6 @@ pub fn init(cfg: &mut web::ServiceConfig) { ); } -const ABOUT: &str = r#" - - - About - - -

Torrust Index Backend

- -

About

- -

Hi! This is a running torrust-index-backend.

- - - -"#; - /// Get About Section HTML /// /// # Errors @@ -39,37 +22,9 @@ const ABOUT: &str = r#" pub async fn get() -> ServiceResult { Ok(HttpResponse::build(StatusCode::OK) .content_type("text/html; charset=utf-8") - .body(ABOUT)) + .body(index_page())) } -const LICENSE: &str = r#" - - - Licensing - - -

Torrust Index Backend

- -

Licensing

- -

Multiple Licenses

- -

This repository has multiple licenses depending on the content type, the date of contributions or stemming from external component licenses that were not developed by any of Torrust team members or Torrust repository contributors.

- -

The two main applicable license to most of its content are:

- -

- For Code -- agpl-3.0

- -

- For Media (Images, etc.) -- cc-by-sa

- -

If you want to read more about all the licenses and how they apply please refer to the contributor agreement.

- - - -"#; - /// Get the License in HTML /// /// # Errors @@ -79,5 +34,5 @@ const LICENSE: &str = r#" pub async fn license() -> ServiceResult { Ok(HttpResponse::build(StatusCode::OK) .content_type("text/html; charset=utf-8") - .body(LICENSE)) + .body(license_page())) } diff --git a/src/services/about.rs b/src/services/about.rs new file mode 100644 index 00000000..53840421 --- /dev/null +++ b/src/services/about.rs @@ -0,0 +1,59 @@ +//! Templates for "about" static pages. + +use crate::routes::API_VERSION; + +#[must_use] +pub fn index_page() -> String { + format!( + r#" + + + About + + +

Torrust Index Backend

+ +

About

+ +

Hi! This is a running torrust-index-backend.

+ + + +"# + ) +} + +#[must_use] +pub fn license_page() -> String { + format!( + r#" + + + Licensing + + +

Torrust Index Backend

+ +

Licensing

+ +

Multiple Licenses

+ +

This repository has multiple licenses depending on the content type, the date of contributions or stemming from external component licenses that were not developed by any of Torrust team members or Torrust repository contributors.

+ +

The two main applicable license to most of its content are:

+ +

- For Code -- agpl-3.0

+ +

- For Media (Images, etc.) -- cc-by-sa

+ +

If you want to read more about all the licenses and how they apply please refer to the contributor agreement.

+ + + +"# + ) +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 00000000..ced75210 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod about; From d58f3cc275417f18af3e3a66cccbffb29de19be5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 17 May 2023 16:20:35 +0100 Subject: [PATCH 180/357] refactor: [#157] extract service: category Decoupling services from actix-web framework. --- src/app.rs | 8 +++ src/auth.rs | 12 ++++- src/common.rs | 12 +++++ src/errors.rs | 10 +++- src/models/category.rs | 2 + src/models/mod.rs | 1 + src/models/user.rs | 13 +++-- src/routes/category.rs | 24 +++------ src/services/category.rs | 113 +++++++++++++++++++++++++++++++++++++++ src/services/mod.rs | 2 + src/services/user.rs | 31 +++++++++++ 11 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 src/models/category.rs create mode 100644 src/services/category.rs create mode 100644 src/services/user.rs diff --git a/src/app.rs b/src/app.rs index 3dadfb5f..625ec2aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,8 @@ use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; use crate::config::Configuration; use crate::databases::database; +use crate::services::category::{self, DbCategoryRepository}; +use crate::services::user::DbUserRepository; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, routes, tracker}; @@ -48,6 +50,9 @@ pub async fn run(configuration: Configuration) -> Running { Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); let mailer_service = Arc::new(mailer::Service::new(cfg.clone()).await); let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); + let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); + let user_repository = Arc::new(DbUserRepository::new(database.clone())); + let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); // Build app container @@ -59,6 +64,9 @@ pub async fn run(configuration: Configuration) -> Running { tracker_statistics_importer.clone(), mailer_service, image_cache_service, + category_repository, + user_repository, + category_service, )); // Start repeating task to import tracker torrent data and updating diff --git a/src/auth.rs b/src/auth.rs index a8fbf76b..609496d4 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -6,7 +6,7 @@ use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; -use crate::models::user::{UserClaims, UserCompact}; +use crate::models::user::{UserClaims, UserCompact, UserId}; use crate::utils::clock; pub struct AuthorizationService { @@ -94,4 +94,14 @@ impl AuthorizationService { .await .map_err(|_| ServiceError::UserNotFound) } + + /// Get User id from Request + /// + /// # Errors + /// + /// This function will return an error if it can get claims from the request + pub async fn get_user_id_from_request(&self, req: &HttpRequest) -> Result { + let claims = self.get_claims_from_request(req).await?; + Ok(claims.user.user_id) + } } diff --git a/src/common.rs b/src/common.rs index 51861fae..333c604c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -4,6 +4,8 @@ use crate::auth::AuthorizationService; use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; +use crate::services::category::{self, DbCategoryRepository}; +use crate::services::user::DbUserRepository; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, tracker}; pub type Username = String; @@ -18,9 +20,13 @@ pub struct AppData { pub tracker_statistics_importer: Arc, pub mailer: Arc, pub image_cache_manager: Arc, + pub category_repository: Arc, + pub user_repository: Arc, + pub category_service: Arc, } impl AppData { + #[allow(clippy::too_many_arguments)] pub fn new( cfg: Arc, database: Arc>, @@ -29,6 +35,9 @@ impl AppData { tracker_statistics_importer: Arc, mailer: Arc, image_cache_manager: Arc, + category_repository: Arc, + user_repository: Arc, + category_service: Arc, ) -> AppData { AppData { cfg, @@ -38,6 +47,9 @@ impl AppData { tracker_statistics_importer, mailer, image_cache_manager, + category_repository, + user_repository, + category_service, } } } diff --git a/src/errors.rs b/src/errors.rs index 12601e3c..0d3d4067 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -120,8 +120,14 @@ pub enum ServiceError { #[display(fmt = "Failed to send verification email.")] FailedToSendVerificationEmail, - #[display(fmt = "Category already exists..")] + #[display(fmt = "Category already exists.")] CategoryExists, + + #[display(fmt = "Category not found.")] + CategoryNotFound, + + #[display(fmt = "Database error.")] + DatabaseError, } #[derive(Serialize, Deserialize)] @@ -168,6 +174,8 @@ impl ResponseError for ServiceError { ServiceError::EmailMissing => StatusCode::NOT_FOUND, ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::CategoryNotFound => StatusCode::NOT_FOUND, } } diff --git a/src/models/category.rs b/src/models/category.rs new file mode 100644 index 00000000..76b74f20 --- /dev/null +++ b/src/models/category.rs @@ -0,0 +1,2 @@ +#[allow(clippy::module_name_repetitions)] +pub type CategoryId = i64; diff --git a/src/models/mod.rs b/src/models/mod.rs index 6a317c58..5e54368f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod category; pub mod info_hash; pub mod response; pub mod torrent; diff --git a/src/models/user.rs b/src/models/user.rs index f808c87a..b115e10c 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,8 +1,11 @@ use serde::{Deserialize, Serialize}; +#[allow(clippy::module_name_repetitions)] +pub type UserId = i64; + #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct User { - pub user_id: i64, + pub user_id: UserId, pub date_registered: Option, pub date_imported: Option, pub administrator: bool, @@ -11,14 +14,14 @@ pub struct User { #[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserAuthentication { - pub user_id: i64, + pub user_id: UserId, pub password_hash: String, } #[allow(clippy::module_name_repetitions)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserProfile { - pub user_id: i64, + pub user_id: UserId, pub username: String, pub email: String, pub email_verified: bool, @@ -29,7 +32,7 @@ pub struct UserProfile { #[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserCompact { - pub user_id: i64, + pub user_id: UserId, pub username: String, pub administrator: bool, } @@ -37,7 +40,7 @@ pub struct UserCompact { #[allow(clippy::module_name_repetitions)] #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct UserFull { - pub user_id: i64, + pub user_id: UserId, pub date_registered: Option, pub date_imported: Option, pub administrator: bool, diff --git a/src/routes/category.rs b/src/routes/category.rs index c1c09f01..113615f7 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -2,7 +2,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; use crate::common::WebAppData; -use crate::errors::{ServiceError, ServiceResult}; +use crate::errors::ServiceResult; use crate::models::response::OkResponse; use crate::routes::API_VERSION; @@ -23,7 +23,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// /// This function will return an error if there is a database error. pub async fn get(app_data: WebAppData) -> ServiceResult { - let categories = app_data.database.get_categories().await?; + let categories = app_data.category_repository.get_categories().await?; Ok(HttpResponse::Ok().json(OkResponse { data: categories })) } @@ -41,15 +41,9 @@ pub struct Category { /// This function will return an error if unable to get user. /// This function will return an error if unable to insert into the database the new category. pub async fn add(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - // check for user - let user = app_data.auth.get_user_compact_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_request(&req).await?; - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } - - let _ = app_data.database.insert_category_and_get_id(&payload.name).await?; + let _category_id = app_data.category_service.add_category(&payload.name, &user_id).await?; Ok(HttpResponse::Ok().json(OkResponse { data: payload.name.clone(), @@ -67,15 +61,9 @@ pub async fn delete(req: HttpRequest, payload: web::Json, app_data: We // And we should use the ID instead of the name, because the name could change // or we could add support for multiple languages. - // check for user - let user = app_data.auth.get_user_compact_from_request(&req).await?; - - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + let user_id = app_data.auth.get_user_id_from_request(&req).await?; - app_data.database.delete_category(&payload.name).await?; + app_data.category_service.delete_category(&payload.name, &user_id).await?; Ok(HttpResponse::Ok().json(OkResponse { data: payload.name.clone(), diff --git a/src/services/category.rs b/src/services/category.rs new file mode 100644 index 00000000..53070c5a --- /dev/null +++ b/src/services/category.rs @@ -0,0 +1,113 @@ +//! Category service. +use std::sync::Arc; + +use super::user::DbUserRepository; +use crate::databases::database::{Category, Database, Error as DatabaseError}; +use crate::errors::ServiceError; +use crate::models::category::CategoryId; +use crate::models::user::UserId; + +pub struct Service { + category_repository: Arc, + user_repository: Arc, +} + +impl Service { + #[must_use] + pub fn new(category_repository: Arc, user_repository: Arc) -> Service { + Service { + category_repository, + user_repository, + } + } + + /// Adds a new category. + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is a database error. + pub async fn add_category(&self, category_name: &str, user_id: &UserId) -> Result { + let user = self.user_repository.get_compact_user(user_id).await?; + + // Check if user is administrator + // todo: extract authorization service + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + match self.category_repository.add_category(category_name).await { + Ok(id) => Ok(id), + Err(e) => match e { + DatabaseError::CategoryAlreadyExists => Err(ServiceError::CategoryExists), + _ => Err(ServiceError::DatabaseError), + }, + } + } + + /// Deletes a new category. + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is a database error. + pub async fn delete_category(&self, category_name: &str, user_id: &UserId) -> Result<(), ServiceError> { + let user = self.user_repository.get_compact_user(user_id).await?; + + // Check if user is administrator + // todo: extract authorization service + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + match self.category_repository.delete_category(category_name).await { + Ok(_) => Ok(()), + Err(e) => match e { + DatabaseError::CategoryNotFound => Err(ServiceError::CategoryNotFound), + _ => Err(ServiceError::DatabaseError), + }, + } + } +} + +pub struct DbCategoryRepository { + database: Arc>, +} + +impl DbCategoryRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It returns the categories. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_categories(&self) -> Result, DatabaseError> { + self.database.get_categories().await + } + + /// Adds a new category. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn add_category(&self, category_name: &str) -> Result { + self.database.insert_category_and_get_id(category_name).await + } + + /// Deletes a new category. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn delete_category(&self, category_name: &str) -> Result<(), DatabaseError> { + self.database.delete_category(category_name).await + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index ced75210..901b3b82 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1 +1,3 @@ pub mod about; +pub mod category; +pub mod user; diff --git a/src/services/user.rs b/src/services/user.rs new file mode 100644 index 00000000..a1d19c8c --- /dev/null +++ b/src/services/user.rs @@ -0,0 +1,31 @@ +//! User repository. +use std::sync::Arc; + +use crate::databases::database::Database; +use crate::errors::ServiceError; +use crate::models::user::{UserCompact, UserId}; + +pub struct DbUserRepository { + database: Arc>, +} + +impl DbUserRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It returns the compact user. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_compact_user(&self, user_id: &UserId) -> Result { + // todo: persistence layer should have its own errors instead of + // returning a `ServiceError`. + self.database + .get_user_compact_from_id(*user_id) + .await + .map_err(|_| ServiceError::UserNotFound) + } +} From c9fb24980d84ad29e3e764bc3c33dfeed7a60c31 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 18 May 2023 16:38:25 +0100 Subject: [PATCH 181/357] refactor: [#157] extract service: proxy Decoupling services from actix-web framework. --- src/app.rs | 5 ++- src/common.rs | 4 ++ src/lib.rs | 1 + src/routes/proxy.rs | 99 +++++++++++++++---------------------------- src/services/mod.rs | 1 + src/services/proxy.rs | 46 ++++++++++++++++++++ src/ui/mod.rs | 2 + src/ui/proxy.rs | 56 ++++++++++++++++++++++++ 8 files changed, 147 insertions(+), 67 deletions(-) create mode 100644 src/services/proxy.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/proxy.rs diff --git a/src/app.rs b/src/app.rs index 625ec2aa..83029694 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,6 +13,7 @@ use crate::common::AppData; use crate::config::Configuration; use crate::databases::database; use crate::services::category::{self, DbCategoryRepository}; +use crate::services::proxy; use crate::services::user::DbUserRepository; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, routes, tracker}; @@ -49,10 +50,11 @@ pub async fn run(configuration: Configuration) -> Running { let tracker_statistics_importer = Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); let mailer_service = Arc::new(mailer::Service::new(cfg.clone()).await); - let image_cache_service = Arc::new(ImageCacheService::new(cfg.clone()).await); + let image_cache_service: Arc = Arc::new(ImageCacheService::new(cfg.clone()).await); let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); let user_repository = Arc::new(DbUserRepository::new(database.clone())); let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); + let proxy_service = Arc::new(proxy::Service::new(image_cache_service.clone(), user_repository.clone())); // Build app container @@ -67,6 +69,7 @@ pub async fn run(configuration: Configuration) -> Running { category_repository, user_repository, category_service, + proxy_service, )); // Start repeating task to import tracker torrent data and updating diff --git a/src/common.rs b/src/common.rs index 333c604c..4570d790 100644 --- a/src/common.rs +++ b/src/common.rs @@ -5,6 +5,7 @@ use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; use crate::services::category::{self, DbCategoryRepository}; +use crate::services::proxy; use crate::services::user::DbUserRepository; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, tracker}; @@ -23,6 +24,7 @@ pub struct AppData { pub category_repository: Arc, pub user_repository: Arc, pub category_service: Arc, + pub proxy_service: Arc, } impl AppData { @@ -38,6 +40,7 @@ impl AppData { category_repository: Arc, user_repository: Arc, category_service: Arc, + proxy_service: Arc, ) -> AppData { AppData { cfg, @@ -50,6 +53,7 @@ impl AppData { category_repository, user_repository, category_service, + proxy_service, } } } diff --git a/src/lib.rs b/src/lib.rs index ae8132b0..03213e05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod models; pub mod routes; pub mod services; pub mod tracker; +pub mod ui; pub mod upgrades; pub mod utils; diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs index 0226c628..c985b53e 100644 --- a/src/routes/proxy.rs +++ b/src/routes/proxy.rs @@ -1,31 +1,11 @@ -use std::sync::Once; - use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use bytes::Bytes; -use text_to_png::TextRenderer; use crate::cache::image::manager::Error; use crate::common::WebAppData; use crate::errors::ServiceResult; use crate::routes::API_VERSION; - -static ERROR_IMAGE_LOADER: Once = Once::new(); - -static mut ERROR_IMAGE_URL_IS_UNREACHABLE: Bytes = Bytes::new(); -static mut ERROR_IMAGE_URL_IS_NOT_AN_IMAGE: Bytes = Bytes::new(); -static mut ERROR_IMAGE_TOO_BIG: Bytes = Bytes::new(); -static mut ERROR_IMAGE_USER_QUOTA_MET: Bytes = Bytes::new(); -static mut ERROR_IMAGE_UNAUTHENTICATED: Bytes = Bytes::new(); - -const ERROR_IMG_FONT_SIZE: u8 = 16; -const ERROR_IMG_COLOR: &str = "Red"; - -const ERROR_IMAGE_URL_IS_UNREACHABLE_TEXT: &str = "Could not find image."; -const ERROR_IMAGE_URL_IS_NOT_AN_IMAGE_TEXT: &str = "Invalid image."; -const ERROR_IMAGE_TOO_BIG_TEXT: &str = "Image is too big."; -const ERROR_IMAGE_USER_QUOTA_MET_TEXT: &str = "Image proxy quota met."; -const ERROR_IMAGE_UNAUTHENTICATED_TEXT: &str = "Sign in to see image."; +use crate::ui::proxy::{load_error_images, map_error_to_image}; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( @@ -35,59 +15,46 @@ pub fn init(cfg: &mut web::ServiceConfig) { load_error_images(); } -fn generate_img_from_text(text: &str) -> Bytes { - let renderer = TextRenderer::default(); - - Bytes::from( - renderer - .render_text_to_png_data(text, ERROR_IMG_FONT_SIZE, ERROR_IMG_COLOR) - .unwrap() - .data, - ) -} - -fn load_error_images() { - ERROR_IMAGE_LOADER.call_once(|| unsafe { - ERROR_IMAGE_URL_IS_UNREACHABLE = generate_img_from_text(ERROR_IMAGE_URL_IS_UNREACHABLE_TEXT); - ERROR_IMAGE_URL_IS_NOT_AN_IMAGE = generate_img_from_text(ERROR_IMAGE_URL_IS_NOT_AN_IMAGE_TEXT); - ERROR_IMAGE_TOO_BIG = generate_img_from_text(ERROR_IMAGE_TOO_BIG_TEXT); - ERROR_IMAGE_USER_QUOTA_MET = generate_img_from_text(ERROR_IMAGE_USER_QUOTA_MET_TEXT); - ERROR_IMAGE_UNAUTHENTICATED = generate_img_from_text(ERROR_IMAGE_UNAUTHENTICATED_TEXT); - }); -} - /// Get the proxy image. /// /// # Errors /// /// This function will return `Ok` only for now. pub async fn get_proxy_image(req: HttpRequest, app_data: WebAppData, path: web::Path) -> ServiceResult { - // Check for optional user. - let opt_user = app_data.auth.get_user_compact_from_request(&req).await.ok(); - - let encoded_url = path.into_inner(); - let url = urlencoding::decode(&encoded_url).unwrap_or_default(); - - match app_data.image_cache_manager.get_image_by_url(&url, opt_user).await { - Ok(image_bytes) => Ok(HttpResponse::build(StatusCode::OK) - .content_type("image/png") - .append_header(("Cache-Control", "max-age=15552000")) - .body(image_bytes)), - Err(e) => unsafe { - // Handling status codes in the frontend other tan OK is quite a pain. - // Return OK for now. - let (_status_code, error_image_bytes): (StatusCode, Bytes) = match e { - Error::UrlIsUnreachable => (StatusCode::GATEWAY_TIMEOUT, ERROR_IMAGE_URL_IS_UNREACHABLE.clone()), - Error::UrlIsNotAnImage => (StatusCode::BAD_REQUEST, ERROR_IMAGE_URL_IS_NOT_AN_IMAGE.clone()), - Error::ImageTooBig => (StatusCode::BAD_REQUEST, ERROR_IMAGE_TOO_BIG.clone()), - Error::UserQuotaMet => (StatusCode::TOO_MANY_REQUESTS, ERROR_IMAGE_USER_QUOTA_MET.clone()), - Error::Unauthenticated => (StatusCode::UNAUTHORIZED, ERROR_IMAGE_UNAUTHENTICATED.clone()), - }; - + let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); + + match user_id { + Some(user_id) => { + // Get image URL from URL path + let encoded_image_url = path.into_inner(); + let image_url = urlencoding::decode(&encoded_image_url).unwrap_or_default(); + + match app_data.proxy_service.get_image_by_url(&image_url, &user_id).await { + Ok(image_bytes) => { + // Returns the cached image. + Ok(HttpResponse::build(StatusCode::OK) + .content_type("image/png") + .append_header(("Cache-Control", "max-age=15552000")) + .body(image_bytes)) + } + Err(e) => + // Returns an error image. + // Handling status codes in the frontend other tan OK is quite a pain. + // Return OK for now. + { + Ok(HttpResponse::build(StatusCode::OK) + .content_type("image/png") + .append_header(("Cache-Control", "no-cache")) + .body(map_error_to_image(&e))) + } + } + } + None => { + // Unauthenticated users can't see images. Ok(HttpResponse::build(StatusCode::OK) .content_type("image/png") .append_header(("Cache-Control", "no-cache")) - .body(error_image_bytes)) - }, + .body(map_error_to_image(&Error::Unauthenticated))) + } } } diff --git a/src/services/mod.rs b/src/services/mod.rs index 901b3b82..a52ba223 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,4 @@ pub mod about; pub mod category; +pub mod proxy; pub mod user; diff --git a/src/services/proxy.rs b/src/services/proxy.rs new file mode 100644 index 00000000..45ba5b34 --- /dev/null +++ b/src/services/proxy.rs @@ -0,0 +1,46 @@ +//! Image cache proxy. +//! +//! The image cache proxy is a service that allows users to proxy images +//! through the server. +//! +//! Sample URL: +//! +//! +use std::sync::Arc; + +use bytes::Bytes; + +use super::user::DbUserRepository; +use crate::cache::image::manager::{Error, ImageCacheService}; +use crate::models::user::UserId; + +pub struct Service { + image_cache_service: Arc, + user_repository: Arc, +} + +impl Service { + #[must_use] + pub fn new(image_cache_service: Arc, user_repository: Arc) -> Self { + Self { + image_cache_service, + user_repository, + } + } + + /// It gets image by URL and caches it. + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The image URL is unreachable. + /// * The image URL is not an image. + /// * The image is too big. + /// * The user quota is met. + pub async fn get_image_by_url(&self, url: &str, user_id: &UserId) -> Result { + let user = self.user_repository.get_compact_user(user_id).await.ok(); + + self.image_cache_service.get_image_by_url(url, user).await + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 00000000..143a6381 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,2 @@ +//! User interface module. Presentation layer. +pub mod proxy; diff --git a/src/ui/proxy.rs b/src/ui/proxy.rs new file mode 100644 index 00000000..a744c5b7 --- /dev/null +++ b/src/ui/proxy.rs @@ -0,0 +1,56 @@ +use std::sync::Once; + +use bytes::Bytes; +use text_to_png::TextRenderer; + +use crate::cache::image::manager::Error; + +pub static ERROR_IMAGE_LOADER: Once = Once::new(); + +pub static mut ERROR_IMAGE_URL_IS_UNREACHABLE: Bytes = Bytes::new(); +pub static mut ERROR_IMAGE_URL_IS_NOT_AN_IMAGE: Bytes = Bytes::new(); +pub static mut ERROR_IMAGE_TOO_BIG: Bytes = Bytes::new(); +pub static mut ERROR_IMAGE_USER_QUOTA_MET: Bytes = Bytes::new(); +pub static mut ERROR_IMAGE_UNAUTHENTICATED: Bytes = Bytes::new(); + +const ERROR_IMG_FONT_SIZE: u8 = 16; +const ERROR_IMG_COLOR: &str = "Red"; + +const ERROR_IMAGE_URL_IS_UNREACHABLE_TEXT: &str = "Could not find image."; +const ERROR_IMAGE_URL_IS_NOT_AN_IMAGE_TEXT: &str = "Invalid image."; +const ERROR_IMAGE_TOO_BIG_TEXT: &str = "Image is too big."; +const ERROR_IMAGE_USER_QUOTA_MET_TEXT: &str = "Image proxy quota met."; +const ERROR_IMAGE_UNAUTHENTICATED_TEXT: &str = "Sign in to see image."; + +pub fn load_error_images() { + ERROR_IMAGE_LOADER.call_once(|| unsafe { + ERROR_IMAGE_URL_IS_UNREACHABLE = generate_img_from_text(ERROR_IMAGE_URL_IS_UNREACHABLE_TEXT); + ERROR_IMAGE_URL_IS_NOT_AN_IMAGE = generate_img_from_text(ERROR_IMAGE_URL_IS_NOT_AN_IMAGE_TEXT); + ERROR_IMAGE_TOO_BIG = generate_img_from_text(ERROR_IMAGE_TOO_BIG_TEXT); + ERROR_IMAGE_USER_QUOTA_MET = generate_img_from_text(ERROR_IMAGE_USER_QUOTA_MET_TEXT); + ERROR_IMAGE_UNAUTHENTICATED = generate_img_from_text(ERROR_IMAGE_UNAUTHENTICATED_TEXT); + }); +} + +pub fn map_error_to_image(error: &Error) -> Bytes { + unsafe { + match error { + Error::UrlIsUnreachable => ERROR_IMAGE_URL_IS_UNREACHABLE.clone(), + Error::UrlIsNotAnImage => ERROR_IMAGE_URL_IS_NOT_AN_IMAGE.clone(), + Error::ImageTooBig => ERROR_IMAGE_TOO_BIG.clone(), + Error::UserQuotaMet => ERROR_IMAGE_USER_QUOTA_MET.clone(), + Error::Unauthenticated => ERROR_IMAGE_UNAUTHENTICATED.clone(), + } + } +} + +fn generate_img_from_text(text: &str) -> Bytes { + let renderer = TextRenderer::default(); + + Bytes::from( + renderer + .render_text_to_png_data(text, ERROR_IMG_FONT_SIZE, ERROR_IMG_COLOR) + .unwrap() + .data, + ) +} From 0387b9748525ffcdbefddc95e4e1ca39ea54dd77 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 18 May 2023 19:20:13 +0100 Subject: [PATCH 182/357] refactor: [#157] extract service: settings Decoupling services from actix-web framework. --- src/app.rs | 4 +- src/common.rs | 5 ++- src/config.rs | 12 ++++++ src/routes/settings.rs | 84 +++++++++++++++++----------------------- src/services/mod.rs | 1 + src/services/settings.rs | 75 +++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 50 deletions(-) create mode 100644 src/services/settings.rs diff --git a/src/app.rs b/src/app.rs index 83029694..87c6e2ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,8 +13,8 @@ use crate::common::AppData; use crate::config::Configuration; use crate::databases::database; use crate::services::category::{self, DbCategoryRepository}; -use crate::services::proxy; use crate::services::user::DbUserRepository; +use crate::services::{proxy, settings}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, routes, tracker}; @@ -55,6 +55,7 @@ pub async fn run(configuration: Configuration) -> Running { let user_repository = Arc::new(DbUserRepository::new(database.clone())); let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); let proxy_service = Arc::new(proxy::Service::new(image_cache_service.clone(), user_repository.clone())); + let settings_service = Arc::new(settings::Service::new(cfg.clone(), user_repository.clone())); // Build app container @@ -70,6 +71,7 @@ pub async fn run(configuration: Configuration) -> Running { user_repository, category_service, proxy_service, + settings_service, )); // Start repeating task to import tracker torrent data and updating diff --git a/src/common.rs b/src/common.rs index 4570d790..e37f2dfe 100644 --- a/src/common.rs +++ b/src/common.rs @@ -5,8 +5,8 @@ use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; use crate::services::category::{self, DbCategoryRepository}; -use crate::services::proxy; use crate::services::user::DbUserRepository; +use crate::services::{proxy, settings}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, tracker}; pub type Username = String; @@ -25,6 +25,7 @@ pub struct AppData { pub user_repository: Arc, pub category_service: Arc, pub proxy_service: Arc, + pub settings_service: Arc, } impl AppData { @@ -41,6 +42,7 @@ impl AppData { user_repository: Arc, category_service: Arc, proxy_service: Arc, + settings_service: Arc, ) -> AppData { AppData { cfg, @@ -54,6 +56,7 @@ impl AppData { user_repository, category_service, proxy_service, + settings_service, } } } diff --git a/src/config.rs b/src/config.rs index f6e5589d..becab7e1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -321,6 +321,12 @@ impl Configuration { } } + pub async fn get_all(&self) -> TorrustBackend { + let settings_lock = self.settings.read().await; + + settings_lock.clone() + } + pub async fn get_public(&self) -> ConfigurationPublic { let settings_lock = self.settings.read().await; @@ -331,6 +337,12 @@ impl Configuration { email_on_signup: settings_lock.auth.email_on_signup.clone(), } } + + pub async fn get_site_name(&self) -> String { + let settings_lock = self.settings.read().await; + + settings_lock.website.name.clone() + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/routes/settings.rs b/src/routes/settings.rs index c9dba928..806426b8 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -2,16 +2,20 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use crate::common::WebAppData; use crate::config; -use crate::errors::{ServiceError, ServiceResult}; +use crate::errors::ServiceResult; use crate::models::response::OkResponse; use crate::routes::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope(&format!("/{API_VERSION}/settings")) - .service(web::resource("").route(web::get().to(get)).route(web::post().to(update))) - .service(web::resource("/name").route(web::get().to(site_name))) - .service(web::resource("/public").route(web::get().to(get_public))), + .service( + web::resource("") + .route(web::get().to(get_all_handler)) + .route(web::post().to(update_handler)), + ) + .service(web::resource("/name").route(web::get().to(get_site_name_handler))) + .service(web::resource("/public").route(web::get().to(get_public_handler))), ); } @@ -20,42 +24,12 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// # Errors /// /// This function will return an error if unable to get user from database. -pub async fn get(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - // check for user - let user = app_data.auth.get_user_compact_from_request(&req).await?; +pub async fn get_all_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { + let user_id = app_data.auth.get_user_id_from_request(&req).await?; - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } + let all_settings = app_data.settings_service.get_all(&user_id).await?; - let settings: tokio::sync::RwLockReadGuard = app_data.cfg.settings.read().await; - - Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) -} - -/// Get Public Settings -/// -/// # Errors -/// -/// This function should not return an error. -pub async fn get_public(app_data: WebAppData) -> ServiceResult { - let public_settings = app_data.cfg.get_public().await; - - Ok(HttpResponse::Ok().json(OkResponse { data: public_settings })) -} - -/// Get Name of Website -/// -/// # Errors -/// -/// This function should not return an error. -pub async fn site_name(app_data: WebAppData) -> ServiceResult { - let settings = app_data.cfg.settings.read().await; - - Ok(HttpResponse::Ok().json(OkResponse { - data: &settings.website.name, - })) + Ok(HttpResponse::Ok().json(OkResponse { data: all_settings })) } /// Update the settings @@ -67,22 +41,36 @@ pub async fn site_name(app_data: WebAppData) -> ServiceResult { /// - There is no logged-in user. /// - The user is not an administrator. /// - The settings could not be updated because they were loaded from env vars. -pub async fn update( +pub async fn update_handler( req: HttpRequest, payload: web::Json, app_data: WebAppData, ) -> ServiceResult { - // check for user - let user = app_data.auth.get_user_compact_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_request(&req).await?; + + let new_settings = app_data.settings_service.update_all(payload.into_inner(), &user_id).await?; + + Ok(HttpResponse::Ok().json(OkResponse { data: new_settings })) +} - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } +/// Get Public Settings +/// +/// # Errors +/// +/// This function should not return an error. +pub async fn get_public_handler(app_data: WebAppData) -> ServiceResult { + let public_settings = app_data.settings_service.get_public().await; - let _ = app_data.cfg.update_settings(payload.into_inner()).await; + Ok(HttpResponse::Ok().json(OkResponse { data: public_settings })) +} - let settings = app_data.cfg.settings.read().await; +/// Get Name of Website +/// +/// # Errors +/// +/// This function should not return an error. +pub async fn get_site_name_handler(app_data: WebAppData) -> ServiceResult { + let site_name = app_data.settings_service.get_site_name().await; - Ok(HttpResponse::Ok().json(OkResponse { data: &*settings })) + Ok(HttpResponse::Ok().json(OkResponse { data: site_name })) } diff --git a/src/services/mod.rs b/src/services/mod.rs index a52ba223..a9af5155 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod about; pub mod category; pub mod proxy; +pub mod settings; pub mod user; diff --git a/src/services/settings.rs b/src/services/settings.rs new file mode 100644 index 00000000..857ee1fa --- /dev/null +++ b/src/services/settings.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use super::user::DbUserRepository; +use crate::config::{Configuration, ConfigurationPublic, TorrustBackend}; +use crate::errors::ServiceError; +use crate::models::user::UserId; + +pub struct Service { + configuration: Arc, + user_repository: Arc, +} + +impl Service { + #[must_use] + pub fn new(configuration: Arc, user_repository: Arc) -> Service { + Service { + configuration, + user_repository, + } + } + + /// It gets all the settings. + /// + /// # Errors + /// + /// It returns an error if the user does not have the required permissions. + pub async fn get_all(&self, user_id: &UserId) -> Result { + let user = self.user_repository.get_compact_user(user_id).await?; + + // Check if user is administrator + // todo: extract authorization service + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + Ok(self.configuration.get_all().await) + } + + /// It updates all the settings. + /// + /// # Errors + /// + /// It returns an error if the user does not have the required permissions. + pub async fn update_all(&self, torrust_backend: TorrustBackend, user_id: &UserId) -> Result { + let user = self.user_repository.get_compact_user(user_id).await?; + + // Check if user is administrator + // todo: extract authorization service + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + let _ = self.configuration.update_settings(torrust_backend).await; + + Ok(self.configuration.get_all().await) + } + + /// It gets only the public settings. + /// + /// # Errors + /// + /// It returns an error if the user does not have the required permissions. + pub async fn get_public(&self) -> ConfigurationPublic { + self.configuration.get_public().await + } + + /// It gets the site name from the settings. + /// + /// # Errors + /// + /// It returns an error if the user does not have the required permissions. + pub async fn get_site_name(&self) -> String { + self.configuration.get_site_name().await + } +} From 1abcc4d892242c6c317f09edec44d18dd8b8f77e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 19 May 2023 10:30:36 +0100 Subject: [PATCH 183/357] refactor: [#157] extract service: torrent Decoupling services from actix-web framework. --- src/app.rs | 51 +++- src/common.rs | 30 +- src/databases/database.rs | 4 +- src/databases/mysql.rs | 4 +- src/databases/sqlite.rs | 4 +- src/models/response.rs | 18 +- src/models/torrent.rs | 5 +- src/models/torrent_file.rs | 4 +- src/routes/category.rs | 2 +- src/routes/torrent.rs | 322 ++++----------------- src/services/category.rs | 28 +- src/services/mod.rs | 1 + src/services/torrent.rs | 553 +++++++++++++++++++++++++++++++++++++ src/tracker/service.rs | 3 +- 14 files changed, 723 insertions(+), 306 deletions(-) create mode 100644 src/services/torrent.rs diff --git a/src/app.rs b/src/app.rs index 87c6e2ca..6c7f8a8e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,8 +13,12 @@ use crate::common::AppData; use crate::config::Configuration; use crate::databases::database; use crate::services::category::{self, DbCategoryRepository}; +use crate::services::torrent::{ + DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, + DbTorrentRepository, +}; use crate::services::user::DbUserRepository; -use crate::services::{proxy, settings}; +use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, routes, tracker}; @@ -27,12 +31,12 @@ pub struct Running { pub async fn run(configuration: Configuration) -> Running { logging::setup(); - let cfg = Arc::new(configuration); + let configuration = Arc::new(configuration); // Get configuration settings needed to build the app dependencies and // services: main API server and tracker torrents importer. - let settings = cfg.settings.read().await; + let settings = configuration.settings.read().await; let database_connect_url = settings.database.connect_url.clone(); let torrent_info_update_interval = settings.tracker_statistics_importer.torrent_info_update_interval; @@ -45,33 +49,60 @@ pub async fn run(configuration: Configuration) -> Running { // Build app dependencies let database = Arc::new(database::connect(&database_connect_url).await.expect("Database error.")); - let auth = Arc::new(AuthorizationService::new(cfg.clone(), database.clone())); - let tracker_service = Arc::new(tracker::service::Service::new(cfg.clone(), database.clone()).await); + let auth = Arc::new(AuthorizationService::new(configuration.clone(), database.clone())); + let tracker_service = Arc::new(tracker::service::Service::new(configuration.clone(), database.clone()).await); let tracker_statistics_importer = - Arc::new(StatisticsImporter::new(cfg.clone(), tracker_service.clone(), database.clone()).await); - let mailer_service = Arc::new(mailer::Service::new(cfg.clone()).await); - let image_cache_service: Arc = Arc::new(ImageCacheService::new(cfg.clone()).await); + Arc::new(StatisticsImporter::new(configuration.clone(), tracker_service.clone(), database.clone()).await); + let mailer_service = Arc::new(mailer::Service::new(configuration.clone()).await); + let image_cache_service: Arc = Arc::new(ImageCacheService::new(configuration.clone()).await); + // Repositories let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); let user_repository = Arc::new(DbUserRepository::new(database.clone())); + let torrent_repository = Arc::new(DbTorrentRepository::new(database.clone())); + let torrent_info_repository = Arc::new(DbTorrentInfoRepository::new(database.clone())); + let torrent_file_repository = Arc::new(DbTorrentFileRepository::new(database.clone())); + let torrent_announce_url_repository = Arc::new(DbTorrentAnnounceUrlRepository::new(database.clone())); + let torrent_listing_generator = Arc::new(DbTorrentListingGenerator::new(database.clone())); + // Services let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); let proxy_service = Arc::new(proxy::Service::new(image_cache_service.clone(), user_repository.clone())); - let settings_service = Arc::new(settings::Service::new(cfg.clone(), user_repository.clone())); + let settings_service = Arc::new(settings::Service::new(configuration.clone(), user_repository.clone())); + let torrent_index = Arc::new(torrent::Index::new( + configuration.clone(), + tracker_statistics_importer.clone(), + tracker_service.clone(), + user_repository.clone(), + category_repository.clone(), + torrent_repository.clone(), + torrent_info_repository.clone(), + torrent_file_repository.clone(), + torrent_announce_url_repository.clone(), + torrent_listing_generator.clone(), + )); // Build app container let app_data = Arc::new(AppData::new( - cfg.clone(), + configuration.clone(), database.clone(), auth.clone(), tracker_service.clone(), tracker_statistics_importer.clone(), mailer_service, image_cache_service, + // Repositories category_repository, user_repository, + torrent_repository, + torrent_info_repository, + torrent_file_repository, + torrent_announce_url_repository, + torrent_listing_generator, + // Services category_service, proxy_service, settings_service, + torrent_index, )); // Start repeating task to import tracker torrent data and updating diff --git a/src/common.rs b/src/common.rs index e37f2dfe..dea79c2c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -5,8 +5,12 @@ use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; use crate::services::category::{self, DbCategoryRepository}; +use crate::services::torrent::{ + DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, + DbTorrentRepository, +}; use crate::services::user::DbUserRepository; -use crate::services::{proxy, settings}; +use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, tracker}; pub type Username = String; @@ -21,11 +25,19 @@ pub struct AppData { pub tracker_statistics_importer: Arc, pub mailer: Arc, pub image_cache_manager: Arc, + // Repositories pub category_repository: Arc, pub user_repository: Arc, + pub torrent_repository: Arc, + pub torrent_info_repository: Arc, + pub torrent_file_repository: Arc, + pub torrent_announce_url_repository: Arc, + pub torrent_listing_generator: Arc, + // Services pub category_service: Arc, pub proxy_service: Arc, pub settings_service: Arc, + pub torrent_service: Arc, } impl AppData { @@ -38,11 +50,19 @@ impl AppData { tracker_statistics_importer: Arc, mailer: Arc, image_cache_manager: Arc, + // Repositories category_repository: Arc, user_repository: Arc, + torrent_repository: Arc, + torrent_info_repository: Arc, + torrent_file_repository: Arc, + torrent_announce_url_repository: Arc, + torrent_listing_generator: Arc, + // Services category_service: Arc, proxy_service: Arc, settings_service: Arc, + torrent_service: Arc, ) -> AppData { AppData { cfg, @@ -52,11 +72,19 @@ impl AppData { tracker_statistics_importer, mailer, image_cache_manager, + // Repositories category_repository, user_repository, + torrent_repository, + torrent_info_repository, + torrent_file_repository, + torrent_announce_url_repository, + torrent_listing_generator, + // Services category_service, proxy_service, settings_service, + torrent_service, } } } diff --git a/src/databases/database.rs b/src/databases/database.rs index 2bc68865..7b440378 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -9,7 +9,7 @@ use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; -use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; +use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; /// Database drivers. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] @@ -165,7 +165,7 @@ pub trait Database: Sync + Send { async fn insert_torrent_and_get_id( &self, torrent: &Torrent, - uploader_id: i64, + uploader_id: UserId, category_id: i64, title: &str, description: &str, diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 7985d752..74557175 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -10,7 +10,7 @@ use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; -use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; +use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; use crate::utils::clock; use crate::utils::hex::from_bytes; @@ -380,7 +380,7 @@ impl Database for Mysql { async fn insert_torrent_and_get_id( &self, torrent: &Torrent, - uploader_id: i64, + uploader_id: UserId, category_id: i64, title: &str, description: &str, diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index c2678e1c..0bc6ddd1 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -10,7 +10,7 @@ use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; use crate::models::tracker_key::TrackerKey; -use crate::models::user::{User, UserAuthentication, UserCompact, UserProfile}; +use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; use crate::utils::clock; use crate::utils::hex::from_bytes; @@ -370,7 +370,7 @@ impl Database for Sqlite { async fn insert_torrent_and_get_id( &self, torrent: &Torrent, - uploader_id: i64, + uploader_id: UserId, category_id: i64, title: &str, description: &str, diff --git a/src/models/response.rs b/src/models/response.rs index cbcb3c90..8d9a2d90 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +use super::torrent::TorrentId; use crate::databases::database::Category; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::TorrentFile; @@ -31,7 +32,14 @@ pub struct TokenResponse { #[allow(clippy::module_name_repetitions)] #[derive(Serialize, Deserialize, Debug)] pub struct NewTorrentResponse { - pub torrent_id: i64, + pub torrent_id: TorrentId, + pub info_hash: String, +} + +#[allow(clippy::module_name_repetitions)] +#[derive(Serialize, Deserialize, Debug)] +pub struct DeletedTorrentResponse { + pub torrent_id: TorrentId, pub info_hash: String, } @@ -55,18 +63,14 @@ pub struct TorrentResponse { impl TorrentResponse { #[must_use] - pub fn from_listing(torrent_listing: TorrentListing) -> TorrentResponse { + pub fn from_listing(torrent_listing: TorrentListing, category: Category) -> TorrentResponse { TorrentResponse { torrent_id: torrent_listing.torrent_id, uploader: torrent_listing.uploader, info_hash: torrent_listing.info_hash, title: torrent_listing.title, description: torrent_listing.description, - category: Category { - category_id: 0, - name: String::new(), - num_torrents: 0, - }, + category, upload_date: torrent_listing.date_uploaded, file_size: torrent_listing.file_size, seeders: torrent_listing.seeders, diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 2ecbf984..41325b9a 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -3,11 +3,14 @@ use serde::{Deserialize, Serialize}; use crate::models::torrent_file::Torrent; use crate::routes::torrent::Create; +#[allow(clippy::module_name_repetitions)] +pub type TorrentId = i64; + #[allow(clippy::module_name_repetitions)] #[allow(dead_code)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] pub struct TorrentListing { - pub torrent_id: i64, + pub torrent_id: TorrentId, pub uploader: String, pub info_hash: String, pub title: String, diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index e3c0a49f..57cf3c36 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -165,7 +165,9 @@ impl Torrent { } } - pub async fn set_torrust_config(&mut self, cfg: &Configuration) { + /// Sets the announce url to the tracker url and removes all other trackers + /// if the torrent is private. + pub async fn set_announce_urls(&mut self, cfg: &Configuration) { let settings = cfg.settings.read().await; self.announce = Some(settings.tracker.url.clone()); diff --git a/src/routes/category.rs b/src/routes/category.rs index 113615f7..f087d2b8 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -23,7 +23,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// /// This function will return an error if there is a database error. pub async fn get(app_data: WebAppData) -> ServiceResult { - let categories = app_data.category_repository.get_categories().await?; + let categories = app_data.category_repository.get_all().await?; Ok(HttpResponse::Ok().json(OkResponse { data: categories })) } diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 0dc13881..7327ff80 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -9,25 +9,24 @@ use serde::Deserialize; use sqlx::FromRow; use crate::common::WebAppData; -use crate::databases::database::Sorting; use crate::errors::{ServiceError, ServiceResult}; use crate::models::info_hash::InfoHash; -use crate::models::response::{NewTorrentResponse, OkResponse, TorrentResponse}; +use crate::models::response::{NewTorrentResponse, OkResponse}; use crate::models::torrent::TorrentRequest; use crate::routes::API_VERSION; +use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; -use crate::AsCSV; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope(&format!("/{API_VERSION}/torrent")) - .service(web::resource("/upload").route(web::post().to(upload))) + .service(web::resource("/upload").route(web::post().to(upload_torrent_handler))) .service(web::resource("/download/{info_hash}").route(web::get().to(download_torrent_handler))) .service( web::resource("/{info_hash}") - .route(web::get().to(get)) - .route(web::put().to(update)) - .route(web::delete().to(delete)), + .route(web::get().to(get_torrent_info_handler)) + .route(web::put().to(update_torrent_info_handler)) + .route(web::delete().to(delete_torrent_handler)), ), ); cfg.service( @@ -62,16 +61,6 @@ impl Create { } } -#[derive(Debug, Deserialize)] -pub struct Search { - page_size: Option, - page: Option, - sort: Option, - // expects comma separated string, eg: "?categories=movie,other,app" - categories: Option, - search: Option, -} - #[derive(Debug, Deserialize)] pub struct Update { title: Option, @@ -82,64 +71,21 @@ pub struct Update { /// /// # Errors /// -/// This function will return an error if unable to get the user from the database. -/// This function will return an error if unable to get torrent request from payload. -/// This function will return an error if unable to get the category from the database. -/// This function will return an error if unable to insert the torrent into the database. -/// This function will return an error if unable to add the torrent to the whitelist. -pub async fn upload(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { - let user = app_data.auth.get_user_compact_from_request(&req).await?; - - // get torrent and fields from request - let mut torrent_request = get_torrent_request_from_payload(payload).await?; - - // update announce url to our own tracker url - torrent_request.torrent.set_torrust_config(&app_data.cfg).await; - - // get the correct category name from database - let category = app_data - .database - .get_category_from_name(&torrent_request.fields.category) - .await - .map_err(|_| ServiceError::InvalidCategory)?; - - // insert entire torrent in database - let torrent_id = app_data - .database - .insert_torrent_and_get_id( - &torrent_request.torrent, - user.user_id, - category.category_id, - &torrent_request.fields.title, - &torrent_request.fields.description, - ) - .await?; +/// This function will return an error if there was a problem uploading the +/// torrent. +pub async fn upload_torrent_handler(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { + let user_id = app_data.auth.get_user_id_from_request(&req).await?; - // update torrent tracker stats - let _ = app_data - .tracker_statistics_importer - .import_torrent_statistics(torrent_id, &torrent_request.torrent.info_hash()) - .await; - - // whitelist info hash on tracker - // code-review: why do we always try to whitelist the torrent on the tracker? - // shouldn't we only do this if the torrent is in "Listed" mode? - if let Err(e) = app_data - .tracker_service - .whitelist_info_hash(torrent_request.torrent.info_hash()) - .await - { - // if the torrent can't be whitelisted somehow, remove the torrent from database - let _ = app_data.database.delete_torrent(torrent_id).await; - return Err(e); - } + let torrent_request = get_torrent_request_from_payload(payload).await?; + + let info_hash = torrent_request.torrent.info_hash().clone(); + + let torrent_service = app_data.torrent_service.clone(); + + let torrent_id = torrent_service.add_torrent(torrent_request, user_id).await?; - // respond with the newly uploaded torrent id Ok(HttpResponse::Ok().json(OkResponse { - data: NewTorrentResponse { - torrent_id, - info_hash: torrent_request.torrent.info_hash(), - }, + data: NewTorrentResponse { torrent_id, info_hash }, })) } @@ -150,36 +96,9 @@ pub async fn upload(req: HttpRequest, payload: Multipart, app_data: WebAppData) /// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; + let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); - // optional - let user = app_data.auth.get_user_compact_from_request(&req).await; - - let mut torrent = app_data.database.get_torrent_from_info_hash(&info_hash).await?; - - let settings = app_data.cfg.settings.read().await; - - let tracker_url = settings.tracker.url.clone(); - - drop(settings); - - // add personal tracker url or default tracker url - match user { - Ok(user) => { - let personal_announce_url = app_data - .tracker_service - .get_personal_announce_url(user.user_id) - .await - .unwrap_or(tracker_url); - torrent.announce = Some(personal_announce_url.clone()); - if let Some(list) = &mut torrent.announce_list { - let vec = vec![personal_announce_url]; - list.insert(0, vec); - } - } - Err(_) => { - torrent.announce = Some(tracker_url); - } - } + let torrent = app_data.torrent_service.get_torrent(&info_hash, user_id).await?; let buffer = parse_torrent::encode_torrent(&torrent).map_err(|_| ServiceError::InternalServerError)?; @@ -190,93 +109,12 @@ pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> /// /// # Errors /// -/// This function will return an error if unable to get torrent ID. -/// This function will return an error if unable to get torrent listing from id. -/// This function will return an error if unable to get torrent category from id. -/// This function will return an error if unable to get torrent files from id. -/// This function will return an error if unable to get torrent info from id. -/// This function will return an error if unable to get torrent announce url(s) from id. -pub async fn get(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - // optional - let user = app_data.auth.get_user_compact_from_request(&req).await; - - let settings = app_data.cfg.settings.read().await; - +/// This function will return an error if unable to get torrent info. +pub async fn get_torrent_info_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; + let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); - let torrent_listing = app_data.database.get_torrent_listing_from_info_hash(&info_hash).await?; - - let torrent_id = torrent_listing.torrent_id; - - let category = app_data.database.get_category_from_id(torrent_listing.category_id).await?; - - let mut torrent_response = TorrentResponse::from_listing(torrent_listing); - - torrent_response.category = category; - - let tracker_url = settings.tracker.url.clone(); - - drop(settings); - - torrent_response.files = app_data.database.get_torrent_files_from_id(torrent_id).await?; - - if torrent_response.files.len() == 1 { - let torrent_info = app_data.database.get_torrent_info_from_info_hash(&info_hash).await?; - - torrent_response - .files - .iter_mut() - .for_each(|v| v.path = vec![torrent_info.name.to_string()]); - } - - torrent_response.trackers = app_data - .database - .get_torrent_announce_urls_from_id(torrent_id) - .await - .map(|v| v.into_iter().flatten().collect())?; - - // add tracker url - match user { - Ok(user) => { - // if no user owned tracker key can be found, use default tracker url - let personal_announce_url = app_data - .tracker_service - .get_personal_announce_url(user.user_id) - .await - .unwrap_or(tracker_url); - // add personal tracker url to front of vec - torrent_response.trackers.insert(0, personal_announce_url); - } - Err(_) => { - torrent_response.trackers.insert(0, tracker_url); - } - } - - // todo: extract a struct or function to build the magnet links - - // add magnet link - let mut magnet = format!( - "magnet:?xt=urn:btih:{}&dn={}", - torrent_response.info_hash, - urlencoding::encode(&torrent_response.title) - ); - - // add trackers from torrent file to magnet link - for tracker in &torrent_response.trackers { - magnet.push_str(&format!("&tr={}", urlencoding::encode(tracker))); - } - - torrent_response.magnet_link = magnet; - - // get realtime seeders and leechers - if let Ok(torrent_info) = app_data - .tracker_statistics_importer - .import_torrent_statistics(torrent_response.torrent_id, &torrent_response.info_hash) - .await - { - torrent_response.seeders = torrent_info.seeders; - torrent_response.leechers = torrent_info.leechers; - } + let torrent_response = app_data.torrent_service.get_torrent_info(&info_hash, user_id).await?; Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } @@ -285,46 +123,24 @@ pub async fn get(req: HttpRequest, app_data: WebAppData) -> ServiceResult, app_data: WebAppData) -> ServiceResult { - let user = app_data.auth.get_user_compact_from_request(&req).await?; - +/// This function will return an error if unable to: +/// +/// * Get the user id from the request. +/// * Get the torrent info-hash from the request. +/// * Update the torrent info. +pub async fn update_torrent_info_handler( + req: HttpRequest, + payload: web::Json, + app_data: WebAppData, +) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; + let user_id = app_data.auth.get_user_id_from_request(&req).await?; - let torrent_listing = app_data.database.get_torrent_listing_from_info_hash(&info_hash).await?; - - // check if user is owner or administrator - if torrent_listing.uploader != user.username && !user.administrator { - return Err(ServiceError::Unauthorized); - } - - // update torrent title - if let Some(title) = &payload.title { - app_data - .database - .update_torrent_title(torrent_listing.torrent_id, title) - .await?; - } - - // update torrent description - if let Some(description) = &payload.description { - app_data - .database - .update_torrent_description(torrent_listing.torrent_id, description) - .await?; - } - - let torrent_listing = app_data - .database - .get_torrent_listing_from_id(torrent_listing.torrent_id) + let torrent_response = app_data + .torrent_service + .update_torrent_info(&info_hash, &payload.title, &payload.description, &user_id) .await?; - let torrent_response = TorrentResponse::from_listing(torrent_listing); - Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) } @@ -332,36 +148,19 @@ pub async fn update(req: HttpRequest, payload: web::Json, app_data: WebA /// /// # Errors /// -/// This function will return an error if unable to get the user. -/// This function will return an `ServiceError::Unauthorized` if the user is not an administrator. -/// This function will return an error if unable to get the torrent listing from it's ID. -/// This function will return an error if unable to delete the torrent from the database. -pub async fn delete(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let user = app_data.auth.get_user_compact_from_request(&req).await?; - - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } - +/// This function will return an error if unable to: +/// +/// * Get the user id from the request. +/// * Get the torrent info-hash from the request. +/// * Delete the torrent. +pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; + let user_id = app_data.auth.get_user_id_from_request(&req).await?; - // needed later for removing torrent from tracker whitelist - let torrent_listing = app_data.database.get_torrent_listing_from_info_hash(&info_hash).await?; - - app_data.database.delete_torrent(torrent_listing.torrent_id).await?; - - // remove info_hash from tracker whitelist - let _ = app_data - .tracker_service - .remove_info_hash_from_whitelist(torrent_listing.info_hash.clone()) - .await; + let deleted_torrent_response = app_data.torrent_service.delete_torrent(&info_hash, &user_id).await?; Ok(HttpResponse::Ok().json(OkResponse { - data: NewTorrentResponse { - torrent_id: torrent_listing.torrent_id, - info_hash: torrent_listing.info_hash, - }, + data: deleted_torrent_response, })) } @@ -371,31 +170,8 @@ pub async fn delete(req: HttpRequest, app_data: WebAppData) -> ServiceResult, app_data: WebAppData) -> ServiceResult { - let settings = app_data.cfg.settings.read().await; - - let sort = params.sort.unwrap_or(Sorting::UploadedDesc); - - let page = params.page.unwrap_or(0); - - let page_size = params.page_size.unwrap_or(settings.api.default_torrent_page_size); - - // Guard that page size does not exceed the maximum - let max_torrent_page_size = settings.api.max_torrent_page_size; - let page_size = if page_size > max_torrent_page_size { - max_torrent_page_size - } else { - page_size - }; - - let offset = u64::from(page * u32::from(page_size)); - - let categories = params.categories.as_csv::().unwrap_or(None); - - let torrents_response = app_data - .database - .get_torrents_search_sorted_paginated(¶ms.search, &categories, &sort, offset, page_size) - .await?; +pub async fn get_torrents_handler(criteria: Query, app_data: WebAppData) -> ServiceResult { + let torrents_response = app_data.torrent_service.generate_torrent_info_listing(&criteria).await?; Ok(HttpResponse::Ok().json(OkResponse { data: torrents_response })) } diff --git a/src/services/category.rs b/src/services/category.rs index 53070c5a..0d30836f 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -38,7 +38,7 @@ impl Service { return Err(ServiceError::Unauthorized); } - match self.category_repository.add_category(category_name).await { + match self.category_repository.add(category_name).await { Ok(id) => Ok(id), Err(e) => match e { DatabaseError::CategoryAlreadyExists => Err(ServiceError::CategoryExists), @@ -64,7 +64,7 @@ impl Service { return Err(ServiceError::Unauthorized); } - match self.category_repository.delete_category(category_name).await { + match self.category_repository.delete(category_name).await { Ok(_) => Ok(()), Err(e) => match e { DatabaseError::CategoryNotFound => Err(ServiceError::CategoryNotFound), @@ -89,7 +89,7 @@ impl DbCategoryRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn get_categories(&self) -> Result, DatabaseError> { + pub async fn get_all(&self) -> Result, DatabaseError> { self.database.get_categories().await } @@ -98,7 +98,7 @@ impl DbCategoryRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn add_category(&self, category_name: &str) -> Result { + pub async fn add(&self, category_name: &str) -> Result { self.database.insert_category_and_get_id(category_name).await } @@ -107,7 +107,25 @@ impl DbCategoryRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn delete_category(&self, category_name: &str) -> Result<(), DatabaseError> { + pub async fn delete(&self, category_name: &str) -> Result<(), DatabaseError> { self.database.delete_category(category_name).await } + + /// It finds a category by name + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_by_name(&self, category_name: &str) -> Result { + self.database.get_category_from_name(category_name).await + } + + /// It finds a category by id + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_by_id(&self, category_id: &CategoryId) -> Result { + self.database.get_category_from_id(*category_id).await + } } diff --git a/src/services/mod.rs b/src/services/mod.rs index a9af5155..306931e0 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,4 +2,5 @@ pub mod about; pub mod category; pub mod proxy; pub mod settings; +pub mod torrent; pub mod user; diff --git a/src/services/torrent.rs b/src/services/torrent.rs new file mode 100644 index 00000000..4a20e754 --- /dev/null +++ b/src/services/torrent.rs @@ -0,0 +1,553 @@ +use std::sync::Arc; + +use serde_derive::Deserialize; + +use super::category::DbCategoryRepository; +use super::user::DbUserRepository; +use crate::config::Configuration; +use crate::databases::database::{Category, Database, Error, Sorting}; +use crate::errors::ServiceError; +use crate::models::info_hash::InfoHash; +use crate::models::response::{DeletedTorrentResponse, TorrentResponse, TorrentsResponse}; +use crate::models::torrent::{TorrentId, TorrentListing, TorrentRequest}; +use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::user::UserId; +use crate::tracker::statistics_importer::StatisticsImporter; +use crate::{tracker, AsCSV}; + +pub struct Index { + configuration: Arc, + tracker_statistics_importer: Arc, + tracker_service: Arc, + user_repository: Arc, + category_repository: Arc, + torrent_repository: Arc, + torrent_info_repository: Arc, + torrent_file_repository: Arc, + torrent_announce_url_repository: Arc, + torrent_listing_generator: Arc, +} + +/// User request to generate a torrent listing. +#[derive(Debug, Deserialize)] +pub struct ListingRequest { + pub page_size: Option, + pub page: Option, + pub sort: Option, + /// Expects comma separated string, eg: "?categories=movie,other,app" + pub categories: Option, + pub search: Option, +} + +/// Internal specification for torrent listings. +#[derive(Debug, Deserialize)] +pub struct ListingSpecification { + pub search: Option, + pub categories: Option>, + pub sort: Sorting, + pub offset: u64, + pub page_size: u8, +} + +impl Index { + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + configuration: Arc, + tracker_statistics_importer: Arc, + tracker_service: Arc, + user_repository: Arc, + category_repository: Arc, + torrent_repository: Arc, + torrent_info_repository: Arc, + torrent_file_repository: Arc, + torrent_announce_url_repository: Arc, + torrent_listing_repository: Arc, + ) -> Self { + Self { + configuration, + tracker_statistics_importer, + tracker_service, + user_repository, + category_repository, + torrent_repository, + torrent_info_repository, + torrent_file_repository, + torrent_announce_url_repository, + torrent_listing_generator: torrent_listing_repository, + } + } + + /// Adds a torrent to the index. + /// + /// # Errors + /// + /// This function will return an error if: + /// + /// * Unable to get the user from the database. + /// * Unable to get torrent request from payload. + /// * Unable to get the category from the database. + /// * Unable to insert the torrent into the database. + /// * Unable to add the torrent to the whitelist. + pub async fn add_torrent(&self, mut torrent_request: TorrentRequest, user_id: UserId) -> Result { + torrent_request.torrent.set_announce_urls(&self.configuration).await; + + let category = self + .category_repository + .get_by_name(&torrent_request.fields.category) + .await + .map_err(|_| ServiceError::InvalidCategory)?; + + let torrent_id = self.torrent_repository.add(&torrent_request, user_id, category).await?; + + let _ = self + .tracker_statistics_importer + .import_torrent_statistics(torrent_id, &torrent_request.torrent.info_hash()) + .await; + + // We always whitelist the torrent on the tracker because even if the tracker mode is `public` + // it could be changed to `private` later on. + if let Err(e) = self + .tracker_service + .whitelist_info_hash(torrent_request.torrent.info_hash()) + .await + { + // If the torrent can't be whitelisted somehow, remove the torrent from database + let _ = self.torrent_repository.delete(&torrent_id).await; + return Err(e); + } + + Ok(torrent_id) + } + + /// Gets a torrent from the Index. + /// + /// # Errors + /// + /// This function will return an error if unable to get the torrent from the + /// database. + pub async fn get_torrent(&self, info_hash: &InfoHash, opt_user_id: Option) -> Result { + let mut torrent = self.torrent_repository.get_by_info_hash(info_hash).await?; + + let tracker_url = self.get_tracker_url().await; + + // Add personal tracker url or default tracker url + match opt_user_id { + Some(user_id) => { + let personal_announce_url = self + .tracker_service + .get_personal_announce_url(user_id) + .await + .unwrap_or(tracker_url); + torrent.announce = Some(personal_announce_url.clone()); + if let Some(list) = &mut torrent.announce_list { + let vec = vec![personal_announce_url]; + list.insert(0, vec); + } + } + None => { + torrent.announce = Some(tracker_url); + } + } + + Ok(torrent) + } + + /// Delete a Torrent from the Index + /// + /// # Errors + /// + /// This function will return an error if: + /// + /// * Unable to get the user who is deleting the torrent (logged-in user). + /// * The user does not have permission to delete the torrent. + /// * Unable to get the torrent listing from it's ID. + /// * Unable to delete the torrent from the database. + pub async fn delete_torrent(&self, info_hash: &InfoHash, user_id: &UserId) -> Result { + let user = self.user_repository.get_compact_user(user_id).await?; + + // Only administrator can delete torrents. + // todo: move this to an authorization service. + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?; + + self.torrent_repository.delete(&torrent_listing.torrent_id).await?; + + // Remove info-hash from tracker whitelist + let _ = self + .tracker_service + .remove_info_hash_from_whitelist(info_hash.to_string()) + .await; + + Ok(DeletedTorrentResponse { + torrent_id: torrent_listing.torrent_id, + info_hash: torrent_listing.info_hash, + }) + } + + /// Get torrent info from the Index + /// + /// # Errors + /// + /// This function will return an error if: + /// * Unable to get torrent ID. + /// * Unable to get torrent listing from id. + /// * Unable to get torrent category from id. + /// * Unable to get torrent files from id. + /// * Unable to get torrent info from id. + /// * Unable to get torrent announce url(s) from id. + pub async fn get_torrent_info( + &self, + info_hash: &InfoHash, + opt_user_id: Option, + ) -> Result { + let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?; + + let torrent_id = torrent_listing.torrent_id; + + let category = self.category_repository.get_by_id(&torrent_listing.category_id).await?; + + let mut torrent_response = TorrentResponse::from_listing(torrent_listing, category); + + // Add files + + torrent_response.files = self.torrent_file_repository.get_by_torrent_id(&torrent_id).await?; + + if torrent_response.files.len() == 1 { + let torrent_info = self.torrent_info_repository.get_by_info_hash(info_hash).await?; + + torrent_response + .files + .iter_mut() + .for_each(|v| v.path = vec![torrent_info.name.to_string()]); + } + + // Add trackers + + torrent_response.trackers = self.torrent_announce_url_repository.get_by_torrent_id(&torrent_id).await?; + + let tracker_url = self.get_tracker_url().await; + + // add tracker url + match opt_user_id { + Some(user_id) => { + // if no user owned tracker key can be found, use default tracker url + let personal_announce_url = self + .tracker_service + .get_personal_announce_url(user_id) + .await + .unwrap_or(tracker_url); + // add personal tracker url to front of vec + torrent_response.trackers.insert(0, personal_announce_url); + } + None => { + torrent_response.trackers.insert(0, tracker_url); + } + } + + // Add magnet link + + // todo: extract a struct or function to build the magnet links + let mut magnet = format!( + "magnet:?xt=urn:btih:{}&dn={}", + torrent_response.info_hash, + urlencoding::encode(&torrent_response.title) + ); + + // Add trackers from torrent file to magnet link + for tracker in &torrent_response.trackers { + magnet.push_str(&format!("&tr={}", urlencoding::encode(tracker))); + } + + torrent_response.magnet_link = magnet; + + // Get realtime seeders and leechers + if let Ok(torrent_info) = self + .tracker_statistics_importer + .import_torrent_statistics(torrent_response.torrent_id, &torrent_response.info_hash) + .await + { + torrent_response.seeders = torrent_info.seeders; + torrent_response.leechers = torrent_info.leechers; + } + + Ok(torrent_response) + } + + /// It returns a list of torrents matching the search criteria. + /// + /// # Errors + /// + /// Returns a `ServiceError::DatabaseError` if the database query fails. + pub async fn generate_torrent_info_listing(&self, request: &ListingRequest) -> Result { + let torrent_listing_specification = self.listing_specification_from_user_request(request).await; + + let torrents_response = self + .torrent_listing_generator + .generate_listing(&torrent_listing_specification) + .await?; + + Ok(torrents_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_torrent_page_size = settings.api.default_torrent_page_size; + let max_torrent_page_size = settings.api.max_torrent_page_size; + drop(settings); + + let sort = request.sort.unwrap_or(Sorting::UploadedDesc); + let page = request.page.unwrap_or(0); + let page_size = request.page_size.unwrap_or(default_torrent_page_size); + + // Guard that page size does not exceed the maximum + let max_torrent_page_size = max_torrent_page_size; + let page_size = if page_size > max_torrent_page_size { + max_torrent_page_size + } else { + page_size + }; + + let offset = u64::from(page * u32::from(page_size)); + + let categories = request.categories.as_csv::().unwrap_or(None); + + ListingSpecification { + search: request.search.clone(), + categories, + sort, + offset, + page_size, + } + } + + /// Update the torrent info on the Index. + /// + /// # Errors + /// + /// This function will return an error if: + /// + /// * Unable to get the user. + /// * Unable to get listing from id. + /// * Unable to update the torrent tile or description. + /// * User does not have the permissions to update the torrent. + pub async fn update_torrent_info( + &self, + info_hash: &InfoHash, + title: &Option, + description: &Option, + user_id: &UserId, + ) -> Result { + let updater = self.user_repository.get_compact_user(user_id).await?; + + let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?; + + // Check if user is owner or administrator + // todo: move this to an authorization service. + if !(torrent_listing.uploader == updater.username || updater.administrator) { + return Err(ServiceError::Unauthorized); + } + + self.torrent_info_repository + .update(&torrent_listing.torrent_id, title, description) + .await?; + + let torrent_listing = self + .torrent_listing_generator + .one_torrent_by_torrent_id(&torrent_listing.torrent_id) + .await?; + + let category = self.category_repository.get_by_id(&torrent_listing.category_id).await?; + + let torrent_response = TorrentResponse::from_listing(torrent_listing, category); + + Ok(torrent_response) + } + + async fn get_tracker_url(&self) -> String { + let settings = self.configuration.settings.read().await; + settings.tracker.url.clone() + } +} + +pub struct DbTorrentRepository { + database: Arc>, +} + +impl DbTorrentRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It finds the torrent by info-hash. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn get_by_info_hash(&self, info_hash: &InfoHash) -> Result { + self.database.get_torrent_from_info_hash(info_hash).await + } + + /// Inserts the entire torrent in the database. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn add(&self, torrent_request: &TorrentRequest, user_id: UserId, category: Category) -> Result { + self.database + .insert_torrent_and_get_id( + &torrent_request.torrent, + user_id, + category.category_id, + &torrent_request.fields.title, + &torrent_request.fields.description, + ) + .await + } + + /// Deletes the entire torrent in the database. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn delete(&self, torrent_id: &TorrentId) -> Result<(), Error> { + self.database.delete_torrent(*torrent_id).await + } +} + +pub struct DbTorrentInfoRepository { + database: Arc>, +} + +impl DbTorrentInfoRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It finds the torrent info by info-hash. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn get_by_info_hash(&self, info_hash: &InfoHash) -> Result { + self.database.get_torrent_info_from_info_hash(info_hash).await + } + + /// It updates the torrent title or/and description by torrent ID. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn update( + &self, + torrent_id: &TorrentId, + opt_title: &Option, + opt_description: &Option, + ) -> Result<(), Error> { + if let Some(title) = &opt_title { + self.database.update_torrent_title(*torrent_id, title).await?; + } + + if let Some(description) = &opt_description { + self.database.update_torrent_description(*torrent_id, description).await?; + } + + Ok(()) + } +} + +pub struct DbTorrentFileRepository { + database: Arc>, +} + +impl DbTorrentFileRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It finds the torrent files by torrent id + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_by_torrent_id(&self, torrent_id: &TorrentId) -> Result, Error> { + self.database.get_torrent_files_from_id(*torrent_id).await + } +} + +pub struct DbTorrentAnnounceUrlRepository { + database: Arc>, +} + +impl DbTorrentAnnounceUrlRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It finds the announce URLs by torrent id + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_by_torrent_id(&self, torrent_id: &TorrentId) -> Result, Error> { + self.database + .get_torrent_announce_urls_from_id(*torrent_id) + .await + .map(|v| v.into_iter().flatten().collect()) + } +} + +pub struct DbTorrentListingGenerator { + database: Arc>, +} + +impl DbTorrentListingGenerator { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It finds the torrent listing by info-hash + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn one_torrent_by_info_hash(&self, info_hash: &InfoHash) -> Result { + self.database.get_torrent_listing_from_info_hash(info_hash).await + } + + /// It finds the torrent listing by torrent ID. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn one_torrent_by_torrent_id(&self, torrent_id: &TorrentId) -> Result { + self.database.get_torrent_listing_from_id(*torrent_id).await + } + + /// It finds the torrent listing by torrent ID. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn generate_listing(&self, specification: &ListingSpecification) -> Result { + self.database + .get_torrents_search_sorted_paginated( + &specification.search, + &specification.categories, + &specification.sort, + specification.offset, + specification.page_size, + ) + .await + } +} diff --git a/src/tracker/service.rs b/src/tracker/service.rs index 35374aab..e8b17847 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -8,6 +8,7 @@ use crate::config::Configuration; use crate::databases::database::Database; use crate::errors::ServiceError; use crate::models::tracker_key::TrackerKey; +use crate::models::user::UserId; #[derive(Debug, Serialize, Deserialize)] pub struct TorrentInfo { @@ -113,7 +114,7 @@ impl Service { /// /// Will return an error if the HTTP request to get generated a new /// user tracker key failed. - pub async fn get_personal_announce_url(&self, user_id: i64) -> Result { + pub async fn get_personal_announce_url(&self, user_id: UserId) -> Result { let tracker_key = self.database.get_user_tracker_key(user_id).await; match tracker_key { From 2eb0f7c5098b0933f89ad712ad9b549cf9634661 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 19 May 2023 17:14:57 +0100 Subject: [PATCH 184/357] refactor: [#157] extract service for user registration Decoupling services from actix-web framework. --- src/app.rs | 12 +- src/common.rs | 8 +- src/databases/database.rs | 2 +- src/mailer.rs | 3 +- src/routes/user.rs | 155 ++++++-------------------- src/services/category.rs | 4 +- src/services/proxy.rs | 2 +- src/services/settings.rs | 4 +- src/services/torrent.rs | 4 +- src/services/user.rs | 228 +++++++++++++++++++++++++++++++++++++- templates/verify.html | 2 +- 11 files changed, 288 insertions(+), 136 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6c7f8a8e..020d50fb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,7 @@ use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, }; -use crate::services::user::DbUserRepository; +use crate::services::user::{self, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, routes, tracker}; @@ -28,6 +28,7 @@ pub struct Running { pub tracker_data_importer_handle: tokio::task::JoinHandle<()>, } +#[allow(clippy::too_many_lines)] pub async fn run(configuration: Configuration) -> Running { logging::setup(); @@ -58,6 +59,7 @@ pub async fn run(configuration: Configuration) -> Running { // Repositories let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); let user_repository = Arc::new(DbUserRepository::new(database.clone())); + let user_profile_repository = Arc::new(DbUserProfileRepository::new(database.clone())); let torrent_repository = Arc::new(DbTorrentRepository::new(database.clone())); let torrent_info_repository = Arc::new(DbTorrentInfoRepository::new(database.clone())); let torrent_file_repository = Arc::new(DbTorrentFileRepository::new(database.clone())); @@ -79,6 +81,12 @@ pub async fn run(configuration: Configuration) -> Running { torrent_announce_url_repository.clone(), torrent_listing_generator.clone(), )); + let registration_service = Arc::new(user::RegistrationService::new( + configuration.clone(), + mailer_service.clone(), + user_repository.clone(), + user_profile_repository.clone(), + )); // Build app container @@ -93,6 +101,7 @@ pub async fn run(configuration: Configuration) -> Running { // Repositories category_repository, user_repository, + user_profile_repository, torrent_repository, torrent_info_repository, torrent_file_repository, @@ -103,6 +112,7 @@ pub async fn run(configuration: Configuration) -> Running { proxy_service, settings_service, torrent_index, + registration_service, )); // Start repeating task to import tracker torrent data and updating diff --git a/src/common.rs b/src/common.rs index dea79c2c..4049687d 100644 --- a/src/common.rs +++ b/src/common.rs @@ -9,7 +9,7 @@ use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, }; -use crate::services::user::DbUserRepository; +use crate::services::user::{self, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, tracker}; @@ -28,6 +28,7 @@ pub struct AppData { // Repositories pub category_repository: Arc, pub user_repository: Arc, + pub user_profile_repository: Arc, pub torrent_repository: Arc, pub torrent_info_repository: Arc, pub torrent_file_repository: Arc, @@ -38,6 +39,7 @@ pub struct AppData { pub proxy_service: Arc, pub settings_service: Arc, pub torrent_service: Arc, + pub registration_service: Arc, } impl AppData { @@ -53,6 +55,7 @@ impl AppData { // Repositories category_repository: Arc, user_repository: Arc, + user_profile_repository: Arc, torrent_repository: Arc, torrent_info_repository: Arc, torrent_file_repository: Arc, @@ -63,6 +66,7 @@ impl AppData { proxy_service: Arc, settings_service: Arc, torrent_service: Arc, + registration_service: Arc, ) -> AppData { AppData { cfg, @@ -75,6 +79,7 @@ impl AppData { // Repositories category_repository, user_repository, + user_profile_repository, torrent_repository, torrent_info_repository, torrent_file_repository, @@ -85,6 +90,7 @@ impl AppData { proxy_service, settings_service, torrent_service, + registration_service, } } } diff --git a/src/databases/database.rs b/src/databases/database.rs index 7b440378..303e1061 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -101,7 +101,7 @@ pub trait Database: Sync + Send { Self: Sized; /// Add new user and return the newly inserted `user_id`. - async fn insert_user_and_get_id(&self, username: &str, email: &str, password: &str) -> Result; + async fn insert_user_and_get_id(&self, username: &str, email: &str, password: &str) -> Result; /// Get `User` from `user_id`. async fn get_user_from_id(&self, user_id: i64) -> Result; diff --git a/src/mailer.rs b/src/mailer.rs index 42f5aad9..35d2ec3e 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::config::Configuration; use crate::errors::ServiceError; +use crate::routes::API_VERSION; use crate::utils::clock; pub struct Service { @@ -146,7 +147,7 @@ impl Service { base_url = cfg_base_url; } - format!("{base_url}/user/email/verify/{token}") + format!("{base_url}/{API_VERSION}/user/email/verify/{token}") } } diff --git a/src/routes/user.rs b/src/routes/user.rs index 4ca0cf60..017d24cb 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -1,37 +1,38 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use argon2::password_hash::SaltString; -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use log::{debug, info}; -use pbkdf2::password_hash::rand_core::OsRng; +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use log::debug; use pbkdf2::Pbkdf2; use serde::{Deserialize, Serialize}; use crate::common::WebAppData; -use crate::config::EmailOnSignup; use crate::errors::{ServiceError, ServiceResult}; -use crate::mailer::VerifyClaims; use crate::models::response::{OkResponse, TokenResponse}; use crate::models::user::UserAuthentication; use crate::routes::API_VERSION; use crate::utils::clock; -use crate::utils::regex::validate_email_address; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope(&format!("/{API_VERSION}/user")) - .service(web::resource("/register").route(web::post().to(register))) + // Registration + .service(web::resource("/register").route(web::post().to(registration_handler))) + // code-review: should this be part of the REST API? + // - This endpoint should only verify the email. + // - There should be an independent service (web app) serving the email verification page. + // The wep app can user this endpoint to verify the email and render the page accordingly. + .service(web::resource("/email/verify/{token}").route(web::get().to(email_verification_handler))) + // Authentication .service(web::resource("/login").route(web::post().to(login))) - // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. - .service(web::resource("/ban/{user}").route(web::delete().to(ban))) .service(web::resource("/token/verify").route(web::post().to(verify_token))) .service(web::resource("/token/renew").route(web::post().to(renew_token))) - .service(web::resource("/email/verify/{token}").route(web::get().to(verify_email))), + // User Access Ban + // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. + .service(web::resource("/ban/{user}").route(web::delete().to(ban))), ); } #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Register { +pub struct RegistrationForm { pub username: String, pub email: Option, pub password: String, @@ -53,93 +54,21 @@ pub struct Token { /// /// # Errors /// -/// This function will return a `ServiceError::EmailMissing` if email is required, but missing. -/// This function will return a `ServiceError::EmailInvalid` if supplied email is badly formatted. -/// This function will return a `ServiceError::PasswordsDontMatch` if the supplied passwords do not match. -/// This function will return a `ServiceError::PasswordTooShort` if the supplied password is too short. -/// This function will return a `ServiceError::PasswordTooLong` if the supplied password is too long. -/// This function will return a `ServiceError::UsernameInvalid` if the supplied username is badly formatted. -/// This function will return an error if unable to successfully hash the password. -/// This function will return an error if unable to insert user into the database. -/// This function will return a `ServiceError::FailedToSendVerificationEmail` if unable to send the required verification email. -pub async fn register(req: HttpRequest, mut payload: web::Json, app_data: WebAppData) -> ServiceResult { - info!("registering user: {}", payload.username); - - let settings = app_data.cfg.settings.read().await; - - match settings.auth.email_on_signup { - EmailOnSignup::Required => { - if payload.email.is_none() { - return Err(ServiceError::EmailMissing); - } - } - EmailOnSignup::None => payload.email = None, - EmailOnSignup::Optional => {} - } - - if let Some(email) = &payload.email { - // check if email address is valid - if !validate_email_address(email) { - return Err(ServiceError::EmailInvalid); - } - } - - if payload.password != payload.confirm_password { - return Err(ServiceError::PasswordsDontMatch); - } - - let password_length = payload.password.len(); - - if password_length <= settings.auth.min_password_length { - return Err(ServiceError::PasswordTooShort); - } - - if password_length >= settings.auth.max_password_length { - return Err(ServiceError::PasswordTooLong); - } - - let salt = SaltString::generate(&mut OsRng); - - // Argon2 with default params (Argon2id v19) - let argon2 = Argon2::default(); - - // Hash password to PHC string ($argon2id$v=19$...) - let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt)?.to_string(); - - if payload.username.contains('@') { - return Err(ServiceError::UsernameInvalid); - } - - let email = payload.email.as_ref().unwrap_or(&String::new()).to_string(); - - let user_id = app_data - .database - .insert_user_and_get_id(&payload.username, &email, &password_hash) - .await?; - - // if this is the first created account, give administrator rights - if user_id == 1 { - let _ = app_data.database.grant_admin_role(user_id).await; - } - +/// This function will return an error if the user could not be registered. +pub async fn registration_handler( + req: HttpRequest, + registration_form: web::Json, + app_data: WebAppData, +) -> ServiceResult { let conn_info = req.connection_info().clone(); + // todo: we should add this in the configuration. It does not work is the + // server is behind a reverse proxy. + let api_base_url = format!("{}://{}", conn_info.scheme(), conn_info.host()); - if settings.mail.email_verification_enabled && payload.email.is_some() { - let mail_res = app_data - .mailer - .send_verification_mail( - payload.email.as_ref().expect("variable `email` is checked above"), - &payload.username, - user_id, - format!("{}://{}", conn_info.scheme(), conn_info.host()).as_str(), - ) - .await; - - if mail_res.is_err() { - let _ = app_data.database.delete_user(user_id).await; - return Err(ServiceError::FailedToSendVerificationEmail); - } - } + let _user_id = app_data + .registration_service + .register_user(®istration_form, &api_base_url) + .await?; Ok(HttpResponse::Ok()) } @@ -266,35 +195,17 @@ pub async fn renew_token(payload: web::Json, app_data: WebAppData) -> Ser })) } -pub async fn verify_email(req: HttpRequest, app_data: WebAppData) -> String { - let settings = app_data.cfg.settings.read().await; +pub async fn email_verification_handler(req: HttpRequest, app_data: WebAppData) -> String { + // Get token from URL path let token = match req.match_info().get("token").ok_or(ServiceError::InternalServerError) { Ok(token) => token, Err(err) => return err.to_string(), }; - let token_data = match decode::( - token, - &DecodingKey::from_secret(settings.auth.secret_key.as_bytes()), - &Validation::new(Algorithm::HS256), - ) { - Ok(token_data) => { - if !token_data.claims.iss.eq("email-verification") { - return ServiceError::TokenInvalid.to_string(); - } - - token_data.claims - } - Err(_) => return ServiceError::TokenInvalid.to_string(), - }; - - drop(settings); - - if app_data.database.verify_email(token_data.sub).await.is_err() { - return ServiceError::InternalServerError.to_string(); - }; - - String::from("Email verified, you can close this page.") + match app_data.registration_service.verify_email(token).await { + Ok(_) => String::from("Email verified, you can close this page."), + Err(error) => error.to_string(), + } } /// Ban a user from the Index diff --git a/src/services/category.rs b/src/services/category.rs index 0d30836f..2ff56d38 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -30,7 +30,7 @@ impl Service { /// * The user does not have the required permissions. /// * There is a database error. pub async fn add_category(&self, category_name: &str, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact_user(user_id).await?; + let user = self.user_repository.get_compact(user_id).await?; // Check if user is administrator // todo: extract authorization service @@ -56,7 +56,7 @@ impl Service { /// * The user does not have the required permissions. /// * There is a database error. pub async fn delete_category(&self, category_name: &str, user_id: &UserId) -> Result<(), ServiceError> { - let user = self.user_repository.get_compact_user(user_id).await?; + let user = self.user_repository.get_compact(user_id).await?; // Check if user is administrator // todo: extract authorization service diff --git a/src/services/proxy.rs b/src/services/proxy.rs index 45ba5b34..248747c1 100644 --- a/src/services/proxy.rs +++ b/src/services/proxy.rs @@ -39,7 +39,7 @@ impl Service { /// * The image is too big. /// * The user quota is met. pub async fn get_image_by_url(&self, url: &str, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact_user(user_id).await.ok(); + let user = self.user_repository.get_compact(user_id).await.ok(); self.image_cache_service.get_image_by_url(url, user).await } diff --git a/src/services/settings.rs b/src/services/settings.rs index 857ee1fa..f9aa0350 100644 --- a/src/services/settings.rs +++ b/src/services/settings.rs @@ -25,7 +25,7 @@ impl Service { /// /// It returns an error if the user does not have the required permissions. pub async fn get_all(&self, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact_user(user_id).await?; + let user = self.user_repository.get_compact(user_id).await?; // Check if user is administrator // todo: extract authorization service @@ -42,7 +42,7 @@ impl Service { /// /// It returns an error if the user does not have the required permissions. pub async fn update_all(&self, torrust_backend: TorrustBackend, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact_user(user_id).await?; + let user = self.user_repository.get_compact(user_id).await?; // Check if user is administrator // todo: extract authorization service diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 4a20e754..7c18d6d2 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -164,7 +164,7 @@ impl Index { /// * Unable to get the torrent listing from it's ID. /// * Unable to delete the torrent from the database. pub async fn delete_torrent(&self, info_hash: &InfoHash, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact_user(user_id).await?; + let user = self.user_repository.get_compact(user_id).await?; // Only administrator can delete torrents. // todo: move this to an authorization service. @@ -343,7 +343,7 @@ impl Index { description: &Option, user_id: &UserId, ) -> Result { - let updater = self.user_repository.get_compact_user(user_id).await?; + let updater = self.user_repository.get_compact(user_id).await?; let torrent_listing = self.torrent_listing_generator.one_torrent_by_info_hash(info_hash).await?; diff --git a/src/services/user.rs b/src/services/user.rs index a1d19c8c..ca303c0b 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -1,9 +1,186 @@ //! User repository. use std::sync::Arc; -use crate::databases::database::Database; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHasher}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use log::info; +use pbkdf2::password_hash::rand_core::OsRng; + +use crate::config::{Configuration, EmailOnSignup}; +use crate::databases::database::{Database, Error}; use crate::errors::ServiceError; +use crate::mailer; +use crate::mailer::VerifyClaims; use crate::models::user::{UserCompact, UserId}; +use crate::routes::user::RegistrationForm; +use crate::utils::regex::validate_email_address; + +/// Since user email could be optional, we need a way to represent "no email" +/// in the database. This function returns the string that should be used for +/// that purpose. +fn no_email() -> String { + String::new() +} + +pub struct RegistrationService { + configuration: Arc, + mailer: Arc, + user_repository: Arc, + user_profile_repository: Arc, +} + +impl RegistrationService { + #[must_use] + pub fn new( + configuration: Arc, + mailer: Arc, + user_repository: Arc, + user_profile_repository: Arc, + ) -> Self { + Self { + configuration, + mailer, + user_repository, + user_profile_repository, + } + } + + /// It registers a new user. + /// + /// # Errors + /// + /// This function will return a: + /// + /// * `ServiceError::EmailMissing` if email is required, but missing. + /// * `ServiceError::EmailInvalid` if supplied email is badly formatted. + /// * `ServiceError::PasswordsDontMatch` if the supplied passwords do not match. + /// * `ServiceError::PasswordTooShort` if the supplied password is too short. + /// * `ServiceError::PasswordTooLong` if the supplied password is too long. + /// * `ServiceError::UsernameInvalid` if the supplied username is badly formatted. + /// * `ServiceError::FailedToSendVerificationEmail` if unable to send the required verification email. + /// * An error if unable to successfully hash the password. + /// * An error if unable to insert user into the database. + pub async fn register_user(&self, registration_form: &RegistrationForm, api_base_url: &str) -> Result { + info!("registering user: {}", registration_form.username); + + let settings = self.configuration.settings.read().await; + + let opt_email = match settings.auth.email_on_signup { + EmailOnSignup::Required => { + if registration_form.email.is_none() { + return Err(ServiceError::EmailMissing); + } + registration_form.email.clone() + } + EmailOnSignup::None => None, + EmailOnSignup::Optional => registration_form.email.clone(), + }; + + if let Some(email) = ®istration_form.email { + if !validate_email_address(email) { + return Err(ServiceError::EmailInvalid); + } + } + + if registration_form.password != registration_form.confirm_password { + return Err(ServiceError::PasswordsDontMatch); + } + + let password_length = registration_form.password.len(); + + if password_length <= settings.auth.min_password_length { + return Err(ServiceError::PasswordTooShort); + } + + if password_length >= settings.auth.max_password_length { + return Err(ServiceError::PasswordTooLong); + } + + let salt = SaltString::generate(&mut OsRng); + + // Argon2 with default params (Argon2id v19) + let argon2 = Argon2::default(); + + // Hash password to PHC string ($argon2id$v=19$...) + let password_hash = argon2 + .hash_password(registration_form.password.as_bytes(), &salt)? + .to_string(); + + if registration_form.username.contains('@') { + return Err(ServiceError::UsernameInvalid); + } + + let user_id = self + .user_repository + .add( + ®istration_form.username, + &opt_email.clone().unwrap_or(no_email()), + &password_hash, + ) + .await?; + + // If this is the first created account, give administrator rights + if user_id == 1 { + let _ = self.user_repository.grant_admin_role(&user_id).await; + } + + if settings.mail.email_verification_enabled && opt_email.is_some() { + let mail_res = self + .mailer + .send_verification_mail( + &opt_email.expect("variable `email` is checked above"), + ®istration_form.username, + user_id, + api_base_url, + ) + .await; + + if mail_res.is_err() { + let _ = self.user_repository.delete(&user_id).await; + return Err(ServiceError::FailedToSendVerificationEmail); + } + } + + Ok(user_id) + } + + /// It verifies the email address of a user via the token sent to the + /// user's email. + /// + /// # Errors + /// + /// This function will return a `ServiceError::DatabaseError` if unable to + /// update the user's email verification status. + pub async fn verify_email(&self, token: &str) -> Result { + let settings = self.configuration.settings.read().await; + + let token_data = match decode::( + token, + &DecodingKey::from_secret(settings.auth.secret_key.as_bytes()), + &Validation::new(Algorithm::HS256), + ) { + Ok(token_data) => { + if !token_data.claims.iss.eq("email-verification") { + return Ok(false); + } + + token_data.claims + } + Err(_) => return Ok(false), + }; + + drop(settings); + + let user_id = token_data.sub; + + if self.user_profile_repository.verify_email(&user_id).await.is_err() { + return Err(ServiceError::DatabaseError); + }; + + Ok(true) + } +} pub struct DbUserRepository { database: Arc>, @@ -20,7 +197,7 @@ impl DbUserRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn get_compact_user(&self, user_id: &UserId) -> Result { + pub async fn get_compact(&self, user_id: &UserId) -> Result { // todo: persistence layer should have its own errors instead of // returning a `ServiceError`. self.database @@ -28,4 +205,51 @@ impl DbUserRepository { .await .map_err(|_| ServiceError::UserNotFound) } + + /// It grants the admin role to the user. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn grant_admin_role(&self, user_id: &UserId) -> Result<(), Error> { + self.database.grant_admin_role(*user_id).await + } + + /// It deletes the user. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn delete(&self, user_id: &UserId) -> Result<(), Error> { + self.database.delete_user(*user_id).await + } + + /// It adds a new user. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn add(&self, username: &str, email: &str, password_hash: &str) -> Result { + self.database.insert_user_and_get_id(username, email, password_hash).await + } +} + +pub struct DbUserProfileRepository { + database: Arc>, +} + +impl DbUserProfileRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It marks the user's email as verified. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn verify_email(&self, user_id: &UserId) -> Result<(), Error> { + self.database.verify_email(*user_id).await + } } diff --git a/templates/verify.html b/templates/verify.html index 27c4b8fb..e43ad6f0 100644 --- a/templates/verify.html +++ b/templates/verify.html @@ -20,4 +20,4 @@ @import url(https://fonts.googleapis.com/css?family=Poppins);
Torrust

Welcome to Torrust, <%= username %>.
Please click the confirmation link below to verify your account.
Verify account
Or copy and paste the following link into your browser:
© Copyright Torrust 2021
\ No newline at end of file +
Welcome to Torrust, <%= username %>.
Please click the confirmation link below to verify your account.
Verify account
Or copy and paste the following link into your browser:
© Copyright Torrust 2023
\ No newline at end of file From 8101048da08bb050da37d502fa5b8286c654aed3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 22 May 2023 13:27:31 +0100 Subject: [PATCH 185/357] refactor: [#157] extract service to ban users Decoupling services from actix-web framework. --- src/app.rs | 10 ++++- src/common.rs | 8 +++- src/routes/user.rs | 34 +++------------- src/services/user.rs | 94 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 33 deletions(-) diff --git a/src/app.rs b/src/app.rs index 020d50fb..2a7d3d6b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,7 @@ use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, }; -use crate::services::user::{self, DbUserProfileRepository, DbUserRepository}; +use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, routes, tracker}; @@ -65,6 +65,7 @@ pub async fn run(configuration: Configuration) -> Running { let torrent_file_repository = Arc::new(DbTorrentFileRepository::new(database.clone())); let torrent_announce_url_repository = Arc::new(DbTorrentAnnounceUrlRepository::new(database.clone())); let torrent_listing_generator = Arc::new(DbTorrentListingGenerator::new(database.clone())); + let banned_user_list = Arc::new(DbBannedUserList::new(database.clone())); // Services let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); let proxy_service = Arc::new(proxy::Service::new(image_cache_service.clone(), user_repository.clone())); @@ -87,6 +88,11 @@ pub async fn run(configuration: Configuration) -> Running { user_repository.clone(), user_profile_repository.clone(), )); + let ban_service = Arc::new(user::BanService::new( + user_repository.clone(), + user_profile_repository.clone(), + banned_user_list.clone(), + )); // Build app container @@ -107,12 +113,14 @@ pub async fn run(configuration: Configuration) -> Running { torrent_file_repository, torrent_announce_url_repository, torrent_listing_generator, + banned_user_list, // Services category_service, proxy_service, settings_service, torrent_index, registration_service, + ban_service, )); // Start repeating task to import tracker torrent data and updating diff --git a/src/common.rs b/src/common.rs index 4049687d..5db886f3 100644 --- a/src/common.rs +++ b/src/common.rs @@ -9,7 +9,7 @@ use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, }; -use crate::services::user::{self, DbUserProfileRepository, DbUserRepository}; +use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{mailer, tracker}; @@ -34,12 +34,14 @@ pub struct AppData { pub torrent_file_repository: Arc, pub torrent_announce_url_repository: Arc, pub torrent_listing_generator: Arc, + pub banned_user_list: Arc, // Services pub category_service: Arc, pub proxy_service: Arc, pub settings_service: Arc, pub torrent_service: Arc, pub registration_service: Arc, + pub ban_service: Arc, } impl AppData { @@ -61,12 +63,14 @@ impl AppData { torrent_file_repository: Arc, torrent_announce_url_repository: Arc, torrent_listing_generator: Arc, + banned_user_list: Arc, // Services category_service: Arc, proxy_service: Arc, settings_service: Arc, torrent_service: Arc, registration_service: Arc, + ban_service: Arc, ) -> AppData { AppData { cfg, @@ -85,12 +89,14 @@ impl AppData { torrent_file_repository, torrent_announce_url_repository, torrent_listing_generator, + banned_user_list, // Services category_service, proxy_service, settings_service, torrent_service, registration_service, + ban_service, } } } diff --git a/src/routes/user.rs b/src/routes/user.rs index 017d24cb..5c5973da 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -1,6 +1,5 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; -use log::debug; use pbkdf2::Pbkdf2; use serde::{Deserialize, Serialize}; @@ -27,7 +26,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { .service(web::resource("/token/renew").route(web::post().to(renew_token))) // User Access Ban // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. - .service(web::resource("/ban/{user}").route(web::delete().to(ban))), + .service(web::resource("/ban/{user}").route(web::delete().to(ban_handler))), ); } @@ -214,35 +213,12 @@ pub async fn email_verification_handler(req: HttpRequest, app_data: WebAppData) /// /// # Errors /// -/// This function will return a `ServiceError::InternalServerError` if unable get user from the request. -/// This function will return an error if unable to get user profile from supplied username. -/// This function will return an error if unable to ser the ban of the user in the database. -pub async fn ban(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - debug!("banning user"); - - let user = app_data.auth.get_user_compact_from_request(&req).await?; - - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } - +/// This function will return if the user could not be banned. +pub async fn ban_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { + let user_id = app_data.auth.get_user_id_from_request(&req).await?; let to_be_banned_username = req.match_info().get("user").ok_or(ServiceError::InternalServerError)?; - debug!("user to be banned: {}", to_be_banned_username); - - let user_profile = app_data - .database - .get_user_profile_from_username(to_be_banned_username) - .await?; - - let reason = "no reason".to_string(); - - // user will be banned until the year 9999 - let date_expiry = chrono::NaiveDateTime::parse_from_str("9999-01-01 00:00:00", "%Y-%m-%d %H:%M:%S") - .expect("Could not parse date from 9999-01-01 00:00:00."); - - app_data.database.ban_user(user_profile.user_id, &reason, date_expiry).await?; + app_data.ban_service.ban_user(to_be_banned_username, &user_id).await?; Ok(HttpResponse::Ok().json(OkResponse { data: format!("Banned user: {to_be_banned_username}"), diff --git a/src/services/user.rs b/src/services/user.rs index ca303c0b..fb5c82f6 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use log::info; +use log::{debug, info}; use pbkdf2::password_hash::rand_core::OsRng; use crate::config::{Configuration, EmailOnSignup}; @@ -12,7 +12,7 @@ use crate::databases::database::{Database, Error}; use crate::errors::ServiceError; use crate::mailer; use crate::mailer::VerifyClaims; -use crate::models::user::{UserCompact, UserId}; +use crate::models::user::{UserCompact, UserId, UserProfile}; use crate::routes::user::RegistrationForm; use crate::utils::regex::validate_email_address; @@ -182,6 +182,56 @@ impl RegistrationService { } } +pub struct BanService { + user_repository: Arc, + user_profile_repository: Arc, + banned_user_list: Arc, +} + +impl BanService { + #[must_use] + pub fn new( + user_repository: Arc, + user_profile_repository: Arc, + banned_user_list: Arc, + ) -> Self { + Self { + user_repository, + user_profile_repository, + banned_user_list, + } + } + + /// Ban a user from the Index. + /// + /// # Errors + /// + /// This function will return a: + /// + /// * `ServiceError::InternalServerError` if unable get user from the request. + /// * An error if unable to get user profile from supplied username. + /// * An error if unable to set the ban of the user in the database. + pub async fn ban_user(&self, username_to_be_banned: &str, user_id: &UserId) -> Result<(), ServiceError> { + debug!("user with ID {user_id} banning username: {username_to_be_banned}"); + + let user = self.user_repository.get_compact(user_id).await?; + + // Check if user is administrator + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + let user_profile = self + .user_profile_repository + .get_user_profile_from_username(username_to_be_banned) + .await?; + + self.banned_user_list.add(&user_profile.user_id).await?; + + Ok(()) + } +} + pub struct DbUserRepository { database: Arc>, } @@ -252,4 +302,44 @@ impl DbUserProfileRepository { pub async fn verify_email(&self, user_id: &UserId) -> Result<(), Error> { self.database.verify_email(*user_id).await } + + /// It get the user profile from the username. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_user_profile_from_username(&self, username: &str) -> Result { + self.database.get_user_profile_from_username(username).await + } +} + +pub struct DbBannedUserList { + database: Arc>, +} + +impl DbBannedUserList { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It add a user to the banned users list. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn add(&self, user_id: &UserId) -> Result<(), Error> { + // todo: add reason and `date_expiry` parameters to request. + + // code-review: add the user ID of the user who banned the user. + + // For the time being, we will not use a reason for banning a user. + let reason = "no reason".to_string(); + + // User will be banned until the year 9999 + let date_expiry = chrono::NaiveDateTime::parse_from_str("9999-01-01 00:00:00", "%Y-%m-%d %H:%M:%S") + .expect("Could not parse date from 9999-01-01 00:00:00."); + + self.database.ban_user(*user_id, &reason, date_expiry).await + } } From 5396301913081173329ce44259abfde04ab9c0dc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 22 May 2023 16:20:17 +0100 Subject: [PATCH 186/357] refactor: [#157] extract authentication service Decoupling services from actix-web framework. --- src/app.rs | 31 +++-- src/auth.rs | 58 ++------ src/common.rs | 16 ++- src/databases/database.rs | 2 +- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/routes/user.rs | 140 +++---------------- src/services/authentication.rs | 242 +++++++++++++++++++++++++++++++++ src/services/mod.rs | 1 + 9 files changed, 306 insertions(+), 188 deletions(-) create mode 100644 src/services/authentication.rs diff --git a/src/app.rs b/src/app.rs index 2a7d3d6b..3aa8e29e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,12 +6,13 @@ use actix_web::dev::Server; use actix_web::{middleware, web, App, HttpServer}; use log::info; -use crate::auth::AuthorizationService; +use crate::auth::Authentication; use crate::bootstrap::logging; use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; use crate::config::Configuration; use crate::databases::database; +use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, @@ -50,15 +51,13 @@ pub async fn run(configuration: Configuration) -> Running { // Build app dependencies let database = Arc::new(database::connect(&database_connect_url).await.expect("Database error.")); - let auth = Arc::new(AuthorizationService::new(configuration.clone(), database.clone())); - let tracker_service = Arc::new(tracker::service::Service::new(configuration.clone(), database.clone()).await); - let tracker_statistics_importer = - Arc::new(StatisticsImporter::new(configuration.clone(), tracker_service.clone(), database.clone()).await); - let mailer_service = Arc::new(mailer::Service::new(configuration.clone()).await); - let image_cache_service: Arc = Arc::new(ImageCacheService::new(configuration.clone()).await); + let json_web_token = Arc::new(JsonWebToken::new(configuration.clone())); + let auth = Arc::new(Authentication::new(json_web_token.clone())); + // Repositories let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); let user_repository = Arc::new(DbUserRepository::new(database.clone())); + let user_authentication_repository = Arc::new(DbUserAuthenticationRepository::new(database.clone())); let user_profile_repository = Arc::new(DbUserProfileRepository::new(database.clone())); let torrent_repository = Arc::new(DbTorrentRepository::new(database.clone())); let torrent_info_repository = Arc::new(DbTorrentInfoRepository::new(database.clone())); @@ -66,7 +65,13 @@ pub async fn run(configuration: Configuration) -> Running { let torrent_announce_url_repository = Arc::new(DbTorrentAnnounceUrlRepository::new(database.clone())); let torrent_listing_generator = Arc::new(DbTorrentListingGenerator::new(database.clone())); let banned_user_list = Arc::new(DbBannedUserList::new(database.clone())); + // Services + let tracker_service = Arc::new(tracker::service::Service::new(configuration.clone(), database.clone()).await); + let tracker_statistics_importer = + Arc::new(StatisticsImporter::new(configuration.clone(), tracker_service.clone(), database.clone()).await); + let mailer_service = Arc::new(mailer::Service::new(configuration.clone()).await); + let image_cache_service: Arc = Arc::new(ImageCacheService::new(configuration.clone()).await); let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); let proxy_service = Arc::new(proxy::Service::new(image_cache_service.clone(), user_repository.clone())); let settings_service = Arc::new(settings::Service::new(configuration.clone(), user_repository.clone())); @@ -93,20 +98,29 @@ pub async fn run(configuration: Configuration) -> Running { user_profile_repository.clone(), banned_user_list.clone(), )); + let authentication_service = Arc::new(Service::new( + configuration.clone(), + json_web_token.clone(), + user_repository.clone(), + user_profile_repository.clone(), + user_authentication_repository.clone(), + )); // Build app container let app_data = Arc::new(AppData::new( configuration.clone(), database.clone(), + json_web_token.clone(), auth.clone(), + authentication_service, tracker_service.clone(), tracker_statistics_importer.clone(), mailer_service, image_cache_service, - // Repositories category_repository, user_repository, + user_authentication_repository, user_profile_repository, torrent_repository, torrent_info_repository, @@ -114,7 +128,6 @@ pub async fn run(configuration: Configuration) -> Running { torrent_announce_url_repository, torrent_listing_generator, banned_user_list, - // Services category_service, proxy_service, settings_service, diff --git a/src/auth.rs b/src/auth.rs index 609496d4..722cf2f1 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,36 +1,24 @@ use std::sync::Arc; use actix_web::HttpRequest; -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use crate::config::Configuration; -use crate::databases::database::Database; use crate::errors::ServiceError; use crate::models::user::{UserClaims, UserCompact, UserId}; -use crate::utils::clock; +use crate::services::authentication::JsonWebToken; -pub struct AuthorizationService { - cfg: Arc, - database: Arc>, +pub struct Authentication { + json_web_token: Arc, } -impl AuthorizationService { - pub fn new(cfg: Arc, database: Arc>) -> AuthorizationService { - AuthorizationService { cfg, database } +impl Authentication { + #[must_use] + pub fn new(json_web_token: Arc) -> Self { + Self { json_web_token } } /// Create Json Web Token pub async fn sign_jwt(&self, user: UserCompact) -> String { - let settings = self.cfg.settings.read().await; - - // create JWT that expires in two weeks - let key = settings.auth.secret_key.as_bytes(); - // TODO: create config option for setting the token validity in seconds - let exp_date = clock::now() + 1_209_600; // two weeks from now - - let claims = UserClaims { user, exp: exp_date }; - - encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).expect("argument `Header` should match `EncodingKey`") + self.json_web_token.sign(user).await } /// Verify Json Web Token @@ -39,21 +27,7 @@ impl AuthorizationService { /// /// This function will return an error if the JWT is not good or expired. pub async fn verify_jwt(&self, token: &str) -> Result { - let settings = self.cfg.settings.read().await; - - match decode::( - token, - &DecodingKey::from_secret(settings.auth.secret_key.as_bytes()), - &Validation::new(Algorithm::HS256), - ) { - Ok(token_data) => { - if token_data.claims.exp < clock::now() { - return Err(ServiceError::TokenExpired); - } - Ok(token_data.claims) - } - Err(_) => Err(ServiceError::TokenInvalid), - } + self.json_web_token.verify(token).await } /// Get Claims from Request @@ -81,20 +55,6 @@ impl AuthorizationService { } } - /// Get User (in compact form) from Request - /// - /// # Errors - /// - /// This function will return an `ServiceError::UserNotFound` if unable to get user from database. - pub async fn get_user_compact_from_request(&self, req: &HttpRequest) -> Result { - let claims = self.get_claims_from_request(req).await?; - - self.database - .get_user_compact_from_id(claims.user.user_id) - .await - .map_err(|_| ServiceError::UserNotFound) - } - /// Get User id from Request /// /// # Errors diff --git a/src/common.rs b/src/common.rs index 5db886f3..4faa4cae 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,9 +1,10 @@ use std::sync::Arc; -use crate::auth::AuthorizationService; +use crate::auth::Authentication; use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; +use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, @@ -20,7 +21,9 @@ pub type WebAppData = actix_web::web::Data>; pub struct AppData { pub cfg: Arc, pub database: Arc>, - pub auth: Arc, + pub json_web_token: Arc, + pub auth: Arc, + pub authentication_service: Arc, pub tracker_service: Arc, pub tracker_statistics_importer: Arc, pub mailer: Arc, @@ -28,6 +31,7 @@ pub struct AppData { // Repositories pub category_repository: Arc, pub user_repository: Arc, + pub user_authentication_repository: Arc, pub user_profile_repository: Arc, pub torrent_repository: Arc, pub torrent_info_repository: Arc, @@ -49,7 +53,9 @@ impl AppData { pub fn new( cfg: Arc, database: Arc>, - auth: Arc, + json_web_token: Arc, + auth: Arc, + authentication_service: Arc, tracker_service: Arc, tracker_statistics_importer: Arc, mailer: Arc, @@ -57,6 +63,7 @@ impl AppData { // Repositories category_repository: Arc, user_repository: Arc, + user_authentication_repository: Arc, user_profile_repository: Arc, torrent_repository: Arc, torrent_info_repository: Arc, @@ -75,7 +82,9 @@ impl AppData { AppData { cfg, database, + json_web_token, auth, + authentication_service, tracker_service, tracker_statistics_importer, mailer, @@ -83,6 +92,7 @@ impl AppData { // Repositories category_repository, user_repository, + user_authentication_repository, user_profile_repository, torrent_repository, torrent_info_repository, diff --git a/src/databases/database.rs b/src/databases/database.rs index 303e1061..8ac719c6 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -107,7 +107,7 @@ pub trait Database: Sync + Send { async fn get_user_from_id(&self, user_id: i64) -> Result; /// Get `UserAuthentication` from `user_id`. - async fn get_user_authentication_from_id(&self, user_id: i64) -> Result; + async fn get_user_authentication_from_id(&self, user_id: UserId) -> Result; /// Get `UserProfile` from `username`. async fn get_user_profile_from_username(&self, username: &str) -> Result; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 74557175..5e3206db 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -107,7 +107,7 @@ impl Database for Mysql { .map_err(|_| database::Error::UserNotFound) } - async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { + async fn get_user_authentication_from_id(&self, user_id: UserId) -> Result { query_as::<_, UserAuthentication>("SELECT * FROM torrust_user_authentication WHERE user_id = ?") .bind(user_id) .fetch_one(&self.pool) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 0bc6ddd1..31bec6a2 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -108,7 +108,7 @@ impl Database for Sqlite { .map_err(|_| database::Error::UserNotFound) } - async fn get_user_authentication_from_id(&self, user_id: i64) -> Result { + async fn get_user_authentication_from_id(&self, user_id: UserId) -> Result { query_as::<_, UserAuthentication>("SELECT * FROM torrust_user_authentication WHERE user_id = ?") .bind(user_id) .fetch_one(&self.pool) diff --git a/src/routes/user.rs b/src/routes/user.rs index 5c5973da..5912334a 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -1,14 +1,10 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use argon2::{Argon2, PasswordHash, PasswordVerifier}; -use pbkdf2::Pbkdf2; use serde::{Deserialize, Serialize}; use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::{OkResponse, TokenResponse}; -use crate::models::user::UserAuthentication; use crate::routes::API_VERSION; -use crate::utils::clock; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( @@ -21,9 +17,9 @@ pub fn init(cfg: &mut web::ServiceConfig) { // The wep app can user this endpoint to verify the email and render the page accordingly. .service(web::resource("/email/verify/{token}").route(web::get().to(email_verification_handler))) // Authentication - .service(web::resource("/login").route(web::post().to(login))) - .service(web::resource("/token/verify").route(web::post().to(verify_token))) - .service(web::resource("/token/renew").route(web::post().to(renew_token))) + .service(web::resource("/login").route(web::post().to(login_handler))) + .service(web::resource("/token/verify").route(web::post().to(verify_token_handler))) + .service(web::resource("/token/renew").route(web::post().to(renew_token_handler))) // User Access Ban // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. .service(web::resource("/ban/{user}").route(web::delete().to(ban_handler))), @@ -76,42 +72,12 @@ pub async fn registration_handler( /// /// # Errors /// -/// This function will return a `ServiceError::WrongPasswordOrUsername` if unable to get user profile. -/// This function will return a `ServiceError::InternalServerError` if unable to get user authentication data from the user id. -/// This function will return an error if unable to verify the password. -/// This function will return a `ServiceError::EmailNotVerified` if the email should be, but is not verified. -/// This function will return an error if unable to get the user data from the database. -pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceResult { - // get the user profile from database - let user_profile = app_data - .database - .get_user_profile_from_username(&payload.login) - .await - .map_err(|_| ServiceError::WrongPasswordOrUsername)?; - - // should not be able to fail if user_profile succeeded - let user_authentication = app_data - .database - .get_user_authentication_from_id(user_profile.user_id) - .await - .map_err(|_| ServiceError::InternalServerError)?; - - verify_password(payload.password.as_bytes(), &user_authentication)?; - - let settings = app_data.cfg.settings.read().await; - - // fail login if email verification is required and this email is not verified - if settings.mail.email_verification_enabled && !user_profile.email_verified { - return Err(ServiceError::EmailNotVerified); - } - - // drop read lock on settings - drop(settings); - - let user_compact = app_data.database.get_user_compact_from_id(user_profile.user_id).await?; - - // sign jwt with compact user details as payload - let token = app_data.auth.sign_jwt(user_compact.clone()).await; +/// This function will return an error if the user could not be logged in. +pub async fn login_handler(payload: web::Json, app_data: WebAppData) -> ServiceResult { + let (token, user_compact) = app_data + .authentication_service + .login(&payload.login, &payload.password) + .await?; Ok(HttpResponse::Ok().json(OkResponse { data: TokenResponse { @@ -122,43 +88,14 @@ pub async fn login(payload: web::Json, app_data: WebAppData) -> ServiceRe })) } -/// Verify if the user supplied and the database supplied passwords match -/// -/// # Errors -/// -/// This function will return an error if unable to parse password hash from the stored user authentication value. -/// This function will return a `ServiceError::WrongPasswordOrUsername` if unable to match the password with either `argon2id` or `pbkdf2-sha256`. -pub fn verify_password(password: &[u8], user_authentication: &UserAuthentication) -> Result<(), ServiceError> { - // wrap string of the hashed password into a PasswordHash struct for verification - let parsed_hash = PasswordHash::new(&user_authentication.password_hash)?; - - match parsed_hash.algorithm.as_str() { - "argon2id" => { - if Argon2::default().verify_password(password, &parsed_hash).is_err() { - return Err(ServiceError::WrongPasswordOrUsername); - } - - Ok(()) - } - "pbkdf2-sha256" => { - if Pbkdf2.verify_password(password, &parsed_hash).is_err() { - return Err(ServiceError::WrongPasswordOrUsername); - } - - Ok(()) - } - _ => Err(ServiceError::WrongPasswordOrUsername), - } -} - /// Verify a supplied JWT. /// /// # Errors /// /// This function will return an error if unable to verify the supplied payload as a valid jwt. -pub async fn verify_token(payload: web::Json, app_data: WebAppData) -> ServiceResult { - // verify if token is valid - let _claims = app_data.auth.verify_jwt(&payload.token).await?; +pub async fn verify_token_handler(payload: web::Json, app_data: WebAppData) -> ServiceResult { + // Verify if JWT is valid + let _claims = app_data.json_web_token.verify(&payload.token).await?; Ok(HttpResponse::Ok().json(OkResponse { data: "Token is valid.".to_string(), @@ -169,21 +106,10 @@ pub async fn verify_token(payload: web::Json, app_data: WebAppData) -> Se /// /// # Errors /// -/// This function will return an error if unable to verify the supplied payload as a valid jwt. -/// This function will return an error if unable to get user data from the database. -pub async fn renew_token(payload: web::Json, app_data: WebAppData) -> ServiceResult { - const ONE_WEEK_IN_SECONDS: u64 = 604_800; - - // verify if token is valid - let claims = app_data.auth.verify_jwt(&payload.token).await?; - - let user_compact = app_data.database.get_user_compact_from_id(claims.user.user_id).await?; - - // renew token if it is valid for less than one week - let token = match claims.exp - clock::now() { - x if x < ONE_WEEK_IN_SECONDS => app_data.auth.sign_jwt(user_compact.clone()).await, - _ => payload.token.clone(), - }; +/// This function will return an error if unable to verify the supplied +/// payload as a valid JWT. +pub async fn renew_token_handler(payload: web::Json, app_data: WebAppData) -> ServiceResult { + let (token, user_compact) = app_data.authentication_service.renew_token(&payload.token).await?; Ok(HttpResponse::Ok().json(OkResponse { data: TokenResponse { @@ -224,37 +150,3 @@ pub async fn ban_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResul data: format!("Banned user: {to_be_banned_username}"), })) } - -#[cfg(test)] -mod tests { - use super::verify_password; - use crate::models::user::UserAuthentication; - - #[test] - fn password_hashed_with_pbkdf2_sha256_should_be_verified() { - let password = "12345678".as_bytes(); - let password_hash = - "$pbkdf2-sha256$i=10000,l=32$pZIh8nilm+cg6fk5Ubf2zQ$AngLuZ+sGUragqm4bIae/W+ior0TWxYFFaTx8CulqtY".to_string(); - let user_authentication = UserAuthentication { - user_id: 1i64, - password_hash, - }; - - assert!(verify_password(password, &user_authentication).is_ok()); - assert!(verify_password("incorrect password".as_bytes(), &user_authentication).is_err()); - } - - #[test] - fn password_hashed_with_argon2_should_be_verified() { - let password = "87654321".as_bytes(); - let password_hash = - "$argon2id$v=19$m=4096,t=3,p=1$ycK5lJ4xmFBnaJ51M1j1eA$kU3UlNiSc3JDbl48TCj7JBDKmrT92DOUAgo4Yq0+nMw".to_string(); - let user_authentication = UserAuthentication { - user_id: 1i64, - password_hash, - }; - - assert!(verify_password(password, &user_authentication).is_ok()); - assert!(verify_password("incorrect password".as_bytes(), &user_authentication).is_err()); - } -} diff --git a/src/services/authentication.rs b/src/services/authentication.rs new file mode 100644 index 00000000..5f792c87 --- /dev/null +++ b/src/services/authentication.rs @@ -0,0 +1,242 @@ +use std::sync::Arc; + +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use pbkdf2::Pbkdf2; + +use super::user::{DbUserProfileRepository, DbUserRepository}; +use crate::config::Configuration; +use crate::databases::database::{Database, Error}; +use crate::errors::ServiceError; +use crate::models::user::{UserAuthentication, UserClaims, UserCompact, UserId}; +use crate::utils::clock; + +pub struct Service { + configuration: Arc, + json_web_token: Arc, + user_repository: Arc, + user_profile_repository: Arc, + user_authentication_repository: Arc, +} + +impl Service { + pub fn new( + configuration: Arc, + json_web_token: Arc, + user_repository: Arc, + user_profile_repository: Arc, + user_authentication_repository: Arc, + ) -> Self { + Self { + configuration, + json_web_token, + user_repository, + user_profile_repository, + user_authentication_repository, + } + } + + /// Authenticate user with username and password. + /// It returns a JWT token and a compact user profile. + /// + /// # Errors + /// + /// It returns: + /// + /// * A `ServiceError::WrongPasswordOrUsername` if unable to get user profile. + /// * A `ServiceError::InternalServerError` if unable to get user authentication data from the user id. + /// * A `ServiceError::EmailNotVerified` if the email should be, but is not verified. + /// * An error if unable to verify the password. + /// * An error if unable to get the user data from the database. + pub async fn login(&self, username: &str, password: &str) -> Result<(String, UserCompact), ServiceError> { + // Get the user profile from database + let user_profile = self + .user_profile_repository + .get_user_profile_from_username(username) + .await + .map_err(|_| ServiceError::WrongPasswordOrUsername)?; + + // Should not be able to fail if user_profile succeeded + let user_authentication = self + .user_authentication_repository + .get_user_authentication_from_id(&user_profile.user_id) + .await + .map_err(|_| ServiceError::InternalServerError)?; + + verify_password(password.as_bytes(), &user_authentication)?; + + let settings = self.configuration.settings.read().await; + + // Fail login if email verification is required and this email is not verified + if settings.mail.email_verification_enabled && !user_profile.email_verified { + return Err(ServiceError::EmailNotVerified); + } + + // Drop read lock on settings + drop(settings); + + let user_compact = self.user_repository.get_compact(&user_profile.user_id).await?; + + // Sign JWT with compact user details as payload + let token = self.json_web_token.sign(user_compact.clone()).await; + + Ok((token, user_compact)) + } + + /// Renew a supplied JWT. + /// + /// # Errors + /// + /// This function will return an error if: + /// + /// * Unable to verify the supplied payload as a valid jwt. + /// * Unable to get user data from the database. + pub async fn renew_token(&self, token: &str) -> Result<(String, UserCompact), ServiceError> { + const ONE_WEEK_IN_SECONDS: u64 = 604_800; + + // Verify if token is valid + let claims = self.json_web_token.verify(token).await?; + + let user_compact = self.user_repository.get_compact(&claims.user.user_id).await?; + + // Renew token if it is valid for less than one week + let token = match claims.exp - clock::now() { + x if x < ONE_WEEK_IN_SECONDS => self.json_web_token.sign(user_compact.clone()).await, + _ => token.clone().to_owned(), + }; + + Ok((token, user_compact)) + } +} + +pub struct JsonWebToken { + cfg: Arc, +} + +impl JsonWebToken { + pub fn new(cfg: Arc) -> Self { + Self { cfg } + } + + /// Create Json Web Token. + pub async fn sign(&self, user: UserCompact) -> String { + let settings = self.cfg.settings.read().await; + + // Create JWT that expires in two weeks + let key = settings.auth.secret_key.as_bytes(); + + // todo: create config option for setting the token validity in seconds. + let exp_date = clock::now() + 1_209_600; // two weeks from now + + let claims = UserClaims { user, exp: exp_date }; + + encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).expect("argument `Header` should match `EncodingKey`") + } + + /// Verify Json Web Token. + /// + /// # Errors + /// + /// This function will return an error if the JWT is not good or expired. + pub async fn verify(&self, token: &str) -> Result { + let settings = self.cfg.settings.read().await; + + match decode::( + token, + &DecodingKey::from_secret(settings.auth.secret_key.as_bytes()), + &Validation::new(Algorithm::HS256), + ) { + Ok(token_data) => { + if token_data.claims.exp < clock::now() { + return Err(ServiceError::TokenExpired); + } + Ok(token_data.claims) + } + Err(_) => Err(ServiceError::TokenInvalid), + } + } +} + +pub struct DbUserAuthenticationRepository { + database: Arc>, +} + +impl DbUserAuthenticationRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// Get user authentication data from user id. + /// + /// # Errors + /// + /// This function will return an error if unable to get the user + /// authentication data from the database. + pub async fn get_user_authentication_from_id(&self, user_id: &UserId) -> Result { + self.database.get_user_authentication_from_id(*user_id).await + } +} + +/// Verify if the user supplied and the database supplied passwords match +/// +/// # Errors +/// +/// This function will return an error if unable to parse password hash from the stored user authentication value. +/// This function will return a `ServiceError::WrongPasswordOrUsername` if unable to match the password with either `argon2id` or `pbkdf2-sha256`. +fn verify_password(password: &[u8], user_authentication: &UserAuthentication) -> Result<(), ServiceError> { + // wrap string of the hashed password into a PasswordHash struct for verification + let parsed_hash = PasswordHash::new(&user_authentication.password_hash)?; + + match parsed_hash.algorithm.as_str() { + "argon2id" => { + if Argon2::default().verify_password(password, &parsed_hash).is_err() { + return Err(ServiceError::WrongPasswordOrUsername); + } + + Ok(()) + } + "pbkdf2-sha256" => { + if Pbkdf2.verify_password(password, &parsed_hash).is_err() { + return Err(ServiceError::WrongPasswordOrUsername); + } + + Ok(()) + } + _ => Err(ServiceError::WrongPasswordOrUsername), + } +} + +#[cfg(test)] +mod tests { + use super::verify_password; + use crate::models::user::UserAuthentication; + + #[test] + fn password_hashed_with_pbkdf2_sha256_should_be_verified() { + let password = "12345678".as_bytes(); + let password_hash = + "$pbkdf2-sha256$i=10000,l=32$pZIh8nilm+cg6fk5Ubf2zQ$AngLuZ+sGUragqm4bIae/W+ior0TWxYFFaTx8CulqtY".to_string(); + let user_authentication = UserAuthentication { + user_id: 1i64, + password_hash, + }; + + assert!(verify_password(password, &user_authentication).is_ok()); + assert!(verify_password("incorrect password".as_bytes(), &user_authentication).is_err()); + } + + #[test] + fn password_hashed_with_argon2_should_be_verified() { + let password = "87654321".as_bytes(); + let password_hash = + "$argon2id$v=19$m=4096,t=3,p=1$ycK5lJ4xmFBnaJ51M1j1eA$kU3UlNiSc3JDbl48TCj7JBDKmrT92DOUAgo4Yq0+nMw".to_string(); + let user_authentication = UserAuthentication { + user_id: 1i64, + password_hash, + }; + + assert!(verify_password(password, &user_authentication).is_ok()); + assert!(verify_password("incorrect password".as_bytes(), &user_authentication).is_err()); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 306931e0..e298313e 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod about; +pub mod authentication; pub mod category; pub mod proxy; pub mod settings; From 7ce3d5e37fb76adbd9199ae60153e12eaffcc6de Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 24 May 2023 11:16:16 +0200 Subject: [PATCH 187/357] feat: torrent tags --- .../20230321122049_torrust_torrent_tags.sql | 5 + ...230321122825_torrust_torrent_tag_links.sql | 7 ++ .../20230321122049_torrust_torrent_tags.sql | 5 + ...230321122825_torrust_torrent_tag_links.sql | 7 ++ src/app.rs | 8 +- src/common.rs | 8 +- src/databases/database.rs | 26 ++++ src/databases/mysql.rs | 91 ++++++++++++++ src/databases/sqlite.rs | 87 ++++++++++++++ src/errors.rs | 1 + src/models/mod.rs | 1 + src/models/response.rs | 3 + src/models/torrent_tag.rs | 10 ++ src/routes/mod.rs | 2 + src/routes/tag.rs | 80 +++++++++++++ src/routes/torrent.rs | 16 ++- src/services/torrent.rs | 113 +++++++++++++++++- 17 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 migrations/mysql/20230321122049_torrust_torrent_tags.sql create mode 100644 migrations/mysql/20230321122825_torrust_torrent_tag_links.sql create mode 100644 migrations/sqlite3/20230321122049_torrust_torrent_tags.sql create mode 100644 migrations/sqlite3/20230321122825_torrust_torrent_tag_links.sql create mode 100644 src/models/torrent_tag.rs create mode 100644 src/routes/tag.rs diff --git a/migrations/mysql/20230321122049_torrust_torrent_tags.sql b/migrations/mysql/20230321122049_torrust_torrent_tags.sql new file mode 100644 index 00000000..6205d59a --- /dev/null +++ b/migrations/mysql/20230321122049_torrust_torrent_tags.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_tags ( + tag_id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + date_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/mysql/20230321122825_torrust_torrent_tag_links.sql b/migrations/mysql/20230321122825_torrust_torrent_tag_links.sql new file mode 100644 index 00000000..f23cf89c --- /dev/null +++ b/migrations/mysql/20230321122825_torrust_torrent_tag_links.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_tag_links ( + torrent_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY (torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES torrust_torrent_tags(tag_id) ON DELETE CASCADE, + PRIMARY KEY (torrent_id, tag_id) +); diff --git a/migrations/sqlite3/20230321122049_torrust_torrent_tags.sql b/migrations/sqlite3/20230321122049_torrust_torrent_tags.sql new file mode 100644 index 00000000..0f71de15 --- /dev/null +++ b/migrations/sqlite3/20230321122049_torrust_torrent_tags.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_tags ( + tag_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + date_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/sqlite3/20230321122825_torrust_torrent_tag_links.sql b/migrations/sqlite3/20230321122825_torrust_torrent_tag_links.sql new file mode 100644 index 00000000..f23cf89c --- /dev/null +++ b/migrations/sqlite3/20230321122825_torrust_torrent_tag_links.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS torrust_torrent_tag_links ( + torrent_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY (torrent_id) REFERENCES torrust_torrents(torrent_id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES torrust_torrent_tags(tag_id) ON DELETE CASCADE, + PRIMARY KEY (torrent_id, tag_id) +); diff --git a/src/app.rs b/src/app.rs index 3aa8e29e..005616aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,10 +14,7 @@ use crate::config::Configuration; use crate::databases::database; use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; -use crate::services::torrent::{ - DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, - DbTorrentRepository, -}; +use crate::services::torrent::{DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository}; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; @@ -63,6 +60,7 @@ pub async fn run(configuration: Configuration) -> Running { let torrent_info_repository = Arc::new(DbTorrentInfoRepository::new(database.clone())); let torrent_file_repository = Arc::new(DbTorrentFileRepository::new(database.clone())); let torrent_announce_url_repository = Arc::new(DbTorrentAnnounceUrlRepository::new(database.clone())); + let torrent_tag_repository = Arc::new(DbTorrentTagRepository::new(database.clone())); let torrent_listing_generator = Arc::new(DbTorrentListingGenerator::new(database.clone())); let banned_user_list = Arc::new(DbBannedUserList::new(database.clone())); @@ -85,6 +83,7 @@ pub async fn run(configuration: Configuration) -> Running { torrent_info_repository.clone(), torrent_file_repository.clone(), torrent_announce_url_repository.clone(), + torrent_tag_repository.clone(), torrent_listing_generator.clone(), )); let registration_service = Arc::new(user::RegistrationService::new( @@ -126,6 +125,7 @@ pub async fn run(configuration: Configuration) -> Running { torrent_info_repository, torrent_file_repository, torrent_announce_url_repository, + torrent_tag_repository, torrent_listing_generator, banned_user_list, category_service, diff --git a/src/common.rs b/src/common.rs index 4faa4cae..9c13a40b 100644 --- a/src/common.rs +++ b/src/common.rs @@ -6,10 +6,7 @@ use crate::config::Configuration; use crate::databases::database::Database; use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; -use crate::services::torrent::{ - DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, - DbTorrentRepository, -}; +use crate::services::torrent::{DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository}; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; @@ -37,6 +34,7 @@ pub struct AppData { pub torrent_info_repository: Arc, pub torrent_file_repository: Arc, pub torrent_announce_url_repository: Arc, + pub torrent_tag_repository: Arc, pub torrent_listing_generator: Arc, pub banned_user_list: Arc, // Services @@ -69,6 +67,7 @@ impl AppData { torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, + torrent_tag_repository: Arc, torrent_listing_generator: Arc, banned_user_list: Arc, // Services @@ -98,6 +97,7 @@ impl AppData { torrent_info_repository, torrent_file_repository, torrent_announce_url_repository, + torrent_tag_repository, torrent_listing_generator, banned_user_list, // Services diff --git a/src/databases/database.rs b/src/databases/database.rs index 8ac719c6..93cb3204 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -8,6 +8,7 @@ use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; @@ -52,6 +53,7 @@ pub enum Sorting { #[derive(Debug)] pub enum Error { Error, + ErrorWithText(String), UnrecognizedDatabaseDriver, // when the db path does not start with sqlite or mysql UsernameTaken, EmailTaken, @@ -228,6 +230,30 @@ pub trait Database: Sync + Send { /// Update a torrent's description with `torrent_id` and `description`. async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), Error>; + /// Add a new tag. + async fn add_tag(&self, name: &str) -> Result; + + /// Delete a tag. + async fn delete_tag(&self, tag_id: TagId) -> Result; + + /// Add a tag to torrent. + async fn add_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), Error>; + + /// Add multiple tags to a torrent at once. + async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), Error>; + + /// Remove a tag from torrent. + async fn delete_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), Error>; + + /// Remove all tags from torrent. + async fn delete_all_torrent_tag_links(&self, torrent_id: i64) -> Result<(), Error>; + + /// Get all tags as `Vec`. + async fn get_tags(&self) -> Result, Error>; + + /// Get tags for `torrent_id`. + async fn get_tags_for_torrent_id(&self, torrent_id: i64) -> Result, Error>; + /// Update the seeders and leechers info for a torrent with `torrent_id`, `tracker_url`, `seeders` and `leechers`. async fn update_tracker_info(&self, torrent_id: i64, tracker_url: &str, seeders: i64, leechers: i64) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 5e3206db..c9f45daa 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -9,6 +9,7 @@ use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; use crate::utils::clock; @@ -669,6 +670,96 @@ impl Database for Mysql { }) } + async fn add_tag(&self, name: &str) -> Result { + println!("inserting tag: {}", name); + + query_as("INSERT INTO torrust_torrent_tags (name) VALUES (?) RETURNING id, name") + .bind(name) + .fetch_one(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn delete_tag(&self, tag_id: TagId) -> Result { + println!("deleting tag: {}", tag_id); + + query_as("DELETE FROM torrust_torrent_tags WHERE tag_id = ? RETURNING id, name") + .bind(tag_id) + .fetch_one(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn add_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), database::Error> { + query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") + .bind(torrent_id) + .bind(tag_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error) + } + + async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), database::Error> { + let mut transaction = self.pool.begin() + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + + for tag_id in tag_ids { + query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") + .bind(torrent_id) + .bind(tag_id) + .execute(&mut transaction) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + } + + transaction.commit() + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn delete_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), database::Error> { + query("DELETE FROM torrust_torrent_tag_links WHERE torrent_id = ? AND tag_id = ?") + .bind(torrent_id) + .bind(tag_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error) + } + + async fn delete_all_torrent_tag_links(&self, torrent_id: i64) -> Result<(), database::Error> { + query("DELETE FROM torrust_torrent_tag_links WHERE torrent_id = ?") + .bind(torrent_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn get_tags(&self) -> Result, database::Error> { + query_as::<_, TorrentTag>( + "SELECT tag_id, name FROM torrust_torrent_tags" + ) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + + async fn get_tags_for_torrent_id(&self, torrent_id: i64) -> Result, database::Error> { + query_as::<_, TorrentTag>( + "SELECT torrust_torrent_tags.tag_id, torrust_torrent_tags.name + FROM torrust_torrent_tags + JOIN torrust_torrent_tag_links ON torrust_torrent_tags.tag_id = torrust_torrent_tag_links.tag_id + WHERE torrust_torrent_tag_links.torrent_id = ?" + ) + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + async fn update_tracker_info( &self, torrent_id: i64, diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 31bec6a2..042654b6 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -9,6 +9,7 @@ use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; use crate::utils::clock; @@ -659,6 +660,92 @@ impl Database for Sqlite { }) } + async fn add_tag(&self, name: &str) -> Result { + query_as("INSERT INTO torrust_torrent_tags (name) VALUES (?) RETURNING id, name") + .bind(name) + .fetch_one(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn delete_tag(&self, tag_id: TagId) -> Result { + query_as("DELETE FROM torrust_torrent_tags WHERE tag_id = ? RETURNING id, name") + .bind(tag_id) + .fetch_one(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn add_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), database::Error> { + query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") + .bind(torrent_id) + .bind(tag_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error) + } + + async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), database::Error> { + let mut transaction = self.pool.begin() + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + + for tag_id in tag_ids { + query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") + .bind(torrent_id) + .bind(tag_id) + .execute(&mut transaction) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + } + + transaction.commit() + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn delete_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), database::Error> { + query("DELETE FROM torrust_torrent_tag_links WHERE torrent_id = ? AND tag_id = ?") + .bind(torrent_id) + .bind(tag_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error) + } + + async fn delete_all_torrent_tag_links(&self, torrent_id: i64) -> Result<(), database::Error> { + query("DELETE FROM torrust_torrent_tag_links WHERE torrent_id = ?") + .bind(torrent_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + + async fn get_tags(&self) -> Result, database::Error> { + query_as::<_, TorrentTag>( + "SELECT tag_id, name FROM torrust_torrent_tags" + ) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + + async fn get_tags_for_torrent_id(&self, torrent_id: i64) -> Result, database::Error> { + query_as::<_, TorrentTag>( + "SELECT torrust_torrent_tags.tag_id, torrust_torrent_tags.name + FROM torrust_torrent_tags + JOIN torrust_torrent_tag_links ON torrust_torrent_tags.tag_id = torrust_torrent_tag_links.tag_id + WHERE torrust_torrent_tag_links.torrent_id = ?" + ) + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) + } + async fn update_tracker_info( &self, torrent_id: i64, diff --git a/src/errors.rs b/src/errors.rs index 0d3d4067..528e7bc0 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -211,6 +211,7 @@ impl From for ServiceError { #[allow(clippy::match_same_arms)] match e { database::Error::Error => ServiceError::InternalServerError, + database::Error::ErrorWithText(_) => ServiceError::InternalServerError, database::Error::UsernameTaken => ServiceError::UsernameTaken, database::Error::EmailTaken => ServiceError::EmailTaken, database::Error::UserNotFound => ServiceError::UserNotFound, diff --git a/src/models/mod.rs b/src/models/mod.rs index 5e54368f..5fff6a4a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,3 +5,4 @@ pub mod torrent; pub mod torrent_file; pub mod tracker_key; pub mod user; +pub mod torrent_tag; diff --git a/src/models/response.rs b/src/models/response.rs index 8d9a2d90..09476be3 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -4,6 +4,7 @@ use super::torrent::TorrentId; use crate::databases::database::Category; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::TorrentFile; +use crate::models::torrent_tag::TorrentTag; pub enum OkResponses { TokenResponse(TokenResponse), @@ -59,6 +60,7 @@ pub struct TorrentResponse { pub files: Vec, pub trackers: Vec, pub magnet_link: String, + pub tags: Vec, } impl TorrentResponse { @@ -78,6 +80,7 @@ impl TorrentResponse { files: vec![], trackers: vec![], magnet_link: String::new(), + tags: vec![], } } } diff --git a/src/models/torrent_tag.rs b/src/models/torrent_tag.rs new file mode 100644 index 00000000..2da97303 --- /dev/null +++ b/src/models/torrent_tag.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +pub type TagId = i64; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, FromRow)] +pub struct TorrentTag { + pub tag_id: TagId, + pub name: String, +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f9b0f2cd..fc76c52a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -7,6 +7,7 @@ pub mod root; pub mod settings; pub mod torrent; pub mod user; +pub mod tag; pub const API_VERSION: &str = "v1"; @@ -17,5 +18,6 @@ pub fn init(cfg: &mut web::ServiceConfig) { settings::init(cfg); about::init(cfg); proxy::init(cfg); + tag::init(cfg); root::init(cfg); } diff --git a/src/routes/tag.rs b/src/routes/tag.rs new file mode 100644 index 00000000..b35fb622 --- /dev/null +++ b/src/routes/tag.rs @@ -0,0 +1,80 @@ +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; + +use crate::common::WebAppData; +use crate::errors::{ServiceError, ServiceResult}; +use crate::models::response::OkResponse; +use crate::models::torrent_tag::TagId; +use crate::routes::API_VERSION; + +pub fn init(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope(&format!("/{API_VERSION}/tag")) + .service( + web::resource("") + .route(web::post().to(add_tag)) + .route(web::delete().to(delete_tag)), + ) + ); + cfg.service( + web::scope(&format!("/{API_VERSION}/tags")) + .service( + web::resource("") + .route(web::get().to(get_tags)) + ) + ); +} + +pub async fn get_tags(app_data: WebAppData) -> ServiceResult { + let tags = app_data.torrent_tag_repository.get_tags().await?; + + Ok(HttpResponse::Ok().json(OkResponse { data: tags })) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AddTag { + pub name: String, +} + +pub async fn add_tag(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { + let user_id = app_data.auth.get_user_id_from_request(&req).await?; + + let user = app_data.user_repository.get_compact(&user_id).await?; + + // check if user is administrator + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + let tag = app_data.torrent_tag_repository.add_tag(&payload.name).await?; + + Ok(HttpResponse::Ok().json(OkResponse { + data: tag, + })) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeleteTag { + pub tag_id: TagId, +} + +pub async fn delete_tag( + req: HttpRequest, + payload: web::Json, + app_data: WebAppData, +) -> ServiceResult { + let user_id = app_data.auth.get_user_id_from_request(&req).await?; + + let user = app_data.user_repository.get_compact(&user_id).await?; + + // check if user is administrator + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + let tag = app_data.torrent_tag_repository.delete_tag(&payload.tag_id).await?; + + Ok(HttpResponse::Ok().json(OkResponse { + data: tag, + })) +} diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 7327ff80..4b857f4b 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -13,6 +13,7 @@ use crate::errors::{ServiceError, ServiceResult}; use crate::models::info_hash::InfoHash; use crate::models::response::{NewTorrentResponse, OkResponse}; use crate::models::torrent::TorrentRequest; +use crate::models::torrent_tag::TagId; use crate::routes::API_VERSION; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; @@ -44,6 +45,7 @@ pub struct Create { pub title: String, pub description: String, pub category: String, + pub tags: Vec } impl Create { @@ -65,6 +67,7 @@ impl Create { pub struct Update { title: Option, description: Option, + tags: Option>, } /// Upload a Torrent to the Index @@ -138,7 +141,7 @@ pub async fn update_torrent_info_handler( let torrent_response = app_data .torrent_service - .update_torrent_info(&info_hash, &payload.title, &payload.description, &user_id) + .update_torrent_info(&info_hash, &payload.title, &payload.description, &payload.tags, &user_id) .await?; Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) @@ -193,21 +196,25 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result = vec![]; while let Ok(Some(mut field)) = payload.try_next().await { match field.content_disposition().get_name().unwrap() { - "title" | "description" | "category" => { + "title" | "description" | "category" | "tags" => { let data = field.next().await; + if data.is_none() { continue; } - let wrapped_data = &data.unwrap().unwrap(); - let parsed_data = std::str::from_utf8(wrapped_data).unwrap(); + + let wrapped_data = &data.unwrap().map_err(|_| ServiceError::BadRequest)?; + let parsed_data = std::str::from_utf8(wrapped_data).map_err(|_| ServiceError::BadRequest)?; match field.content_disposition().get_name().unwrap() { "title" => title = parsed_data.to_string(), "description" => description = parsed_data.to_string(), "category" => category = parsed_data.to_string(), + "tags" => tags = serde_json::from_str(parsed_data).map_err(|_| ServiceError::BadRequest)?, _ => {} } } @@ -229,6 +236,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result, @@ -25,6 +26,7 @@ pub struct Index { torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, + torrent_tag_repository: Arc, torrent_listing_generator: Arc, } @@ -62,6 +64,7 @@ impl Index { torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, + torrent_tag_repository: Arc, torrent_listing_repository: Arc, ) -> Self { Self { @@ -74,6 +77,7 @@ impl Index { torrent_info_repository, torrent_file_repository, torrent_announce_url_repository, + torrent_tag_repository, torrent_listing_generator: torrent_listing_repository, } } @@ -117,6 +121,8 @@ impl Index { return Err(e); } + let _ = self.torrent_tag_repository.link_torrent_to_tags(&torrent_id, &torrent_request.fields.tags).await?; + Ok(torrent_id) } @@ -274,6 +280,8 @@ impl Index { torrent_response.leechers = torrent_info.leechers; } + torrent_response.tags = self.torrent_tag_repository.get_tags_for_torrent(&torrent_id).await?; + Ok(torrent_response) } @@ -341,6 +349,7 @@ impl Index { info_hash: &InfoHash, title: &Option, description: &Option, + tags: &Option>, user_id: &UserId, ) -> Result { let updater = self.user_repository.get_compact(user_id).await?; @@ -354,7 +363,7 @@ impl Index { } self.torrent_info_repository - .update(&torrent_listing.torrent_id, title, description) + .update(&torrent_listing.torrent_id, title, description, tags) .await?; let torrent_listing = self @@ -450,6 +459,7 @@ impl DbTorrentInfoRepository { torrent_id: &TorrentId, opt_title: &Option, opt_description: &Option, + opt_tags: &Option>, ) -> Result<(), Error> { if let Some(title) = &opt_title { self.database.update_torrent_title(*torrent_id, title).await?; @@ -459,6 +469,24 @@ impl DbTorrentInfoRepository { self.database.update_torrent_description(*torrent_id, description).await?; } + if let Some(tags) = opt_tags { + let mut current_tags: Vec = self.database.get_tags_for_torrent_id(*torrent_id) + .await? + .iter() + .map(|tag| tag.tag_id) + .collect(); + + let mut new_tags = tags.clone(); + + current_tags.sort(); + new_tags.sort(); + + if new_tags != current_tags { + self.database.delete_all_torrent_tag_links(*torrent_id).await?; + self.database.add_torrent_tag_links(*torrent_id, tags).await?; + } + } + Ok(()) } } @@ -506,6 +534,89 @@ impl DbTorrentAnnounceUrlRepository { } } +pub struct DbTorrentTagRepository { + database: Arc>, +} + +impl DbTorrentTagRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It adds a new tag and returns the newly created tag. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn add_tag(&self, tag_name: &str) -> Result { + self.database.add_tag(tag_name).await + } + + /// It adds a new torrent tag link. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn link_torrent_to_tag(&self, torrent_id: &TorrentId, tag_id: &TagId) -> Result<(), Error> { + self.database.add_torrent_tag_link(*torrent_id, *tag_id).await + } + + /// It adds multiple torrent tag links at once. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn link_torrent_to_tags(&self, torrent_id: &TorrentId, tag_ids: &Vec) -> Result<(), Error> { + self.database.add_torrent_tag_links(*torrent_id, tag_ids).await + } + + /// It returns all the tags. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_tags(&self) -> Result, Error> { + self.database.get_tags().await + } + + /// It returns all the tags linked to a certain torrent ID. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_tags_for_torrent(&self, torrent_id: &TorrentId) -> Result, Error> { + self.database.get_tags_for_torrent_id(*torrent_id).await + } + + /// It removes a tag and returns it. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn delete_tag(&self, tag_id: &TagId) -> Result { + self.database.delete_tag(*tag_id).await + } + + /// It removes a torrent tag link. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn unlink_torrent_from_tag(&self, torrent_id: &TorrentId, tag_id: &TagId) -> Result<(), Error> { + self.database.delete_torrent_tag_link(*torrent_id, *tag_id).await + } + + /// It removes all tags for a certain torrent. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn unlink_all_tags_for_torrent(&self, torrent_id: &TorrentId) -> Result<(), Error> { + self.database.delete_all_torrent_tag_links(*torrent_id).await + } +} + pub struct DbTorrentListingGenerator { database: Arc>, } From a1bd92f907773d89b2af801570403c03c82e80e7 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Wed, 24 May 2023 12:26:16 +0200 Subject: [PATCH 188/357] fix: sql queries --- src/databases/database.rs | 4 ++-- src/databases/mysql.rs | 18 ++++++++---------- src/databases/sqlite.rs | 14 ++++++++------ src/routes/tag.rs | 8 ++++---- src/services/torrent.rs | 6 +++--- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 93cb3204..66bf57e4 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -231,10 +231,10 @@ pub trait Database: Sync + Send { async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), Error>; /// Add a new tag. - async fn add_tag(&self, name: &str) -> Result; + async fn add_tag(&self, name: &str) -> Result<(), Error>; /// Delete a tag. - async fn delete_tag(&self, tag_id: TagId) -> Result; + async fn delete_tag(&self, tag_id: TagId) -> Result<(), Error>; /// Add a tag to torrent. async fn add_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index c9f45daa..f8b1386a 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -670,23 +670,21 @@ impl Database for Mysql { }) } - async fn add_tag(&self, name: &str) -> Result { - println!("inserting tag: {}", name); - - query_as("INSERT INTO torrust_torrent_tags (name) VALUES (?) RETURNING id, name") + async fn add_tag(&self, name: &str) -> Result<(), database::Error> { + query("INSERT INTO torrust_torrent_tags (name) VALUES (?)") .bind(name) - .fetch_one(&self.pool) + .execute(&self.pool) .await + .map(|_| ()) .map_err(|err| database::Error::ErrorWithText(err.to_string())) } - async fn delete_tag(&self, tag_id: TagId) -> Result { - println!("deleting tag: {}", tag_id); - - query_as("DELETE FROM torrust_torrent_tags WHERE tag_id = ? RETURNING id, name") + async fn delete_tag(&self, tag_id: TagId) -> Result<(), database::Error> { + query("DELETE FROM torrust_torrent_tags WHERE tag_id = ?") .bind(tag_id) - .fetch_one(&self.pool) + .execute(&self.pool) .await + .map(|_| ()) .map_err(|err| database::Error::ErrorWithText(err.to_string())) } diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 042654b6..b63e61ee 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -660,19 +660,21 @@ impl Database for Sqlite { }) } - async fn add_tag(&self, name: &str) -> Result { - query_as("INSERT INTO torrust_torrent_tags (name) VALUES (?) RETURNING id, name") + async fn add_tag(&self, name: &str) -> Result<(), database::Error> { + query("INSERT INTO torrust_torrent_tags (name) VALUES (?)") .bind(name) - .fetch_one(&self.pool) + .execute(&self.pool) .await + .map(|_| ()) .map_err(|err| database::Error::ErrorWithText(err.to_string())) } - async fn delete_tag(&self, tag_id: TagId) -> Result { - query_as("DELETE FROM torrust_torrent_tags WHERE tag_id = ? RETURNING id, name") + async fn delete_tag(&self, tag_id: TagId) -> Result<(), database::Error> { + query("DELETE FROM torrust_torrent_tags WHERE tag_id = ?") .bind(tag_id) - .fetch_one(&self.pool) + .execute(&self.pool) .await + .map(|_| ()) .map_err(|err| database::Error::ErrorWithText(err.to_string())) } diff --git a/src/routes/tag.rs b/src/routes/tag.rs index b35fb622..7fc9a4f6 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -46,10 +46,10 @@ pub async fn add_tag(req: HttpRequest, payload: web::Json, app_data: Web return Err(ServiceError::Unauthorized); } - let tag = app_data.torrent_tag_repository.add_tag(&payload.name).await?; + app_data.torrent_tag_repository.add_tag(&payload.name).await?; Ok(HttpResponse::Ok().json(OkResponse { - data: tag, + data: payload.name.to_string(), })) } @@ -72,9 +72,9 @@ pub async fn delete_tag( return Err(ServiceError::Unauthorized); } - let tag = app_data.torrent_tag_repository.delete_tag(&payload.tag_id).await?; + app_data.torrent_tag_repository.delete_tag(&payload.tag_id).await?; Ok(HttpResponse::Ok().json(OkResponse { - data: tag, + data: payload.tag_id, })) } diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 8023244a..d6429a66 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -121,7 +121,7 @@ impl Index { return Err(e); } - let _ = self.torrent_tag_repository.link_torrent_to_tags(&torrent_id, &torrent_request.fields.tags).await?; + self.torrent_tag_repository.link_torrent_to_tags(&torrent_id, &torrent_request.fields.tags).await?; Ok(torrent_id) } @@ -549,7 +549,7 @@ impl DbTorrentTagRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn add_tag(&self, tag_name: &str) -> Result { + pub async fn add_tag(&self, tag_name: &str) -> Result<(), Error> { self.database.add_tag(tag_name).await } @@ -594,7 +594,7 @@ impl DbTorrentTagRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn delete_tag(&self, tag_id: &TagId) -> Result { + pub async fn delete_tag(&self, tag_id: &TagId) -> Result<(), Error> { self.database.delete_tag(*tag_id).await } From 9ca7341158d5ae2337eae4fb793f771ec93ff004 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 25 May 2023 11:17:09 +0100 Subject: [PATCH 189/357] docs(api): [#163] API documentation on docs.rs --- ...ndelbrot_2048x2048_infohash_v1.png.torrent | Bin 0 -> 375 bytes ...rot_2048x2048_infohash_v1.png.torrent.json | 10 + project-words.txt | 6 + src/cache/image/manager.rs | 2 + src/lib.rs | 10 + src/web/api/mod.rs | 6 + src/web/api/v1/auth.rs | 80 +++++ src/web/api/v1/contexts/about/mod.rs | 86 +++++ src/web/api/v1/contexts/category/mod.rs | 140 ++++++++ src/web/api/v1/contexts/mod.rs | 17 + src/web/api/v1/contexts/proxy/mod.rs | 43 +++ src/web/api/v1/contexts/settings/mod.rs | 169 +++++++++ src/web/api/v1/contexts/torrent/mod.rs | 330 ++++++++++++++++++ src/web/api/v1/contexts/user/mod.rs | 245 +++++++++++++ src/web/api/v1/mod.rs | 8 + src/web/mod.rs | 6 + 16 files changed, 1158 insertions(+) create mode 100644 docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent create mode 100644 docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent.json create mode 100644 src/web/api/mod.rs create mode 100644 src/web/api/v1/auth.rs create mode 100644 src/web/api/v1/contexts/about/mod.rs create mode 100644 src/web/api/v1/contexts/category/mod.rs create mode 100644 src/web/api/v1/contexts/mod.rs create mode 100644 src/web/api/v1/contexts/proxy/mod.rs create mode 100644 src/web/api/v1/contexts/settings/mod.rs create mode 100644 src/web/api/v1/contexts/torrent/mod.rs create mode 100644 src/web/api/v1/contexts/user/mod.rs create mode 100644 src/web/api/v1/mod.rs create mode 100644 src/web/mod.rs diff --git a/docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent b/docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent new file mode 100644 index 0000000000000000000000000000000000000000..1a08a811bfc6bfe1f10e2362b951343b11c09dcf GIT binary patch literal 375 zcmYc>G_Xo8N=+V(0!*Kov4u&h8APxcXogko#6sS=zPmbRhbV+xRV(U^YO{Z+ zc)$Gk_uV<=(-hNGEsowiySkg>eUHlQO<#|k)w<7pfak;7`$ZF;Zgcce&T*gJH{Wd) z4{zVa>HptlbjO=4Zogi5blJ~7y=8_qdq4bqr2O+t!}<^gmY=PeGUc5I%lxjb+Ml@W zP{YjC+u{@S9agCBGg@prmz}3FW5$)a$)4*KBGT0IXJqFZnVhZV5RH?%z`z;4+Q3>N z;ou#i_G_P?yZRTTbD9~3ep2|Bvs5%+r*mqiSa4&l;m2)z&7IB&a&P*h?YXpd+n=%$ Im&vKA0ABB%Bme*a literal 0 HcmV?d00001 diff --git a/docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent.json b/docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent.json new file mode 100644 index 00000000..caaa1a41 --- /dev/null +++ b/docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent.json @@ -0,0 +1,10 @@ +{ + "created by": "qBittorrent v4.4.1", + "creation date": 1679674628, + "info": { + "length": 172204, + "name": "mandelbrot_2048x2048.png", + "piece length": 16384, + "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" + } +} \ No newline at end of file diff --git a/project-words.txt b/project-words.txt index 289364ad..5ca22cd6 100644 --- a/project-words.txt +++ b/project-words.txt @@ -3,6 +3,7 @@ addrs AUTOINCREMENT bencode bencoded +Benoit binascii btih chrono @@ -21,6 +22,7 @@ hexlify httpseeds imagoodboy imdl +indexadmin indexmap infohash jsonwebtoken @@ -30,6 +32,7 @@ LEECHERS lettre luckythelab mailcatcher +mandelbrotset metainfo nanos NCCA @@ -38,6 +41,7 @@ nocapture Oberhachingerstr oneshot ppassword +proxied reqwest Roadmap ROADMAP @@ -54,6 +58,8 @@ tempfile thiserror torrust Torrust +unban +Ununauthorized upgrader Uragqm urlencoding diff --git a/src/cache/image/manager.rs b/src/cache/image/manager.rs index 9e8d814c..40367ca9 100644 --- a/src/cache/image/manager.rs +++ b/src/cache/image/manager.rs @@ -153,6 +153,8 @@ impl ImageCacheService { .await .map_err(|_| Error::UrlIsUnreachable)?; + // code-review: we could get a HTTP 304 response, which doesn't contain a body (the image bytes). + if let Some(content_type) = res.headers().get("Content-Type") { if content_type != "image/jpeg" && content_type != "image/png" { return Err(Error::UrlIsNotAnImage); diff --git a/src/lib.rs b/src/lib.rs index 03213e05..c66d8e73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,12 @@ +//! Documentation for [Torrust Tracker Index Backend](https://github.com/torrust/torrust-index-backend) API. +//! +//! This is the backend API for [Torrust Tracker Index](https://github.com/torrust/torrust-index). +//! +//! It is written in Rust and uses the actix-web framework. It is designed to be +//! used with by the [Torrust Tracker Index Frontend](https://github.com/torrust/torrust-index-frontend). +//! +//! If you are looking for information on how to use the API, please see the +//! [API v1](crate::web::api::v1) section of the documentation. pub mod app; pub mod auth; pub mod bootstrap; @@ -15,6 +24,7 @@ pub mod tracker; pub mod ui; pub mod upgrades; pub mod utils; +pub mod web; trait AsCSV { fn as_csv(&self) -> Result>, ()> diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs new file mode 100644 index 00000000..8582ba66 --- /dev/null +++ b/src/web/api/mod.rs @@ -0,0 +1,6 @@ +//! The Torrust Index Backend API. +//! +//! Currently, the API has only one version: `v1`. +//! +//! Refer to the [`v1`](crate::web::api::v1) module for more information. +pub mod v1; diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs new file mode 100644 index 00000000..b84adee9 --- /dev/null +++ b/src/web/api/v1/auth.rs @@ -0,0 +1,80 @@ +//! API authentication. +//! +//! The API uses a [bearer token authentication scheme](https://datatracker.ietf.org/doc/html/rfc6750). +//! +//! API clients must have an account on the website to be able to use the API. +//! +//! # Authentication flow +//! +//! - [Registration](#registration) +//! - [Login](#login) +//! - [Using the token](#using-the-token) +//! +//! ## Registration +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request POST \ +//! --data '{"username":"indexadmin","email":"indexadmin@torrust.com","password":"BenoitMandelbrot1924","confirm_password":"BenoitMandelbrot1924"}' \ +//! http://127.0.0.1:3000/v1/user/register +//! ``` +//! +//! **NOTICE**: The first user is automatically an administrator. Currently, +//! there is no way to change this. There is one administrator per instance. +//! And you cannot delete the administrator account or make another user an +//! administrator. For testing purposes, you can create a new administrator +//! account by creating a new user and then manually changing the `administrator` +//! field in the `torrust_users` table to `1`. +//! +//! ## Login +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request POST \ +//! --data '{"login":"indexadmin","password":"BenoitMandelbrot1924"}' \ +//! http://127.0.0.1:3000/v1/user/login +//! ``` +//! +//! **Response** +//! +//! ```json +//! { +//! "data":{ +//! "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI", +//! "username":"indexadmin", +//! "admin":true +//! } +//! } +//! ``` +//! +//! **NOTICE**: The token is valid for 2 weeks (`1_209_600` seconds). After that, +//! you will have to renew the token. +//! +//! **NOTICE**: The token is associated with the user role. If you change the +//! user's role, you will have to log in again to get a new token with the new +//! role. +//! +//! ## Using the token +//! +//! Some endpoints require authentication. To use the token, you must add the +//! `Authorization` header to your request. For example, if you want to add a +//! new category, you must do the following: +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request POST \ +//! --data '{"name":"new category","icon":null}' \ +//! http://127.0.0.1:3000/v1/category +//! ``` +//! +//! **Response** +//! +//! ```json +//! { +//! "data": "new category" +//! } +//! ``` diff --git a/src/web/api/v1/contexts/about/mod.rs b/src/web/api/v1/contexts/about/mod.rs new file mode 100644 index 00000000..0b12ff66 --- /dev/null +++ b/src/web/api/v1/contexts/about/mod.rs @@ -0,0 +1,86 @@ +//! API context: `about`. +//! +//! This API context is responsible for providing metadata about the API. +//! +//! # Endpoints +//! +//! - [About](#about) +//! - [License](#license) +//! +//! # About +//! +//! `GET /v1/about` +//! +//! Returns a html page with information about the API. +//! +//! +//! **Example request** +//! +//! ```bash +//! curl "http://127.0.0.1:3000/v1/about" +//! ``` +//! +//! **Example response** `200` +//! +//! ```html +//! +//! +//! About +//! +//! +//!

Torrust Index Backend

+//! +//!

About

+//! +//!

Hi! This is a running torrust-index-backend.

+//! +//! +//! +//! ``` +//! +//! # License +//! +//! `GET /v1/about/license` +//! +//! Returns the API license. +//! +//! **Example request** +//! +//! ```bash +//! curl "http://127.0.0.1:3000/v1/about/license" +//! ``` +//! +//! **Example response** `200` +//! +//! ```html +//! +//! +//! Licensing +//! +//! +//!

Torrust Index Backend

+//! +//!

Licensing

+//! +//!

Multiple Licenses

+//! +//!

+//! This repository has multiple licenses depending on the content type, the date of contributions or stemming from external component licenses that were not developed by any of Torrust team members or Torrust repository +//! contributors. +//!

+//! +//!

The two main applicable license to most of its content are:

+//! +//!

- For Code -- agpl-3.0

+//! +//!

- For Media (Images, etc.) -- cc-by-sa

+//! +//!

If you want to read more about all the licenses and how they apply please refer to the contributor agreement.

+//! +//! +//! +//! ``` diff --git a/src/web/api/v1/contexts/category/mod.rs b/src/web/api/v1/contexts/category/mod.rs new file mode 100644 index 00000000..68cf07e3 --- /dev/null +++ b/src/web/api/v1/contexts/category/mod.rs @@ -0,0 +1,140 @@ +//! API context: `category`. +//! +//! This API context is responsible for handling torrent categories. +//! +//! # Endpoints +//! +//! - [Get all categories](#get-all-categories) +//! - [Add a category](#add-a-category) +//! - [Delete a category](#delete-a-category) +//! +//! **NOTICE**: We don't support multiple languages yet, so the category name +//! is always in English. +//! +//! # Get all categories +//! +//! `GET /v1/category` +//! +//! Returns all torrent categories. +//! +//! **Example request** +//! +//! ```bash +//! curl "http://127.0.0.1:3000/v1/category" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": [ +//! { +//! "category_id": 3, +//! "name": "games", +//! "num_torrents": 0 +//! }, +//! { +//! "category_id": 1, +//! "name": "movies", +//! "num_torrents": 0 +//! }, +//! { +//! "category_id": 4, +//! "name": "music", +//! "num_torrents": 0 +//! }, +//! { +//! "category_id": 5, +//! "name": "software", +//! "num_torrents": 0 +//! }, +//! { +//! "category_id": 2, +//! "name": "tv shows", +//! "num_torrents": 0 +//! } +//! ] +//! } +//! ``` +//! **Resource** +//! +//! Refer to the [`Category`](crate::databases::database::Category) +//! struct for more information about the response attributes. +//! +//! # Add a category +//! +//! `POST /v1/category` +//! +//! It adds a new category. +//! +//! **POST params** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `name` | `String` | The name of the category | Yes | `new category` +//! `icon` | `Option` | Icon representing the category | No | +//! +//! **Notice**: the `icon` field is not implemented yet. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request POST \ +//! --data '{"name":"new category","icon":null}' \ +//! http://127.0.0.1:3000/v1/category +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": "new category" +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to [`OkResponse`](crate::models::response::OkResponse) for more +//! information about the response attributes. The response contains only the +//! name of the newly created category. +//! +//! # Delete a category +//! +//! `DELETE /v1/category` +//! +//! It deletes a category. +//! +//! **POST params** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `name` | `String` | The name of the category | Yes | `new category` +//! `icon` | `Option` | Icon representing the category | No | +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request DELETE \ +//! --data '{"name":"new category","icon":null}' \ +//! http://127.0.0.1:3000/v1/category +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": "new category" +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to [`OkResponse`](crate::models::response::OkResponse) for more +//! information about the response attributes. The response contains only the +//! name of the deleted category. diff --git a/src/web/api/v1/contexts/mod.rs b/src/web/api/v1/contexts/mod.rs new file mode 100644 index 00000000..7119d40d --- /dev/null +++ b/src/web/api/v1/contexts/mod.rs @@ -0,0 +1,17 @@ +//! The API is organized in the following contexts: +//! +//! Context | Description | Version +//! ---|---|--- +//! `About` | Metadata about the API | [`v1`](crate::web::api::v1::contexts::about) +//! `Category` | Torrent categories | [`v1`](crate::web::api::v1::contexts::category) +//! `Proxy` | Image proxy cache | [`v1`](crate::web::api::v1::contexts::proxy) +//! `Settings` | Index settings | [`v1`](crate::web::api::v1::contexts::settings) +//! `Torrent` | Indexed torrents | [`v1`](crate::web::api::v1::contexts::torrent) +//! `User` | Users | [`v1`](crate::web::api::v1::contexts::user) +//! +pub mod about; +pub mod category; +pub mod proxy; +pub mod settings; +pub mod torrent; +pub mod user; diff --git a/src/web/api/v1/contexts/proxy/mod.rs b/src/web/api/v1/contexts/proxy/mod.rs new file mode 100644 index 00000000..29eb0879 --- /dev/null +++ b/src/web/api/v1/contexts/proxy/mod.rs @@ -0,0 +1,43 @@ +//! API context: `proxy`. +//! +//! This context contains the API routes for the proxy service. +//! +//! The torrent descriptions can contain images. These images are proxied +//! through the backend to: +//! +//! - Prevent leaking the user's IP address. +//! - Avoid storing images on the server. +//! +//! The proxy service is a simple cache that stores the images in memory. +//! +//! **NOTICE:** The proxy service is not intended to be used as a general +//! purpose proxy. It is only intended to be used for the images in the +//! torrent descriptions. +//! +//! **NOTICE:** Ununauthorized users can't see images. They will get an image +//! with the text "Sign in to see image" instead. +//! +//! # Example +//! +//! For unauthenticated clients: +//! +//! ```bash +//! curl \ +//! --header "cache-control: no-cache" \ +//! --header "pragma: no-cache" \ +//! --output mandelbrotset.jpg \ +//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2Fthumb%2F2%2F21%2FMandel_zoom_00_mandelbrot_set.jpg%2F1280px-Mandel_zoom_00_mandelbrot_set.jpg +//! ``` +//! +//! You will receive an image with the text "Sign in to see image" instead. +//! +//! For authenticated clients: +//! +//! ```bash +//! curl \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "cache-control: no-cache" \ +//! --header "pragma: no-cache" \ +//! --output mandelbrotset.jpg \ +//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2Fthumb%2F2%2F21%2FMandel_zoom_00_mandelbrot_set.jpg%2F1280px-Mandel_zoom_00_mandelbrot_set.jpg +//! ``` diff --git a/src/web/api/v1/contexts/settings/mod.rs b/src/web/api/v1/contexts/settings/mod.rs new file mode 100644 index 00000000..70cd94b2 --- /dev/null +++ b/src/web/api/v1/contexts/settings/mod.rs @@ -0,0 +1,169 @@ +//! API context: `settings`. +//! +//! This API context is responsible for handling the application settings. +//! +//! # Endpoints +//! +//! - [Get all settings](#get-all-settings) +//! - [Update all settings](#update-all-settings) +//! - [Get site name](#get-site-name) +//! - [Get public settings](#get-public-settings) +//! +//! # Get all settings +//! +//! `GET /v1/settings` +//! +//! Returns all settings. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request GET \ +//! "http://127.0.0.1:3000/v1/settings" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "website": { +//! "name": "Torrust" +//! }, +//! "tracker": { +//! "url": "udp://localhost:6969", +//! "mode": "Public", +//! "api_url": "http://localhost:1212", +//! "token": "MyAccessToken", +//! "token_valid_seconds": 7257600 +//! }, +//! "net": { +//! "port": 3000, +//! "base_url": null +//! }, +//! "auth": { +//! "email_on_signup": "Optional", +//! "min_password_length": 6, +//! "max_password_length": 64, +//! "secret_key": "MaxVerstappenWC2021" +//! }, +//! "database": { +//! "connect_url": "sqlite://./storage/database/data.db?mode=rwc" +//! }, +//! "mail": { +//! "email_verification_enabled": false, +//! "from": "example@email.com", +//! "reply_to": "noreply@email.com", +//! "username": "", +//! "password": "", +//! "server": "", +//! "port": 25 +//! }, +//! "image_cache": { +//! "max_request_timeout_ms": 1000, +//! "capacity": 128000000, +//! "entry_size_limit": 4000000, +//! "user_quota_period_seconds": 3600, +//! "user_quota_bytes": 64000000 +//! }, +//! "api": { +//! "default_torrent_page_size": 10, +//! "max_torrent_page_size": 30 +//! }, +//! "tracker_statistics_importer": { +//! "torrent_info_update_interval": 3600 +//! } +//! } +//! } +//! ``` +//! **Resource** +//! +//! Refer to the [`TorrustBackend`](crate::config::TorrustBackend) +//! struct for more information about the response attributes. +//! +//! # Update all settings +//! +//! **NOTICE**: This endpoint to update the settings does not work when you use +//! environment variables to configure the application. You need to use a +//! configuration file instead. Because settings are persisted in that file. +//! Refer to the issue [#144](https://github.com/torrust/torrust-index-backend/issues/144) +//! for more information. +//! +//! `POST /v1/settings` +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request POST \ +//! --data '{"website":{"name":"Torrust"},"tracker":{"url":"udp://localhost:6969","mode":"Public","api_url":"http://localhost:1212","token":"MyAccessToken","token_valid_seconds":7257600},"net":{"port":3000,"base_url":null},"auth":{"email_on_signup":"Optional","min_password_length":6,"max_password_length":64,"secret_key":"MaxVerstappenWC2021"},"database":{"connect_url":"sqlite://./storage/database/data.db?mode=rwc"},"mail":{"email_verification_enabled":false,"from":"example@email.com","reply_to":"noreply@email.com","username":"","password":"","server":"","port":25},"image_cache":{"max_request_timeout_ms":1000,"capacity":128000000,"entry_size_limit":4000000,"user_quota_period_seconds":3600,"user_quota_bytes":64000000},"api":{"default_torrent_page_size":10,"max_torrent_page_size":30},"tracker_statistics_importer":{"torrent_info_update_interval":3600}}' \ +//! "http://127.0.0.1:3000/v1/settings" +//! ``` +//! +//! The response contains the settings that were updated. +//! +//! **Resource** +//! +//! Refer to the [`TorrustBackend`](crate::config::TorrustBackend) +//! struct for more information about the response attributes. +//! +//! # Get site name +//! +//! `GET /v1/settings/name` +//! +//! It returns the name of the site. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request GET \ +//! "http://127.0.0.1:3000/v1/settings/name" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data":"Torrust" +//! } +//! ``` +//! +//! # Get public settings +//! +//! `GET /v1/settings/public` +//! +//! It returns all the public settings. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request GET \ +//! "http://127.0.0.1:3000/v1/settings/public" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "website_name": "Torrust", +//! "tracker_url": "udp://localhost:6969", +//! "tracker_mode": "Public", +//! "email_on_signup": "Optional" +//! } +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to the [`ConfigurationPublic`](crate::config::ConfigurationPublic) +//! struct for more information about the response attributes. diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs new file mode 100644 index 00000000..77d08b8a --- /dev/null +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -0,0 +1,330 @@ +//! API context: `torrent`. +//! +//! This API context is responsible for handling all torrent related requests. +//! +//! # Endpoints +//! +//! - [Upload new torrent](#upload-new-torrent) +//! - [Download a torrent](#download-a-torrent) +//! - [Get torrent info](#get-torrent-info) +//! - [List torrent infos](#list-torrent-infos) +//! - [Update torrent info](#update-torrent-info) +//! - [Delete a torrent](#delete-a-torrent) +//! +//! # Upload new torrent +//! +//! `POST /v1/torrent/upload` +//! +//! It uploads a new torrent to the index. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: multipart/form-data" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request POST \ +//! --form "title=MandelbrotSet" \ +//! --form "description=MandelbrotSet image" \ +//! --form "category=software" \ +//! --form "torrent=@docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent;type=application/x-bittorrent" \ +//! "http://127.0.0.1:3000/v1/torrent/upload" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "torrent_id": 2, +//! "info_hash": "5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! } +//! } +//! ``` +//! +//! **NOTICE**: Info-hashes will be lowercase hex-encoded strings in the future +//! and the [internal database ID could be removed from the response](https://github.com/torrust/torrust-index-backend/discussions/149). +//! +//! **Resource** +//! +//! Refer to the [`TorrustBackend`](crate::models::response::NewTorrentResponse) +//! struct for more information about the response attributes. +//! +//! # Download a torrent +//! +//! `GET /v1/torrent/download/{info_hash}` +//! +//! It downloads a new torrent file from the the index. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/x-bittorrent" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --output mandelbrot_2048x2048_infohash_v1.png.torrent \ +//! "http://127.0.0.1:3000/v1/torrent/download/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! ``` +//! +//! **Example response** `200` +//! +//! The response is a torrent file `mandelbrot_2048x2048_infohash_v1.png.torrent`. +//! +//! ```text +//! $ imdl torrent show mandelbrot_2048x2048_infohash_v1.png.torrent +//! Name mandelbrot_2048x2048.png +//! Info Hash 1a326de411f96bc15622c62358130f0824f561e1 +//! Torrent Size 492 bytes +//! Content Size 168.17 KiB +//! Private no +//! Tracker udp://localhost:6969/eklijkg8901K2Ol6O6CttT1xlUzO4bFD +//! Announce List Tier 1: udp://localhost:6969/eklijkg8901K2Ol6O6CttT1xlUzO4bFD +//! Tier 2: udp://localhost:6969 +//! Piece Size 16 KiB +//! Piece Count 11 +//! File Count 1 +//! Files mandelbrot_2048x2048.png +//! ``` +//! +//! # Get torrent info +//! +//! `GET /v1/torrents/{info_hash}` +//! +//! It returns the torrent info. +//! +//! **Path parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `info_hash` | `InfoHash` | The info-hash | Yes | `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request GET \ +//! "http://127.0.0.1:3000/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "torrent_id": 2, +//! "uploader": "indexadmin", +//! "info_hash": "5452869BE36F9F3350CCEE6B4544E7E76CAAADAB", +//! "title": "MandelbrotSet", +//! "description": "MandelbrotSet image", +//! "category": { +//! "category_id": 5, +//! "name": "software", +//! "num_torrents": 1 +//! }, +//! "upload_date": "2023-05-25 11:33:02", +//! "file_size": 172204, +//! "seeders": 0, +//! "leechers": 0, +//! "files": [ +//! { +//! "path": [ +//! "mandelbrot_2048x2048.png" +//! ], +//! "length": 172204, +//! "md5sum": null +//! } +//! ], +//! "trackers": [ +//! "udp://localhost:6969/eklijkg8901K2Ol6O6CttT1xlUzO4bFD", +//! "udp://localhost:6969" +//! ], +//! "magnet_link": "magnet:?xt=urn:btih:5452869BE36F9F3350CCEE6B4544E7E76CAAADAB&dn=MandelbrotSet&tr=udp%3A%2F%2Flocalhost%3A6969%2Feklijkg8901K2Ol6O6CttT1xlUzO4bFD&tr=udp%3A%2F%2Flocalhost%3A6969" +//! } +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to the [`TorrentResponse`](crate::models::response::TorrentResponse) +//! struct for more information about the response attributes. +//! +//! # List torrent infos +//! +//! `GET /v1/torrents` +//! +//! It returns the torrent info for multiple torrents +//! +//! **Get parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `search` | `Option` | A text to search | No | `MandelbrotSet` +//! `categories` | `Option` | A coma-separated category list | No | `music,other,movie,software` +//! +//! **Pagination GET parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `page_size` | `Option` | Number of torrents per page | No | `10` +//! `page` | `Option` | Page offset, starting at `0` | No | `music,other,movie,software` +//! +//! Pagination default values can be configured in the server configuration file. +//! +//! ```toml +//! [api] +//! default_torrent_page_size = 10 +//! max_torrent_page_size = 30 +//! ``` +//! +//! **Sorting GET parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `sort` | `Option` | [Sorting](crate::databases::database::Sorting) options | No | `size_DESC` +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request GET \ +//! "http://127.0.0.1:3000/v1/torrents" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "total": 1, +//! "results": [ +//! { +//! "torrent_id": 2, +//! "uploader": "indexadmin", +//! "info_hash": "5452869BE36F9F3350CCEE6B4544E7E76CAAADAB", +//! "title": "MandelbrotSet", +//! "description": "MandelbrotSet image", +//! "category_id": 5, +//! "date_uploaded": "2023-05-25 11:33:02", +//! "file_size": 172204, +//! "seeders": 0, +//! "leechers": 0 +//! } +//! ] +//! } +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to the [`TorrentsResponse`](crate::models::response::TorrentsResponse) +//! struct for more information about the response attributes. +//! +//! # Update torrent info +//! +//! `POST /v1/torrents/{info_hash}` +//! +//! It updates the torrent info. +//! +//! **Path parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `info_hash` | `InfoHash` | The info-hash | Yes | `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +//! +//! **Post parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `title` | `Option` | The torrent title | No | `MandelbrotSet` +//! `description` | `Option` | The torrent description | No | `MandelbrotSet image` +//! +//! +//! Refer to the [`Update`](crate::routes::torrent::Update) +//! struct for more information about the request attributes. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request PUT \ +//! --data '{"title":"MandelbrotSet", "description":"MandelbrotSet image"}' \ +//! "http://127.0.0.1:3000/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "torrent_id": 2, +//! "uploader": "indexadmin", +//! "info_hash": "5452869BE36F9F3350CCEE6B4544E7E76CAAADAB", +//! "title": "MandelbrotSet", +//! "description": "MandelbrotSet image", +//! "category": { +//! "category_id": 5, +//! "name": "software", +//! "num_torrents": 1 +//! }, +//! "upload_date": "2023-05-25 11:33:02", +//! "file_size": 172204, +//! "seeders": 0, +//! "leechers": 0, +//! "files": [], +//! "trackers": [], +//! "magnet_link": "" +//! } +//! } +//! ``` +//! +//! **NOTICE**: the response is not the same as the `GET /v1/torrents/{info_hash}`. +//! It does not contain the `files`, `trackers` and `magnet_link` attributes. +//! +//! **Resource** +//! +//! Refer to the [`TorrentResponse`](crate::models::response::TorrentResponse) +//! struct for more information about the response attributes. +//! +//! # Delete a torrent +//! +//! `DELETE /v1/torrents/{info_hash}` +//! +//! It deletes a torrent. +//! +//! **Path parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `info_hash` | `InfoHash` | The info-hash | Yes | `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request DELETE \ +//! "http://127.0.0.1:3000/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": { +//! "torrent_id": 2, +//! "info_hash": "5452869BE36F9F3350CCEE6B4544E7E76CAAADAB", +//! } +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to the [`DeletedTorrentResponse`](crate::models::response::DeletedTorrentResponse) +//! struct for more information about the response attributes. diff --git a/src/web/api/v1/contexts/user/mod.rs b/src/web/api/v1/contexts/user/mod.rs new file mode 100644 index 00000000..c7974a9c --- /dev/null +++ b/src/web/api/v1/contexts/user/mod.rs @@ -0,0 +1,245 @@ +//! API context: `user`. +//! +//! This API context is responsible for handling: +//! +//! - User registration +//! - User authentication +//! - User ban +//! +//! For more information about the API authentication, refer to the [`auth`](crate::web::api::v1::auth) +//! module. +//! +//! # Endpoints +//! +//! Registration: +//! +//! - [Registration](#registration) +//! - [Email verification](#email-verification) +//! +//! Authentication: +//! +//! - [Login](#login) +//! - [Token verification](#token-verification) +//! - [Token renewal](#token-renewal) +//! +//! User ban: +//! +//! - [Ban a user](#ban-a-user) +//! +//! # Registration +//! +//! `POST /v1/user/register` +//! +//! It registers a new user. +//! +//! **Post parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `username` | `String` | The username | Yes | `indexadmin` +//! `email` | `Option` | The user's email | No | `indexadmin@torrust.com` +//! `password` | `String` | The password | Yes | `BenoitMandelbrot1924` +//! `confirm_password` | `String` | Same password again | Yes | `BenoitMandelbrot1924` +//! +//! **NOTICE**: Email could be optional, depending on the configuration. +//! +//! ```toml +//! [auth] +//! email_on_signup = "Optional" +//! min_password_length = 6 +//! max_password_length = 64 +//! ``` +//! +//! Refer to the [`RegistrationForm`](crate::routes::user::RegistrationForm) +//! struct for more information about the registration form. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request POST \ +//! --data '{"username":"indexadmin","email":"indexadmin@torrust.com","password":"BenoitMandelbrot1924","confirm_password":"BenoitMandelbrot1924"}' \ +//! http://127.0.0.1:3000/v1/user/register +//! ``` +//! +//! For more information about the registration process, refer to the [`auth`](crate::web::api::v1::auth) +//! module. +//! +//! # Email verification +//! +//! `GET /v1/user/email/verify/{token}` +//! +//! If email on signup is enabled, the user will receive an email with a +//! verification link. The link will contain a token that can be used to verify +//! the email address. +//! +//! This endpoint will verify the email address and update the user's email +//! verification status. It also shows an text page with the result of the +//! verification. +//! +//! **Example response** `200` +//! +//! ```text +//! Email verified, you can close this page. +//! ``` +//! +//! # Login +//! +//! `POST /v1/user/login` +//! +//! It logs in a user. +//! +//! **Post parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `login` | `String` | The password | Yes | `indexadmin` +//! `password` | `String` | The password | Yes | `BenoitMandelbrot1924` +//! +//! Refer to the [`RegistrationForm`](crate::routes::user::Login) +//! struct for more information about the registration form. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request POST \ +//! --data '{"login":"indexadmin","password":"BenoitMandelbrot1924"}' \ +//! http://127.0.0.1:3000/v1/user/login +//! ``` +//! +//! For more information about the login process, refer to the [`auth`](crate::web::api::v1::auth) +//! module. +//! +//! # Token verification +//! +//! `POST /v1/user/token/verify` +//! +//! It logs in a user. +//! +//! **Post parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `token` | `String` | The token you want to verify | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` +//! +//! Refer to the [`Token`](crate::routes::user::Token) +//! struct for more information about the registration form. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request POST \ +//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ +//! http://127.0.0.1:3000/v1/user/token/verify +//! ``` +//! +//! **Example response** `200` +//! +//! For a valid token: +//! +//! ```json +//! { +//! "data":"Token is valid." +//! } +//! ``` +//! +//! And for an invalid token: +//! +//! ```json +//! { +//! "data":"Token invalid." +//! } +//! ``` +//! +//! # Token renewal +//! +//! `POST /v1/user/token/verify` +//! +//! It renew a user's token. +//! +//! The token must be valid and not expired. And it's only renewed if it is +//! valid for less than one week. +//! +//! **Post parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `token` | `String` | The current valid token | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` +//! +//! Refer to the [`Token`](crate::routes::user::Token) +//! struct for more information about the registration form. +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --request POST \ +//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ +//! http://127.0.0.1:3000/v1/user/token/renew +//! ``` +//! +//! **Example response** `200` +//! +//! If you try to renew a token that is still valid for more than one week: +//! +//! ```json +//! { +//! "data": { +//! "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI", +//! "username": "indexadmin", +//! "admin": true +//! } +//! } +//! ``` +//! +//! You will get the same token. If a new token is generated, the response will +//! be the same but with the new token. +//! +//! **WARNING**: The token is associated to the user's role. The application does not support +//! changing the role of a user. If you change the user's role manually in the +//! database, the token will still be valid but with the same role. That should +//! only be done for testing purposes. +//! +//! # Ban a user +//! +//! `DELETE /v1/user/ban/{user}` +//! +//! It add a user to the banned user list. +//! +//! Only admin can ban other users. +//! +//! **Path parameters** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `user` | `String` | username | Yes | `indexadmin` +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request DELETE \ +//! http://127.0.0.1:3000/v1/user/ban/indexadmin +//! ``` +//! +//! **Example response** `200` +//! +//! If you try to renew a token that is still valid for more than one week: +//! +//! ```json +//! { +//! "data": "Banned user: indexadmin" +//! } +//! ``` +//! +//! **WARNING**: The admin can ban themselves. If they do, they will not be able +//! to unban themselves. The only way to unban themselves is to manually remove +//! the user from the banned user list in the database. diff --git a/src/web/api/v1/mod.rs b/src/web/api/v1/mod.rs new file mode 100644 index 00000000..716030ab --- /dev/null +++ b/src/web/api/v1/mod.rs @@ -0,0 +1,8 @@ +//! The torrust Index Backend API version `v1`. +//! +//! The API is organized in contexts. +//! +//! Refer to the [`contexts`](crate::web::api::v1::contexts) module for more +//! information. +pub mod auth; +pub mod contexts; diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 00000000..f837326d --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,6 @@ +//! The Torrust Index Backend API. +//! +//! Currently, the API has only one version: `v1`. +//! +//! Refer to the [`v1`](crate::web::api::v1) module for more information. +pub mod api; From 4286ba92b0a85f945c8795f435c3728a4077e549 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Thu, 1 Jun 2023 10:07:35 +0200 Subject: [PATCH 190/357] feat: added filtering torrents on tags --- src/databases/database.rs | 6 ++++++ src/databases/mysql.rs | 36 +++++++++++++++++++++++++++++++++++- src/databases/sqlite.rs | 38 ++++++++++++++++++++++++++++++++++++-- src/errors.rs | 10 ++++++++++ src/services/torrent.rs | 7 +++++++ 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 66bf57e4..a829424f 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -60,6 +60,8 @@ pub enum Error { UserNotFound, CategoryAlreadyExists, CategoryNotFound, + TagAlreadyExists, + TagNotFound, TorrentNotFound, TorrentAlreadyExists, // when uploading an already uploaded info_hash TorrentTitleAlreadyExists, @@ -158,6 +160,7 @@ pub trait Database: Sync + Send { &self, search: &Option, categories: &Option>, + tags: &Option>, sort: &Sorting, offset: u64, page_size: u8, @@ -248,6 +251,9 @@ pub trait Database: Sync + Send { /// Remove all tags from torrent. async fn delete_all_torrent_tag_links(&self, torrent_id: i64) -> Result<(), Error>; + /// Get tag from name. + async fn get_tag_from_name(&self, name: &str) -> Result; + /// Get all tags as `Vec`. async fn get_tags(&self) -> Result, Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index f8b1386a..e68533e6 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -293,6 +293,7 @@ impl Database for Mysql { &self, search: &Option, categories: &Option>, + tags: &Option>, sort: &Sorting, offset: u64, limit: u8, @@ -338,11 +339,36 @@ impl Database for Mysql { String::new() }; + let tag_filter_query = if let Some(t) = tags { + let mut i = 0; + let mut tag_filters = String::new(); + for tag in t.iter() { + // don't take user input in the db query + if let Ok(sanitized_tag) = self.get_tag_from_name(tag).await { + let mut str = format!("tl.tag_id = '{}'", sanitized_tag.tag_id); + if i > 0 { + str = format!(" OR {str}"); + } + tag_filters.push_str(&str); + i += 1; + } + } + if tag_filters.is_empty() { + String::new() + } else { + format!("INNER JOIN torrust_torrent_tag_links tl ON tt.torrent_id = tl.torrent_id AND ({tag_filters}) ") + } + } else { + String::new() + }; + let mut query_string = format!( "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers - FROM torrust_torrents tt {category_filter_query} + FROM torrust_torrents tt + {category_filter_query} + {tag_filter_query} INNER JOIN torrust_user_profiles tp ON tt.uploader_id = tp.user_id INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id @@ -736,6 +762,14 @@ impl Database for Mysql { .map_err(|err| database::Error::ErrorWithText(err.to_string())) } + async fn get_tag_from_name(&self, name: &str) -> Result { + query_as::<_, TorrentTag>("SELECT tag_id, name FROM torrust_torrent_tags WHERE name = ?") + .bind(name) + .fetch_one(&self.pool) + .await + .map_err(|err| database::Error::TagNotFound) + } + async fn get_tags(&self) -> Result, database::Error> { query_as::<_, TorrentTag>( "SELECT tag_id, name FROM torrust_torrent_tags" diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index b63e61ee..d7ba9bb2 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -283,6 +283,7 @@ impl Database for Sqlite { &self, search: &Option, categories: &Option>, + tags: &Option>, sort: &Sorting, offset: u64, limit: u8, @@ -328,11 +329,36 @@ impl Database for Sqlite { String::new() }; + let tag_filter_query = if let Some(t) = tags { + let mut i = 0; + let mut tag_filters = String::new(); + for tag in t.iter() { + // don't take user input in the db query + if let Ok(sanitized_tag) = self.get_tag_from_name(tag).await { + let mut str = format!("tl.tag_id = '{}'", sanitized_tag.tag_id); + if i > 0 { + str = format!(" OR {str}"); + } + tag_filters.push_str(&str); + i += 1; + } + } + if tag_filters.is_empty() { + String::new() + } else { + format!("INNER JOIN torrust_torrent_tag_links tl ON tt.torrent_id = tl.torrent_id AND ({tag_filters}) ") + } + } else { + String::new() + }; + let mut query_string = format!( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers - FROM torrust_torrents tt {category_filter_query} + FROM torrust_torrents tt + {category_filter_query} + {tag_filter_query} INNER JOIN torrust_user_profiles tp ON tt.uploader_id = tp.user_id INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id @@ -726,6 +752,14 @@ impl Database for Sqlite { .map_err(|err| database::Error::ErrorWithText(err.to_string())) } + async fn get_tag_from_name(&self, name: &str) -> Result { + query_as::<_, TorrentTag>("SELECT tag_id, name FROM torrust_torrent_tags WHERE name = ?") + .bind(name) + .fetch_one(&self.pool) + .await + .map_err(|err| database::Error::TagNotFound) + } + async fn get_tags(&self) -> Result, database::Error> { query_as::<_, TorrentTag>( "SELECT tag_id, name FROM torrust_torrent_tags" diff --git a/src/errors.rs b/src/errors.rs index 528e7bc0..c625d37b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -102,6 +102,9 @@ pub enum ServiceError { #[display(fmt = "Selected category does not exist.")] InvalidCategory, + #[display(fmt = "Selected tag does not exist.")] + InvalidTag, + #[display(fmt = "Unauthorized action.")] Unauthorized, @@ -123,6 +126,9 @@ pub enum ServiceError { #[display(fmt = "Category already exists.")] CategoryExists, + #[display(fmt = "Tag already exists.")] + TagExists, + #[display(fmt = "Category not found.")] CategoryNotFound, @@ -165,11 +171,13 @@ impl ResponseError for ServiceError { ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, ServiceError::BadRequest => StatusCode::BAD_REQUEST, ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, + ServiceError::InvalidTag => StatusCode::BAD_REQUEST, ServiceError::Unauthorized => StatusCode::FORBIDDEN, ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::CategoryExists => StatusCode::BAD_REQUEST, + ServiceError::TagExists => StatusCode::BAD_REQUEST, ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::EmailMissing => StatusCode::NOT_FOUND, ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR, @@ -217,6 +225,8 @@ impl From for ServiceError { database::Error::UserNotFound => ServiceError::UserNotFound, database::Error::CategoryAlreadyExists => ServiceError::CategoryExists, database::Error::CategoryNotFound => ServiceError::InvalidCategory, + database::Error::TagAlreadyExists => ServiceError::TagExists, + database::Error::TagNotFound => ServiceError::InvalidTag, database::Error::TorrentNotFound => ServiceError::TorrentNotFound, database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists, diff --git a/src/services/torrent.rs b/src/services/torrent.rs index d6429a66..041b4386 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -38,6 +38,8 @@ pub struct ListingRequest { pub sort: Option, /// Expects comma separated string, eg: "?categories=movie,other,app" pub categories: Option, + /// Expects comma separated string, eg: "?tags=Linux,Ubuntu" + pub tags: Option, pub search: Option, } @@ -46,6 +48,7 @@ pub struct ListingRequest { pub struct ListingSpecification { pub search: Option, pub categories: Option>, + pub tags: Option>, pub sort: Sorting, pub offset: u64, pub page_size: u8, @@ -325,9 +328,12 @@ impl Index { let categories = request.categories.as_csv::().unwrap_or(None); + let tags = request.tags.as_csv::().unwrap_or(None); + ListingSpecification { search: request.search.clone(), categories, + tags, sort, offset, page_size, @@ -655,6 +661,7 @@ impl DbTorrentListingGenerator { .get_torrents_search_sorted_paginated( &specification.search, &specification.categories, + &specification.tags, &specification.sort, specification.offset, specification.page_size, From 9baedfbf467cc164a8749853ec1f3ebb50eda2bc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 29 May 2023 12:51:59 +0100 Subject: [PATCH 191/357] docs: [#166] installation and configuration --- project-words.txt | 1 + src/bin/import_tracker_statistics.rs | 3 +- src/config.rs | 92 ++++++- .../commands/import_tracker_statistics.rs | 5 +- src/lib.rs | 238 +++++++++++++++++- src/services/about.rs | 1 - src/services/authentication.rs | 1 + src/services/mod.rs | 1 + src/services/settings.rs | 1 + src/services/torrent.rs | 1 + src/services/user.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 1 - 12 files changed, 334 insertions(+), 13 deletions(-) diff --git a/project-words.txt b/project-words.txt index 5ca22cd6..b770bb51 100644 --- a/project-words.txt +++ b/project-words.txt @@ -30,6 +30,7 @@ leechers Leechers LEECHERS lettre +libsqlite luckythelab mailcatcher mandelbrotset diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index 3f8456c4..0b7f7288 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -1,7 +1,8 @@ //! Import Tracker Statistics command. +//! //! It imports the number of seeders and leechers for all torrent from the linked tracker. +//! //! You can execute it with: `cargo run --bin import_tracker_statistics` - use torrust_index_backend::console::commands::import_tracker_statistics::run_importer; #[actix_web::main] diff --git a/src/config.rs b/src/config.rs index becab7e1..8348edd3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +//! Configuration for the application. use std::path::Path; use std::{env, fs}; @@ -6,8 +7,10 @@ use log::warn; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; +/// Information displayed to the user in the website. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Website { + /// The name of the website. pub name: String, } @@ -19,12 +22,18 @@ impl Default for Website { } } +/// See `TrackerMode` in [`torrust-tracker-primitives`](https://docs.rs/torrust-tracker-primitives) +/// crate for more information. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TrackerMode { // todo: use https://crates.io/crates/torrust-tracker-primitives + /// Will track every new info hash and serve every peer. Public, + /// Will only serve authenticated peers. Private, + /// Will only track whitelisted info hashes. Whitelisted, + /// Will only track whitelisted info hashes and serve authenticated peers. PrivateWhitelisted, } @@ -34,12 +43,20 @@ impl Default for TrackerMode { } } +/// Configuration for the associated tracker. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tracker { + /// Connection string for the tracker. For example: `udp://TRACKER_IP:6969`. pub url: String, + /// The mode of the tracker. For example: `Public`. + /// See `TrackerMode` in [`torrust-tracker-primitives`](https://docs.rs/torrust-tracker-primitives) + /// crate for more information. pub mode: TrackerMode, + /// The url of the tracker API. For example: `http://localhost:1212`. pub api_url: String, + /// The token used to authenticate with the tracker API. pub token: String, + /// The amount of seconds the token is valid. pub token_valid_seconds: u64, } @@ -55,12 +72,18 @@ impl Default for Tracker { } } -/// Port 0 means that the OS will choose a random free port. +/// Port number representing that the OS will choose one randomly from the available ports. +/// +/// It's the port number `0` pub const FREE_PORT: u16 = 0; +/// The the base URL for the API. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Network { + /// The port to listen on. Default to `3000`. pub port: u16, + /// The base URL for the API. For example: `http://localhost`. + /// If not set, the base URL will be inferred from the request. pub base_url: Option, } @@ -73,11 +96,15 @@ impl Default for Network { } } +/// Whether the email is required on signup or not. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum EmailOnSignup { + /// The email is required on signup. Required, + /// The email is optional on signup. Optional, - None, + /// The email is not allowed on signup. It will only be ignored if provided. + None, // code-review: rename to `Ignored`? } impl Default for EmailOnSignup { @@ -86,11 +113,16 @@ impl Default for EmailOnSignup { } } +/// Authentication options. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Auth { + /// Whether or not to require an email on signup. pub email_on_signup: EmailOnSignup, + /// The minimum password length. pub min_password_length: usize, + /// The maximum password length. pub max_password_length: usize, + /// The secret key used to sign JWT tokens. pub secret_key: String, } @@ -105,8 +137,10 @@ impl Default for Auth { } } +/// Database configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Database { + /// The connection string for the database. For example: `sqlite://data.db?mode=rwc`. pub connect_url: String, } @@ -118,14 +152,22 @@ impl Default for Database { } } +/// SMTP configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Mail { + /// Whether or not to enable email verification on signup. pub email_verification_enabled: bool, + /// The email address to send emails from. pub from: String, + /// The email address to reply to. pub reply_to: String, + /// The username to use for SMTP authentication. pub username: String, + /// The password to use for SMTP authentication. pub password: String, + /// The SMTP server to use. pub server: String, + /// The SMTP port to use. pub port: u16, } @@ -143,19 +185,36 @@ impl Default for Mail { } } +/// Configuration for the image proxy cache. +/// +/// Users have a cache quota per period. For example: 100MB per day. +/// When users are navigating the site, they will be downloading images that are +/// embedded in the torrent description. These images will be cached in the +/// proxy. The proxy will not download new images if the user has reached the +/// quota. #[allow(clippy::module_name_repetitions)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageCache { + /// Maximum time in seconds to wait for downloading the image form the original source. pub max_request_timeout_ms: u64, + /// Cache size in bytes. pub capacity: usize, + /// Maximum size in bytes for a single image. pub entry_size_limit: usize, + /// Users have a cache quota per period. For example: 100MB per day. + /// This is the period in seconds (1 day in seconds). pub user_quota_period_seconds: u64, + /// Users have a cache quota per period. For example: 100MB per day. + /// This is the maximum size in bytes (100MB in bytes). pub user_quota_bytes: usize, } +/// Core configuration for the API #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Api { + /// The default page size for torrent lists. pub default_torrent_page_size: u8, + /// The maximum page size for torrent lists. pub max_torrent_page_size: u8, } @@ -168,8 +227,10 @@ impl Default for Api { } } +/// Configuration for the tracker statistics importer. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrackerStatisticsImporter { + /// The interval in seconds to get statistics from the tracker. pub torrent_info_update_interval: u64, } @@ -193,22 +254,36 @@ impl Default for ImageCache { } } +/// The whole configuration for the backend. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct TorrustBackend { + /// The website customizable values. pub website: Website, + /// The tracker configuration. pub tracker: Tracker, + /// The network configuration. pub net: Network, + /// The authentication configuration. pub auth: Auth, + /// The database configuration. pub database: Database, + /// The SMTP configuration. pub mail: Mail, + /// The image proxy cache configuration. pub image_cache: ImageCache, + /// The API configuration. pub api: Api, + /// The tracker statistics importer job configuration. pub tracker_statistics_importer: TrackerStatisticsImporter, } +/// The configuration service. #[derive(Debug)] pub struct Configuration { + /// The state of the configuration. pub settings: RwLock, + /// The path to the configuration file. This is `None` if the configuration + /// was loaded from the environment. pub config_path: Option, } @@ -237,13 +312,14 @@ impl Configuration { if Path::new(config_path).exists() { config = config_builder.add_source(File::with_name(config_path)).build()?; } else { - warn!("No config file found."); - warn!("Creating config file.."); + warn!("No config file found. Creating default config file ..."); + let config = Configuration::default(); let _ = config.save_to_file(config_path).await; - return Err(ConfigError::Message( - "Please edit the config.TOML in the root folder and restart the tracker.".to_string(), - )); + + return Err(ConfigError::Message(format!( + "No config file found. Created default config file in {config_path}. Edit the file and start the application." + ))); } let torrust_config: TorrustBackend = match config.try_deserialize() { @@ -345,6 +421,8 @@ impl Configuration { } } +/// The public backend configuration. +/// There is an endpoint to get this configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigurationPublic { website_name: String, diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index a322eef2..39cc5a9f 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -1,5 +1,8 @@ //! It imports statistics for all torrents from the linked tracker. - +//! +//! It imports the number of seeders and leechers for all torrent from the linked tracker. +//! +//! You can execute it with: `cargo run --bin import_tracker_statistics` use std::env; use std::sync::Arc; diff --git a/src/lib.rs b/src/lib.rs index c66d8e73..2f5a347d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,11 +2,247 @@ //! //! This is the backend API for [Torrust Tracker Index](https://github.com/torrust/torrust-index). //! -//! It is written in Rust and uses the actix-web framework. It is designed to be +//! It is written in Rust and uses the [actix-web](https://actix.rs/) framework. It is designed to be //! used with by the [Torrust Tracker Index Frontend](https://github.com/torrust/torrust-index-frontend). //! //! If you are looking for information on how to use the API, please see the //! [API v1](crate::web::api::v1) section of the documentation. +//! +//! # Table of contents +//! +//! - [Features](#features) +//! - [Services](#services) +//! - [Installation](#installation) +//! - [Minimum requirements](#minimum-requirements) +//! - [Prerequisites](#prerequisites) +//! - [Install from sources](#install-from-sources) +//! - [Run with docker](#run-with-docker) +//! - [Development](#development) +//! - [Configuration](#configuration) +//! - [Usage](#usage) +//! - [Contributing](#contributing) +//! - [Documentation](#documentation) +//! +//! # Features +//! +//! - Torrent categories +//! - Image proxy cache for torrent descriptions +//! - User registration and authentication +//! - DB Support for `SQLite` and `MySQl` +//! +//! # Services +//! +//! From the end-user perspective the Torrust Tracker exposes three different services. +//! +//! - A REST [API](crate::web::api::v1) +//! +//! From the administrator perspective, the Torrust Index Backend exposes: +//! +//! - A console command to update torrents statistics from the associated tracker +//! - A console command to upgrade the database schema from version `1.0.0` to `2.0.0` +//! +//! # Installation +//! +//! ## Minimum requirements +//! +//! - Rust Stable `1.68` +//! +//! ## Prerequisites +//! +//! In order the run the backend you will need a running torrust tracker. In the +//! configuration you need to fill the `backend` section with the following: +//! +//! ```toml +//! [tracker] +//! url = "udp://localhost:6969" +//! mode = "Public" +//! api_url = "http://localhost:1212" +//! token = "MyAccessToken" +//! token_valid_seconds = 7257600 +//! ``` +//! +//! Refer to the [`config::tracker`](crate::config::Tracker) documentation for more information. +//! +//! You can follow the tracker installation instructions [here](https://docs.rs/torrust-tracker) +//! or you can use the docker to run both the tracker and the backend. Refer to the +//! [Run with docker](#run-with-docker) section for more information. +//! +//! If you are using `SQLite3` as database driver, you will need to install the +//! following dependency: +//! +//! ```text +//! sudo apt-get install libsqlite3-dev +//! ``` +//! +//! > **NOTICE**: those are the commands for `Ubuntu`. If you are using a +//! different OS, you will need to install the equivalent packages. Please +//! refer to the documentation of your OS. +//! +//! With the default configuration you will need to create the `storage` directory: +//! +//! ```text +//! storage/ +//! └── database +//!    └── data.db +//! ``` +//! +//! The default configuration expects a directory `./storage/database` to be writable by the app process. +//! +//! By default the backend uses `SQLite` and the database file name `data.db`. +//! +//! ## Install from sources +//! +//! ```text +//! git clone git@github.com:torrust/torrust-index-backend.git \ +//! && cd torrust-index-backend \ +//! && cargo build --release \ +//! && mkdir -p ./storage/database +//! ``` +//! +//! Then you can run it with: `./target/release/main` +//! +//! ## Run with docker +//! +//! You can run the backend with a pre-built docker image: +//! +//! ```text +//! mkdir -p ./storage/database \ +//! && export TORRUST_IDX_BACK_USER_UID=1000 \ +//! && docker run -it \ +//! --user="$TORRUST_IDX_BACK_USER_UID" \ +//! --publish 3000:3000/tcp \ +//! --volume "$(pwd)/storage":"/app/storage" \ +//! torrust/index-backend +//! ``` +//! +//! For more information about using docker visit the [tracker docker documentation](https://github.com/torrust/torrust-index-backend/tree/develop/docker). +//! +//! ## Development +//! +//! We are using the [The Rust SQL Toolkit](https://github.com/launchbadge/sqlx) +//! [(sqlx)](https://github.com/launchbadge/sqlx) for database migrations. +//! +//! You can install it with: +//! +//! ```text +//! cargo install sqlx-cli +//! ``` +//! +//! To initialize the database run: +//! +//! ```text +//! echo "DATABASE_URL=sqlite://data.db?mode=rwc" > .env +//! sqlx db setup +//! ``` +//! +//! The `sqlx db setup` command will create the database specified in your +//! `DATABASE_URL` and run any pending migrations. +//! +//! > **WARNING**: The `.env` file is also used by docker-compose. +//! +//! > **NOTICE**: Refer to the [sqlx-cli](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli) +//! documentation for other commands to create new migrations or run them. +//! +//! > **NOTICE**: You can run the backend with [tmux](https://github.com/tmux/tmux/wiki) with `tmux new -s torrust-index-backend`. +//! +//! # Configuration +//! In order to run the backend you need to provide the configuration. If you run the backend without providing the configuration, +//! the tracker will generate the default configuration the first time you run it. It will generate a `config.toml` file with +//! in the root directory. +//! +//! The default configuration is: +//! +//! ```toml +//! [website] +//! name = "Torrust" +//! +//! [tracker] +//! url = "udp://localhost:6969" +//! mode = "Public" +//! api_url = "http://localhost:1212" +//! token = "MyAccessToken" +//! token_valid_seconds = 7257600 +//! +//! [net] +//! port = 3000 +//! +//! [auth] +//! email_on_signup = "Optional" +//! min_password_length = 6 +//! max_password_length = 64 +//! secret_key = "MaxVerstappenWC2021" +//! +//! [database] +//! connect_url = "sqlite://data.db?mode=rwc" +//! +//! [mail] +//! email_verification_enabled = false +//! from = "example@email.com" +//! reply_to = "noreply@email.com" +//! username = "" +//! password = "" +//! server = "" +//! port = 25 +//! +//! [image_cache] +//! max_request_timeout_ms = 1000 +//! capacity = 128000000 +//! entry_size_limit = 4000000 +//! user_quota_period_seconds = 3600 +//! user_quota_bytes = 64000000 +//! +//! [api] +//! default_torrent_page_size = 10 +//! max_torrent_page_size = 30 +//! +//! [tracker_statistics_importer] +//! torrent_info_update_interval = 3600 +//! ``` +//! +//! For more information about configuration you can visit the documentation for the [`config`](crate::config) module. +//! +//! Alternatively to the `config.toml` file you can use one environment variable `TORRUST_IDX_BACK_CONFIG` to pass the configuration to the tracker: +//! +//! ```text +//! TORRUST_IDX_BACK_CONFIG=$(cat config.toml) +//! cargo run +//! ``` +//! +//! In the previous example you are just setting the env var with the contents of the `config.toml` file. +//! +//! The env var contains the same data as the `config.toml`. It's particularly useful in you are [running the backend with docker](https://github.com/torrust/torrust-index-backend/tree/develop/docker). +//! +//! > **NOTICE**: The `TORRUST_IDX_BACK_CONFIG` env var has priority over the `config.toml` file. +//! +//! > **NOTICE**: You can also change the location for the configuration file with the `TORRUST_IDX_BACK_CONFIG_PATH` env var. +//! +//! # Usage +//! +//! Running the tracker with the default configuration will expose the REST API on port 3000: +//! +//! You can also run console commands: +//! +//! - [`Import tracker statistics`](crate::console::commands::import_tracker_statistics). +//! - [`Upgrade app from version 1.0.0 to 2.0.0`](crate::upgrades::from_v1_0_0_to_v2_0_0::upgrader). +//! +//! Refer to the documentation of each command for more information. +//! +//! # Contributing +//! +//! If you want to contribute to this documentation you can: +//! +//! - [Open a new discussion](https://github.com/torrust/torrust-index-backend/discussions) +//! - [Open a new issue](https://github.com/torrust/torrust-index-backend/issues). +//! - [Open a new pull request](https://github.com/torrust/torrust-index-backend/pulls). +//! +//! # Documentation +//! +//! You can find this documentation on [docs.rs](https://docs.rs/torrust-index-backend/). +//! +//! If you want to contribute to this documentation you can [open a new pull request](https://github.com/torrust/torrust-index-backend/pulls). +//! +//! In addition to the production code documentation you can find a lot of +//! examples in the [tests](https://github.com/torrust/torrust-index-backend/tree/develop/tests/e2e/contexts) directory. pub mod app; pub mod auth; pub mod bootstrap; diff --git a/src/services/about.rs b/src/services/about.rs index 53840421..b4e52a2a 100644 --- a/src/services/about.rs +++ b/src/services/about.rs @@ -1,5 +1,4 @@ //! Templates for "about" static pages. - use crate::routes::API_VERSION; #[must_use] diff --git a/src/services/authentication.rs b/src/services/authentication.rs index 5f792c87..c872e1e1 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -1,3 +1,4 @@ +//! Authentication services. use std::sync::Arc; use argon2::{Argon2, PasswordHash, PasswordVerifier}; diff --git a/src/services/mod.rs b/src/services/mod.rs index e298313e..79693c9c 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,4 @@ +//! App services. pub mod about; pub mod authentication; pub mod category; diff --git a/src/services/settings.rs b/src/services/settings.rs index f9aa0350..14ce5240 100644 --- a/src/services/settings.rs +++ b/src/services/settings.rs @@ -1,3 +1,4 @@ +//! Settings service. use std::sync::Arc; use super::user::DbUserRepository; diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 7c18d6d2..c89df12f 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -1,3 +1,4 @@ +//! Torrent service. use std::sync::Arc; use serde_derive::Deserialize; diff --git a/src/services/user.rs b/src/services/user.rs index fb5c82f6..10a42b60 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -1,4 +1,4 @@ -//! User repository. +//! User services. use std::sync::Arc; use argon2::password_hash::SaltString; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index d724ffb7..b866dfa6 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -10,7 +10,6 @@ //! //! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. //! Empty string is used as default value. - use std::env; use std::time::SystemTime; From 93d1b648a11bcfac299fe3b917c0452642edb41a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 5 Jun 2023 17:18:54 +0100 Subject: [PATCH 192/357] docs: [#168] statistics importer console command --- .../commands/import_tracker_statistics.rs | 22 +++++++++++++++++-- src/lib.rs | 21 +++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 39cc5a9f..de6825af 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -1,8 +1,26 @@ //! It imports statistics for all torrents from the linked tracker. //! -//! It imports the number of seeders and leechers for all torrent from the linked tracker. +//! It imports the number of seeders and leechers for all torrents from the +//! associated tracker. //! -//! You can execute it with: `cargo run --bin import_tracker_statistics` +//! You can execute it with: `cargo run --bin import_tracker_statistics`. +//! +//! After running it you will see the following output: +//! +//! ```text +//! Importing statistics from linked tracker ... +//! Loading configuration from config file `./config.toml` +//! Tracker url: udp://localhost:6969 +//! ``` +//! +//! Statistics are also imported: +//! +//! - Periodically by the importer job. The importer job is executed every hour +//! by default. See [`TrackerStatisticsImporter`](crate::config::TrackerStatisticsImporter) +//! for more details. +//! - When a new torrent is added. +//! - When the API returns data about a torrent statistics are collected from +//! the tracker in real time. use std::env; use std::sync::Arc; diff --git a/src/lib.rs b/src/lib.rs index 2f5a347d..a547a6b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,9 @@ //! - [Development](#development) //! - [Configuration](#configuration) //! - [Usage](#usage) +//! - [API](#api) +//! - [Tracker Statistics Importer](#tracker-statistics-importer) +//! - [Upgrader](#upgrader) //! - [Contributing](#contributing) //! - [Documentation](#documentation) //! @@ -218,12 +221,24 @@ //! //! # Usage //! +//! ## API +//! //! Running the tracker with the default configuration will expose the REST API on port 3000: //! -//! You can also run console commands: +//! ## Tracker Statistics Importer +//! +//! This console command allows you to manually import the tracker statistics. +//! +//! For more information about this command you can visit the documentation for +//! the [`Import tracker statistics`](crate::console::commands::import_tracker_statistics) module. +//! +//! ## Upgrader +//! +//! This console command allows you to manually upgrade the application from one +//! version to another. //! -//! - [`Import tracker statistics`](crate::console::commands::import_tracker_statistics). -//! - [`Upgrade app from version 1.0.0 to 2.0.0`](crate::upgrades::from_v1_0_0_to_v2_0_0::upgrader). +//! For more information about this command you can visit the documentation for +//! the [`Upgrade app from version 1.0.0 to 2.0.0`](crate::upgrades::from_v1_0_0_to_v2_0_0::upgrader) module. //! //! Refer to the documentation of each command for more information. //! From 548558904930d56ec0238b5978e26b690ec55b50 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 5 Jun 2023 17:36:04 +0100 Subject: [PATCH 193/357] refactor: remove unneeded code --- src/console/commands/import_tracker_statistics.rs | 14 ++++++-------- src/tracker/statistics_importer.rs | 3 ++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index de6825af..2040ca95 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -35,9 +35,6 @@ use crate::tracker::statistics_importer::StatisticsImporter; const NUMBER_OF_ARGUMENTS: usize = 0; -#[derive(Debug)] -pub struct Arguments {} - #[derive(Debug, Display, PartialEq, Error)] #[allow(dead_code)] pub enum ImportError { @@ -45,7 +42,7 @@ pub enum ImportError { WrongNumberOfArgumentsError, } -fn parse_args() -> Result { +fn parse_args() -> Result<(), ImportError> { let args: Vec = env::args().skip(1).collect(); if args.len() != NUMBER_OF_ARGUMENTS { @@ -59,7 +56,7 @@ fn parse_args() -> Result { return Err(ImportError::WrongNumberOfArgumentsError); } - Ok(Arguments {}) + Ok(()) } fn print_usage() { @@ -74,7 +71,8 @@ fn print_usage() { } pub async fn run_importer() { - import(&parse_args().expect("unable to parse command arguments")).await; + parse_args().expect("unable to parse command arguments"); + import().await; } /// Import Command Arguments @@ -82,7 +80,7 @@ pub async fn run_importer() { /// # Panics /// /// Panics if `Configuration::load_from_file` has any error. -pub async fn import(_args: &Arguments) { +pub async fn import() { println!("Importing statistics from linked tracker ..."); let configuration = init_configuration().await; @@ -110,5 +108,5 @@ pub async fn import(_args: &Arguments) { tracker_statistics_importer .import_all_torrents_statistics() .await - .expect("variable `tracker_service` is unable to `update_torrents`"); + .expect("should import all torrents statistics"); } diff --git a/src/tracker/statistics_importer.rs b/src/tracker/statistics_importer.rs index c5044c5c..f2ae0dce 100644 --- a/src/tracker/statistics_importer.rs +++ b/src/tracker/statistics_importer.rs @@ -50,10 +50,11 @@ impl StatisticsImporter { // ``` if let Some(err) = ret.err() { - error!( + let message = format!( "Error updating torrent tracker stats for torrent with id {}: {:?}", torrent.torrent_id, err ); + error!("{}", message); } } From d7f51faebe0c5e1caefe78ba7700bc76210cb1d2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 6 Jun 2023 10:26:15 +0100 Subject: [PATCH 194/357] docs: [#169] app upgrader documentation Documentation for the command to upgrade the application. --- src/lib.rs | 2 - .../from_v1_0_0_to_v2_0_0/upgrader.rs | 59 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a547a6b1..adb5e5f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -240,8 +240,6 @@ //! For more information about this command you can visit the documentation for //! the [`Upgrade app from version 1.0.0 to 2.0.0`](crate::upgrades::from_v1_0_0_to_v2_0_0::upgrader) module. //! -//! Refer to the documentation of each command for more information. -//! //! # Contributing //! //! If you want to contribute to this documentation you can: diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index b866dfa6..0c0f7a9d 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -1,15 +1,51 @@ -//! It updates the application from version v1.0.0 to v2.0.0. +//! A console command to upgrade the application from version `v1.0.0` to `v2.0.0`. +//! +//! # Usage +//! +//! ```bash +//! cargo run --bin upgrade SOURCE_DB_FILE TARGET_DB_FILE TORRENT_UPLOAD_DIR +//! ``` +//! +//! Where: +//! +//! - `SOURCE_DB_FILE` is the source database in version `v1.0.0` we want to migrate. +//! - `TARGET_DB_FILE` is the new migrated database in version `v2.0.0`. +//! - `TORRENT_UPLOAD_DIR` is the relative dir where torrent files are stored. +//! +//! For example: +//! +//! ```bash +//! cargo run --bin upgrade ./data.db ./data_v2.db ./uploads +//! ``` +//! +//! This command was created to help users to migrate from version `v1.0.0` to +//! `v2.0.0`. The main changes in version `v2.0.0` were: +//! +//! - The database schema was changed. +//! - The torrents are now stored entirely in the database. The torrent files +//! are not stored in the filesystem anymore. This command reads the torrent +//! files from the filesystem and store them in the database. +//! +//! We recommend to download your production database and the torrent files dir. +//! And run the command in a local environment with the version `v2.0.0.`. Then, +//! you can run the app locally and make sure all the data was migrated +//! correctly. +//! +//! # Notes //! //! NOTES for `torrust_users` table transfer: //! -//! - In v2, the table `torrust_user` contains a field `date_registered` non existing in v1. -//! We changed that columns to allow NULL. We also added the new column `date_imported` with -//! the datetime when the upgrader was executed. +//! - In v2, the table `torrust_user` contains a field `date_registered` non +//! existing in v1. We changed that column to allow `NULL`. We also added the +//! new column `date_imported` with the datetime when the upgrader was executed. //! //! NOTES for `torrust_user_profiles` table transfer: //! -//! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` and `avatar`. -//! Empty string is used as default value. +//! - In v2, the table `torrust_user_profiles` contains two new fields: `bio` +//! and `avatar`. Empty string is used as default value. +//! +//! +//! If you want more information about this command you can read the [issue 56](https://github.com/torrust/torrust-index-backend/issues/56). use std::env; use std::time::SystemTime; @@ -26,9 +62,12 @@ const NUMBER_OF_ARGUMENTS: usize = 3; #[derive(Debug)] pub struct Arguments { - pub source_database_file: String, // The source database in version v1.0.0 we want to migrate - pub target_database_file: String, // The new migrated database in version v2.0.0 - pub upload_path: String, // The relative dir where torrent files are stored + /// The source database in version v1.0.0 we want to migrate + pub source_database_file: String, + /// The new migrated database in version v2.0.0 + pub target_database_file: String, + // The relative dir where torrent files are stored + pub upload_path: String, } fn print_usage() { @@ -100,7 +139,7 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) { } /// Current datetime in ISO8601 without time zone. -/// For example: 2022-11-10 10:35:15 +/// For example: `2022-11-10 10:35:15` #[must_use] pub fn datetime_iso_8601() -> String { let dt: DateTime = SystemTime::now().into(); From 7347fee778fe39945b39d0d6dad8ee5d02237471 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 6 Jun 2023 15:36:15 +0100 Subject: [PATCH 195/357] feat(api): [#174] new cargo dependencies: axum, hyper We decided to migrate from Actix Web to Axum. The main reason was we are also using Axum on the Tracker and it will be easier for us to mantain only one framework. And apparently there are no drawbacks. This commit adds the new dependencies. --- Cargo.lock | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 ++ 2 files changed, 102 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index c04ca2b4..e0bfde80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,6 +364,55 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "axum" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8175979259124331c1d7bf6586ee7e0da434155e4b2d48ec2c8386281d8df39" +dependencies = [ + "async-trait", + "axum-core", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.13.1" @@ -1546,6 +1595,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" + [[package]] name = "memchr" version = "2.5.0" @@ -2289,6 +2344,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + [[package]] name = "rustybuzz" version = "0.4.0" @@ -2468,6 +2529,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" +dependencies = [ + "serde", +] + [[package]] name = "serde_plain" version = "1.0.1" @@ -2778,6 +2848,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "tempfile" version = "3.5.0" @@ -3030,6 +3106,7 @@ dependencies = [ "actix-web", "argon2", "async-trait", + "axum", "binascii", "bytes", "chrono", @@ -3037,6 +3114,7 @@ dependencies = [ "derive_more", "fern", "futures", + "hyper", "indexmap", "jsonwebtoken", "lettre", @@ -3065,6 +3143,28 @@ dependencies = [ "which", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + [[package]] name = "tower-service" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index ade5ac7b..f18e02bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ text-to-png = "0.2" indexmap = "1.9" thiserror = "1.0" binascii = "0.1" +axum = "0.6.18" +hyper = "0.14.26" [dev-dependencies] rand = "0.8" From 7bcf20ebfe195bea08cc384d57de431eed9dd297 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 6 Jun 2023 17:19:44 +0100 Subject: [PATCH 196/357] feat: disable sqlx logging for all statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the loggin level is set to INFO sqlx logs all SQL statements like this: ``` 2023-06-06T17:19:52.639651375+01:00 [sqlx::query][INFO] SELECT version FROM _sqlx_migrations …; rows affected: 0, rows returned: 0, elapsed: 58.361µs SELECT version FROM _sqlx_migrations WHERE success = false ORDER BY version LIMIT 1 ``` This commits makes sqlc to log only failed statements as errors and slow statements (>1 second) as warnings. --- src/bootstrap/logging.rs | 2 +- src/databases/mysql.rs | 14 +++++++++++--- src/databases/sqlite.rs | 14 +++++++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs index a1441827..303c5775 100644 --- a/src/bootstrap/logging.rs +++ b/src/bootstrap/logging.rs @@ -29,7 +29,7 @@ pub fn setup() { fn config_level_or_default(log_level: &Option) -> LevelFilter { match log_level { - None => log::LevelFilter::Warn, + None => log::LevelFilter::Info, Some(level) => LevelFilter::from_str(level).unwrap(), } } diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 5e3206db..2af56f3e 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -1,7 +1,10 @@ +use std::str::FromStr; +use std::time::Duration; + use async_trait::async_trait; use chrono::NaiveDateTime; -use sqlx::mysql::MySqlPoolOptions; -use sqlx::{query, query_as, Acquire, MySqlPool}; +use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions}; +use sqlx::{query, query_as, Acquire, ConnectOptions, MySqlPool}; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; @@ -25,8 +28,13 @@ impl Database for Mysql { } async fn new(database_url: &str) -> Self { + let mut connection_options = MySqlConnectOptions::from_str(database_url).expect("Unable to create connection options."); + connection_options + .log_statements(log::LevelFilter::Error) + .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(1)); + let db = MySqlPoolOptions::new() - .connect(database_url) + .connect_with(connection_options) .await .expect("Unable to create database pool."); diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 31bec6a2..75a10cd2 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -1,7 +1,10 @@ +use std::str::FromStr; +use std::time::Duration; + use async_trait::async_trait; use chrono::NaiveDateTime; -use sqlx::sqlite::SqlitePoolOptions; -use sqlx::{query, query_as, Acquire, SqlitePool}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{query, query_as, Acquire, ConnectOptions, SqlitePool}; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; @@ -25,8 +28,13 @@ impl Database for Sqlite { } async fn new(database_url: &str) -> Self { + let mut connection_options = SqliteConnectOptions::from_str(database_url).expect("Unable to create connection options."); + connection_options + .log_statements(log::LevelFilter::Error) + .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(1)); + let db = SqlitePoolOptions::new() - .connect(database_url) + .connect_with(connection_options) .await .expect("Unable to create database pool."); From a6cf184759ddcea71a10641dff9b203fe3dadee7 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Thu, 8 Jun 2023 09:06:58 +0200 Subject: [PATCH 197/357] chore: cargo fmt --- src/app.rs | 5 ++++- src/common.rs | 5 ++++- src/databases/mysql.rs | 21 +++++++++++---------- src/databases/sqlite.rs | 21 +++++++++++---------- src/models/mod.rs | 2 +- src/routes/mod.rs | 2 +- src/routes/tag.rs | 23 +++++------------------ src/routes/torrent.rs | 4 ++-- src/services/torrent.rs | 10 +++++++--- 9 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/app.rs b/src/app.rs index 005616aa..1e6b421e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,10 @@ use crate::config::Configuration; use crate::databases::database; use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; -use crate::services::torrent::{DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository}; +use crate::services::torrent::{ + DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, + DbTorrentRepository, DbTorrentTagRepository, +}; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; diff --git a/src/common.rs b/src/common.rs index 9c13a40b..db0361e3 100644 --- a/src/common.rs +++ b/src/common.rs @@ -6,7 +6,10 @@ use crate::config::Configuration; use crate::databases::database::Database; use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; -use crate::services::torrent::{DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository}; +use crate::services::torrent::{ + DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, + DbTorrentRepository, DbTorrentTagRepository, +}; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index e68533e6..925f502a 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -725,7 +725,9 @@ impl Database for Mysql { } async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), database::Error> { - let mut transaction = self.pool.begin() + let mut transaction = self + .pool + .begin() .await .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; @@ -738,7 +740,8 @@ impl Database for Mysql { .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; } - transaction.commit() + transaction + .commit() .await .map_err(|err| database::Error::ErrorWithText(err.to_string())) } @@ -771,9 +774,7 @@ impl Database for Mysql { } async fn get_tags(&self) -> Result, database::Error> { - query_as::<_, TorrentTag>( - "SELECT tag_id, name FROM torrust_torrent_tags" - ) + query_as::<_, TorrentTag>("SELECT tag_id, name FROM torrust_torrent_tags") .fetch_all(&self.pool) .await .map_err(|_| database::Error::Error) @@ -784,12 +785,12 @@ impl Database for Mysql { "SELECT torrust_torrent_tags.tag_id, torrust_torrent_tags.name FROM torrust_torrent_tags JOIN torrust_torrent_tag_links ON torrust_torrent_tags.tag_id = torrust_torrent_tag_links.tag_id - WHERE torrust_torrent_tag_links.torrent_id = ?" + WHERE torrust_torrent_tag_links.torrent_id = ?", ) - .bind(torrent_id) - .fetch_all(&self.pool) - .await - .map_err(|_| database::Error::Error) + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) } async fn update_tracker_info( diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index d7ba9bb2..327ea0cd 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -715,7 +715,9 @@ impl Database for Sqlite { } async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), database::Error> { - let mut transaction = self.pool.begin() + let mut transaction = self + .pool + .begin() .await .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; @@ -728,7 +730,8 @@ impl Database for Sqlite { .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; } - transaction.commit() + transaction + .commit() .await .map_err(|err| database::Error::ErrorWithText(err.to_string())) } @@ -761,9 +764,7 @@ impl Database for Sqlite { } async fn get_tags(&self) -> Result, database::Error> { - query_as::<_, TorrentTag>( - "SELECT tag_id, name FROM torrust_torrent_tags" - ) + query_as::<_, TorrentTag>("SELECT tag_id, name FROM torrust_torrent_tags") .fetch_all(&self.pool) .await .map_err(|_| database::Error::Error) @@ -774,12 +775,12 @@ impl Database for Sqlite { "SELECT torrust_torrent_tags.tag_id, torrust_torrent_tags.name FROM torrust_torrent_tags JOIN torrust_torrent_tag_links ON torrust_torrent_tags.tag_id = torrust_torrent_tag_links.tag_id - WHERE torrust_torrent_tag_links.torrent_id = ?" + WHERE torrust_torrent_tag_links.torrent_id = ?", ) - .bind(torrent_id) - .fetch_all(&self.pool) - .await - .map_err(|_| database::Error::Error) + .bind(torrent_id) + .fetch_all(&self.pool) + .await + .map_err(|_| database::Error::Error) } async fn update_tracker_info( diff --git a/src/models/mod.rs b/src/models/mod.rs index 5fff6a4a..754bfe80 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -3,6 +3,6 @@ pub mod info_hash; pub mod response; pub mod torrent; pub mod torrent_file; +pub mod torrent_tag; pub mod tracker_key; pub mod user; -pub mod torrent_tag; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index fc76c52a..bbb439a4 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -5,9 +5,9 @@ pub mod category; pub mod proxy; pub mod root; pub mod settings; +pub mod tag; pub mod torrent; pub mod user; -pub mod tag; pub const API_VERSION: &str = "v1"; diff --git a/src/routes/tag.rs b/src/routes/tag.rs index 7fc9a4f6..7982b077 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -9,20 +9,13 @@ use crate::routes::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope(&format!("/{API_VERSION}/tag")) - .service( + web::scope(&format!("/{API_VERSION}/tag")).service( web::resource("") .route(web::post().to(add_tag)) .route(web::delete().to(delete_tag)), - ) - ); - cfg.service( - web::scope(&format!("/{API_VERSION}/tags")) - .service( - web::resource("") - .route(web::get().to(get_tags)) - ) + ), ); + cfg.service(web::scope(&format!("/{API_VERSION}/tags")).service(web::resource("").route(web::get().to(get_tags)))); } pub async fn get_tags(app_data: WebAppData) -> ServiceResult { @@ -58,11 +51,7 @@ pub struct DeleteTag { pub tag_id: TagId, } -pub async fn delete_tag( - req: HttpRequest, - payload: web::Json, - app_data: WebAppData, -) -> ServiceResult { +pub async fn delete_tag(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { let user_id = app_data.auth.get_user_id_from_request(&req).await?; let user = app_data.user_repository.get_compact(&user_id).await?; @@ -74,7 +63,5 @@ pub async fn delete_tag( app_data.torrent_tag_repository.delete_tag(&payload.tag_id).await?; - Ok(HttpResponse::Ok().json(OkResponse { - data: payload.tag_id, - })) + Ok(HttpResponse::Ok().json(OkResponse { data: payload.tag_id })) } diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 4b857f4b..86ddf2ba 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -45,7 +45,7 @@ pub struct Create { pub title: String, pub description: String, pub category: String, - pub tags: Vec + pub tags: Vec, } impl Create { @@ -236,7 +236,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result, @@ -124,7 +124,9 @@ impl Index { return Err(e); } - self.torrent_tag_repository.link_torrent_to_tags(&torrent_id, &torrent_request.fields.tags).await?; + self.torrent_tag_repository + .link_torrent_to_tags(&torrent_id, &torrent_request.fields.tags) + .await?; Ok(torrent_id) } @@ -476,7 +478,9 @@ impl DbTorrentInfoRepository { } if let Some(tags) = opt_tags { - let mut current_tags: Vec = self.database.get_tags_for_torrent_id(*torrent_id) + let mut current_tags: Vec = self + .database + .get_tags_for_torrent_id(*torrent_id) .await? .iter() .map(|tag| tag.tag_id) From 4730afddf1b0384ac05b12026d4ff160607a639f Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Thu, 8 Jun 2023 11:46:02 +0200 Subject: [PATCH 198/357] chore: clippy errors --- src/routes/tag.rs | 39 +++++++++++++++++++++++++++------- src/services/authentication.rs | 2 +- src/services/torrent.rs | 4 ++-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/routes/tag.rs b/src/routes/tag.rs index 7982b077..f317d5ca 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -11,25 +11,39 @@ pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( web::scope(&format!("/{API_VERSION}/tag")).service( web::resource("") - .route(web::post().to(add_tag)) - .route(web::delete().to(delete_tag)), + .route(web::post().to(create)) + .route(web::delete().to(delete)), ), ); - cfg.service(web::scope(&format!("/{API_VERSION}/tags")).service(web::resource("").route(web::get().to(get_tags)))); + cfg.service(web::scope(&format!("/{API_VERSION}/tags")).service(web::resource("").route(web::get().to(get_all)))); } -pub async fn get_tags(app_data: WebAppData) -> ServiceResult { +/// Get Tags +/// +/// # Errors +/// +/// This function will return an error if unable to get tags from database. +pub async fn get_all(app_data: WebAppData) -> ServiceResult { let tags = app_data.torrent_tag_repository.get_tags().await?; Ok(HttpResponse::Ok().json(OkResponse { data: tags })) } #[derive(Debug, Serialize, Deserialize)] -pub struct AddTag { +pub struct Create { pub name: String, } -pub async fn add_tag(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { +/// Create Tag +/// +/// # Errors +/// +/// This function will return an error if unable to: +/// +/// * Get the requesting user id from the request. +/// * Get the compact user from the user id. +/// * Add the new tag to the database. +pub async fn create(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { let user_id = app_data.auth.get_user_id_from_request(&req).await?; let user = app_data.user_repository.get_compact(&user_id).await?; @@ -47,11 +61,20 @@ pub async fn add_tag(req: HttpRequest, payload: web::Json, app_data: Web } #[derive(Debug, Serialize, Deserialize)] -pub struct DeleteTag { +pub struct Delete { pub tag_id: TagId, } -pub async fn delete_tag(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { +/// Delete Tag +/// +/// # Errors +/// +/// This function will return an error if unable to: +/// +/// * Get the requesting user id from the request. +/// * Get the compact user from the user id. +/// * Delete the tag from the database. +pub async fn delete(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { let user_id = app_data.auth.get_user_id_from_request(&req).await?; let user = app_data.user_repository.get_compact(&user_id).await?; diff --git a/src/services/authentication.rs b/src/services/authentication.rs index 5f792c87..dc5bf8ea 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -102,7 +102,7 @@ impl Service { // Renew token if it is valid for less than one week let token = match claims.exp - clock::now() { x if x < ONE_WEEK_IN_SECONDS => self.json_web_token.sign(user_compact.clone()).await, - _ => token.clone().to_owned(), + _ => token.to_string(), }; Ok((token, user_compact)) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 5a692696..5a220449 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -488,8 +488,8 @@ impl DbTorrentInfoRepository { let mut new_tags = tags.clone(); - current_tags.sort(); - new_tags.sort(); + current_tags.sort_unstable(); + new_tags.sort_unstable(); if new_tags != current_tags { self.database.delete_all_torrent_tag_links(*torrent_id).await?; From d08f70eccf5b6fde7139c2413d293a1da98b49e0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 6 Jun 2023 17:28:09 +0100 Subject: [PATCH 199/357] refactor(api): [#174] Axum API scaffolding Basic changes needed to run the Axum API implementation in parallel with the current one with ActixWeb. For the time being, we will be only useinf the Auxm implementation for testing until all endpoints are migrated. --- src/app.rs | 45 ++++---------- src/bin/main.rs | 16 ++++- src/services/about.rs | 5 ++ src/web/api/actix.rs | 75 ++++++++++++++++++++++ src/web/api/axum.rs | 76 +++++++++++++++++++++++ src/web/api/mod.rs | 47 ++++++++++++++ src/web/api/v1/contexts/about/handlers.rs | 20 ++++++ src/web/api/v1/contexts/about/mod.rs | 2 + src/web/api/v1/contexts/about/routes.rs | 15 +++++ src/web/api/v1/mod.rs | 1 + src/web/api/v1/routes.rs | 22 +++++++ tests/e2e/contexts/about/contract.rs | 26 +++++++- tests/e2e/contexts/category/contract.rs | 20 +++--- tests/e2e/contexts/root/contract.rs | 4 +- tests/e2e/contexts/settings/contract.rs | 10 +-- tests/e2e/contexts/torrent/contract.rs | 44 +++++++------ tests/e2e/contexts/user/contract.rs | 18 +++--- tests/e2e/environment.rs | 6 +- tests/environments/app_starter.rs | 31 +++++---- tests/environments/isolated.rs | 9 +-- 20 files changed, 398 insertions(+), 94 deletions(-) create mode 100644 src/web/api/actix.rs create mode 100644 src/web/api/axum.rs create mode 100644 src/web/api/v1/contexts/about/handlers.rs create mode 100644 src/web/api/v1/contexts/about/routes.rs create mode 100644 src/web/api/v1/routes.rs diff --git a/src/app.rs b/src/app.rs index 3aa8e29e..a2bceb2f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,7 @@ use std::net::SocketAddr; use std::sync::Arc; -use actix_cors::Cors; -use actix_web::dev::Server; -use actix_web::{middleware, web, App, HttpServer}; -use log::info; +use tokio::task::JoinHandle; use crate::auth::Authentication; use crate::bootstrap::logging; @@ -21,16 +18,18 @@ use crate::services::torrent::{ use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; -use crate::{mailer, routes, tracker}; +use crate::web::api::{start, Implementation}; +use crate::{mailer, tracker}; pub struct Running { - pub api_server: Server, - pub socket_address: SocketAddr, + pub api_socket_addr: SocketAddr, + pub actix_web_api_server: Option>>, + pub axum_api_server: Option>>, pub tracker_data_importer_handle: tokio::task::JoinHandle<()>, } #[allow(clippy::too_many_lines)] -pub async fn run(configuration: Configuration) -> Running { +pub async fn run(configuration: Configuration, api_implementation: &Implementation) -> Running { logging::setup(); let configuration = Arc::new(configuration); @@ -42,6 +41,7 @@ pub async fn run(configuration: Configuration) -> Running { let database_connect_url = settings.database.connect_url.clone(); let torrent_info_update_interval = settings.tracker_statistics_importer.torrent_info_update_interval; + let net_ip = "0.0.0.0".to_string(); let net_port = settings.net.port; // IMPORTANT: drop settings before starting server to avoid read locks that @@ -155,33 +155,14 @@ pub async fn run(configuration: Configuration) -> Running { } }); - // Start main API server + // Start API server - // todo: get IP from settings - let ip = "0.0.0.0".to_string(); - - let server = HttpServer::new(move || { - App::new() - .wrap(Cors::permissive()) - .app_data(web::Data::new(app_data.clone())) - .wrap(middleware::Logger::default()) - .configure(routes::init) - }) - .bind((ip, net_port)) - .expect("can't bind server to socket address"); - - let socket_address = server.addrs()[0]; - - let running_server = server.run(); - - let starting_message = format!("Listening on http://{socket_address}"); - info!("{}", starting_message); - // Logging could be disabled or redirected to file. So print to stdout too. - println!("{starting_message}"); + let running_api = start(app_data, &net_ip, net_port, api_implementation).await; Running { - api_server: running_server, - socket_address, + api_socket_addr: running_api.socket_addr, + actix_web_api_server: running_api.actix_web_api_server, + axum_api_server: running_api.axum_api_server, tracker_data_importer_handle: tracker_statistics_importer_handle, } } diff --git a/src/bin/main.rs b/src/bin/main.rs index 706c74e3..332c352d 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,11 +1,21 @@ use torrust_index_backend::app; use torrust_index_backend::bootstrap::config::init_configuration; +use torrust_index_backend::web::api::Implementation; -#[actix_web::main] +#[tokio::main] async fn main() -> Result<(), std::io::Error> { let configuration = init_configuration().await; - let app = app::run(configuration).await; + // todo: we are migrating from actix-web to axum, so we need to keep both + // implementations for a while. For production we only use ActixWeb. + // Once the Axum implementation is finished and stable, we can switch to it + // and remove the ActixWeb implementation. + let api_implementation = Implementation::ActixWeb; - app.api_server.await + let app = app::run(configuration, &api_implementation).await; + + match api_implementation { + Implementation::ActixWeb => app.actix_web_api_server.unwrap().await.expect("the API server was dropped"), + Implementation::Axum => app.axum_api_server.unwrap().await.expect("the Axum API server was dropped"), + } } diff --git a/src/services/about.rs b/src/services/about.rs index b4e52a2a..b0b18c4a 100644 --- a/src/services/about.rs +++ b/src/services/about.rs @@ -3,6 +3,11 @@ use crate::routes::API_VERSION; #[must_use] pub fn index_page() -> String { + page() +} + +#[must_use] +pub fn page() -> String { format!( r#" diff --git a/src/web/api/actix.rs b/src/web/api/actix.rs new file mode 100644 index 00000000..47b5f3d6 --- /dev/null +++ b/src/web/api/actix.rs @@ -0,0 +1,75 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use actix_cors::Cors; +use actix_web::{middleware, web, App, HttpServer}; +use log::info; +use tokio::sync::oneshot::{self, Sender}; + +use super::Running; +use crate::common::AppData; +use crate::routes; +use crate::web::api::ServerStartedMessage; + +/// Starts the API server with `ActixWeb`. +/// +/// # Panics +/// +/// Panics if the API server can't be started. +pub async fn start(app_data: Arc, net_ip: &str, net_port: u16) -> Running { + let config_socket_addr: SocketAddr = format!("{net_ip}:{net_port}") + .parse() + .expect("API server socket address to be valid."); + + let (tx, rx) = oneshot::channel::(); + + // Run the API server + let join_handle = tokio::spawn(async move { + info!("Starting API server with net config: {} ...", config_socket_addr); + + let server_future = start_server(config_socket_addr, app_data.clone(), tx); + + let _ = server_future.await; + + Ok(()) + }); + + // Wait until the API server is running + let bound_addr = match rx.await { + Ok(msg) => msg.socket_addr, + Err(e) => panic!("API server start. The API server was dropped: {e}"), + }; + + info!("API server started"); + + Running { + socket_addr: bound_addr, + actix_web_api_server: Some(join_handle), + axum_api_server: None, + } +} + +fn start_server( + config_socket_addr: SocketAddr, + app_data: Arc, + tx: Sender, +) -> actix_web::dev::Server { + let server = HttpServer::new(move || { + App::new() + .wrap(Cors::permissive()) + .app_data(web::Data::new(app_data.clone())) + .wrap(middleware::Logger::default()) + .configure(routes::init) + }) + .bind(config_socket_addr) + .expect("can't bind server to socket address"); + + let bound_addr = server.addrs()[0]; + + info!("API server listening on http://{}", bound_addr); + + tx.send(ServerStartedMessage { socket_addr: bound_addr }) + .expect("the API server should not be dropped"); + + server.run() +} diff --git a/src/web/api/axum.rs b/src/web/api/axum.rs new file mode 100644 index 00000000..5371dbc9 --- /dev/null +++ b/src/web/api/axum.rs @@ -0,0 +1,76 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use futures::Future; +use log::info; +use tokio::sync::oneshot::{self, Sender}; + +use super::v1::routes::router; +use super::{Running, ServerStartedMessage}; +use crate::common::AppData; + +/// Starts the API server with `Axum`. +/// +/// # Panics +/// +/// Panics if the API server can't be started. +pub async fn start(app_data: Arc, net_ip: &str, net_port: u16) -> Running { + let config_socket_addr: SocketAddr = format!("{net_ip}:{net_port}") + .parse() + .expect("API server socket address to be valid."); + + let (tx, rx) = oneshot::channel::(); + + // Run the API server + let join_handle = tokio::spawn(async move { + info!("Starting API server with net config: {} ...", config_socket_addr); + + let handle = start_server(config_socket_addr, app_data.clone(), tx); + + if let Ok(()) = handle.await { + info!("API server stopped"); + } + + Ok(()) + }); + + // Wait until the API server is running + let bound_addr = match rx.await { + Ok(msg) => msg.socket_addr, + Err(e) => panic!("API server start. The API server was dropped: {e}"), + }; + + Running { + socket_addr: bound_addr, + actix_web_api_server: None, + axum_api_server: Some(join_handle), + } +} + +fn start_server( + config_socket_addr: SocketAddr, + app_data: Arc, + tx: Sender, +) -> impl Future> { + let tcp_listener = std::net::TcpListener::bind(config_socket_addr).expect("tcp listener to bind to a socket address"); + + let bound_addr = tcp_listener + .local_addr() + .expect("tcp listener to be bound to a socket address."); + + info!("API server listening on http://{}", bound_addr); + + let app = router(app_data); + + let server = axum::Server::from_tcp(tcp_listener) + .expect("a new server from the previously created tcp listener.") + .serve(app.into_make_service_with_connect_info::()); + + tx.send(ServerStartedMessage { socket_addr: bound_addr }) + .expect("the API server should not be dropped"); + + server.with_graceful_shutdown(async move { + tokio::signal::ctrl_c().await.expect("Failed to listen to shutdown signal."); + info!("Stopping API server on http://{} ...", bound_addr); + }) +} diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs index 8582ba66..9321f433 100644 --- a/src/web/api/mod.rs +++ b/src/web/api/mod.rs @@ -3,4 +3,51 @@ //! Currently, the API has only one version: `v1`. //! //! Refer to the [`v1`](crate::web::api::v1) module for more information. +pub mod actix; +pub mod axum; pub mod v1; + +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::task::JoinHandle; + +use crate::common::AppData; +use crate::web::api; + +/// API implementations. +pub enum Implementation { + /// API implementation with Actix Web. + ActixWeb, + /// API implementation with Axum. + Axum, +} + +/// The running API server. +pub struct Running { + /// The socket address the API server is listening on. + pub socket_addr: SocketAddr, + /// The API server when using Actix Web. + pub actix_web_api_server: Option>>, + /// The handle for the running API server task when using Axum. + pub axum_api_server: Option>>, +} + +#[must_use] +#[derive(Debug)] +pub struct ServerStartedMessage { + pub socket_addr: SocketAddr, +} + +/// Starts the API server. +/// +/// We are migrating the API server from Actix Web to Axum. While the migration +/// is in progress, we will keep both implementations, running the Axum one only +/// for testing purposes. +#[must_use] +pub async fn start(app_data: Arc, net_ip: &str, net_port: u16, implementation: &Implementation) -> api::Running { + match implementation { + Implementation::ActixWeb => actix::start(app_data, net_ip, net_port).await, + Implementation::Axum => axum::start(app_data, net_ip, net_port).await, + } +} diff --git a/src/web/api/v1/contexts/about/handlers.rs b/src/web/api/v1/contexts/about/handlers.rs new file mode 100644 index 00000000..99e4cd08 --- /dev/null +++ b/src/web/api/v1/contexts/about/handlers.rs @@ -0,0 +1,20 @@ +//! API handlers for the the [`about`](crate::web::api::v1::contexts::about) API +//! context. +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; + +use crate::common::AppData; +use crate::services::about; + +#[allow(clippy::unused_async)] +pub async fn about_page_handler(State(_app_data): State>) -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + about::page(), + ) + .into_response() +} diff --git a/src/web/api/v1/contexts/about/mod.rs b/src/web/api/v1/contexts/about/mod.rs index 0b12ff66..bde0696a 100644 --- a/src/web/api/v1/contexts/about/mod.rs +++ b/src/web/api/v1/contexts/about/mod.rs @@ -84,3 +84,5 @@ //! //! //! ``` +pub mod handlers; +pub mod routes; diff --git a/src/web/api/v1/contexts/about/routes.rs b/src/web/api/v1/contexts/about/routes.rs new file mode 100644 index 00000000..fe36dd92 --- /dev/null +++ b/src/web/api/v1/contexts/about/routes.rs @@ -0,0 +1,15 @@ +//! API routes for the [`about`](crate::web::api::v1::contexts::about) API context. +//! +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::about). +use std::sync::Arc; + +use axum::routing::get; +use axum::Router; + +use super::handlers::about_page_handler; +use crate::common::AppData; + +/// It adds the routes to the router for the [`about`](crate::web::api::v1::contexts::about) API context. +pub fn add(prefix: &str, router: Router, app_data: Arc) -> Router { + router.route(&format!("{prefix}/about"), get(about_page_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/mod.rs b/src/web/api/v1/mod.rs index 716030ab..9d94e076 100644 --- a/src/web/api/v1/mod.rs +++ b/src/web/api/v1/mod.rs @@ -6,3 +6,4 @@ //! information. pub mod auth; pub mod contexts; +pub mod routes; diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs new file mode 100644 index 00000000..c980bd1e --- /dev/null +++ b/src/web/api/v1/routes.rs @@ -0,0 +1,22 @@ +//! Route initialization for the v1 API. +use std::sync::Arc; + +use axum::Router; + +use super::contexts::about; +use crate::common::AppData; + +/// Add all API routes to the router. +#[allow(clippy::needless_pass_by_value)] +pub fn router(app_data: Arc) -> Router { + let router = Router::new(); + + add(router, app_data) +} + +/// Add the routes for the v1 API. +fn add(router: Router, app_data: Arc) -> Router { + let v1_prefix = "/v1".to_string(); + + about::routes::add(&v1_prefix, router, app_data) +} diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs index 7907c761..d50f93e5 100644 --- a/tests/e2e/contexts/about/contract.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -1,4 +1,6 @@ //! API contract for `about` context. +use torrust_index_backend::web::api; + use crate::common::asserts::{assert_response_title, assert_text_ok}; use crate::common::client::Client; use crate::e2e::environment::TestEnv; @@ -6,7 +8,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] async fn it_should_load_the_about_page_with_information_about_the_api() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.about().await; @@ -18,7 +20,7 @@ async fn it_should_load_the_about_page_with_information_about_the_api() { #[tokio::test] async fn it_should_load_the_license_page_at_the_api_entrypoint() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.license().await; @@ -26,3 +28,23 @@ async fn it_should_load_the_license_page_at_the_api_entrypoint() { assert_text_ok(&response); assert_response_title(&response, "Licensing"); } + +mod with_axum_implementation { + use torrust_index_backend::web::api; + + use crate::common::asserts::{assert_response_title, assert_text_ok}; + use crate::common::client::Client; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_load_the_about_page_with_information_about_the_api() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.about().await; + + assert_text_ok(&response); + assert_response_title(&response, "About"); + } +} diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index 5d12290b..b5682327 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -1,4 +1,6 @@ //! API contract for `category` context. +use torrust_index_backend::web::api; + use crate::common::asserts::assert_json_ok; use crate::common::client::Client; use crate::common::contexts::category::fixtures::random_category_name; @@ -18,7 +20,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_categories().await; @@ -29,7 +31,7 @@ async fn it_should_return_an_empty_category_list_when_there_are_no_categories() #[tokio::test] async fn it_should_return_a_category_list() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); // Add a category @@ -53,7 +55,7 @@ async fn it_should_return_a_category_list() { #[tokio::test] async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client @@ -69,7 +71,7 @@ async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { #[tokio::test] async fn it_should_not_allow_adding_a_new_category_to_non_admins() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let logged_non_admin = new_logged_in_user(&env).await; @@ -88,7 +90,7 @@ async fn it_should_not_allow_adding_a_new_category_to_non_admins() { #[tokio::test] async fn it_should_allow_admins_to_add_new_categories() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -114,7 +116,7 @@ async fn it_should_allow_admins_to_add_new_categories() { #[tokio::test] async fn it_should_not_allow_adding_duplicated_categories() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; // Add a category let random_category_name = random_category_name(); @@ -129,7 +131,7 @@ async fn it_should_not_allow_adding_duplicated_categories() { #[tokio::test] async fn it_should_allow_admins_to_delete_categories() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -158,7 +160,7 @@ async fn it_should_allow_admins_to_delete_categories() { #[tokio::test] async fn it_should_not_allow_non_admins_to_delete_categories() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; // Add a category let category_name = random_category_name(); @@ -181,7 +183,7 @@ async fn it_should_not_allow_non_admins_to_delete_categories() { #[tokio::test] async fn it_should_not_allow_guests_to_delete_categories() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); // Add a category diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs index 84c1fc45..10a2bf6c 100644 --- a/tests/e2e/contexts/root/contract.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -1,4 +1,6 @@ //! API contract for `root` context. +use torrust_index_backend::web::api; + use crate::common::asserts::{assert_response_title, assert_text_ok}; use crate::common::client::Client; use crate::e2e::environment::TestEnv; @@ -6,7 +8,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] async fn it_should_load_the_about_page_at_the_api_entrypoint() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.root().await; diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 3d87a0c7..0802512a 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -1,3 +1,5 @@ +use torrust_index_backend::web::api; + use crate::common::client::Client; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; use crate::e2e::contexts::user::steps::new_logged_in_admin; @@ -6,7 +8,7 @@ use crate::e2e::environment::TestEnv; #[tokio::test] async fn it_should_allow_guests_to_get_the_public_settings() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_public_settings().await; @@ -31,7 +33,7 @@ async fn it_should_allow_guests_to_get_the_public_settings() { #[tokio::test] async fn it_should_allow_guests_to_get_the_site_name() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_site_name().await; @@ -48,7 +50,7 @@ async fn it_should_allow_guests_to_get_the_site_name() { #[tokio::test] async fn it_should_allow_admins_to_get_all_the_settings() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -67,7 +69,7 @@ async fn it_should_allow_admins_to_get_all_the_settings() { #[tokio::test] async fn it_should_allow_admins_to_update_all_the_settings() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.is_isolated() { // This test can't be executed in a non-isolated environment because diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 929e0cea..955f5154 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -16,6 +16,7 @@ Get torrent info: mod for_guests { use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::category::fixtures::software_predefined_category_id; @@ -33,7 +34,7 @@ mod for_guests { #[tokio::test] async fn it_should_allow_guests_to_get_torrents() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -56,7 +57,7 @@ mod for_guests { #[tokio::test] async fn it_should_allow_to_get_torrents_with_pagination() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -86,7 +87,7 @@ mod for_guests { #[tokio::test] async fn it_should_allow_to_limit_the_number_of_torrents_per_request() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -121,7 +122,7 @@ mod for_guests { #[tokio::test] async fn it_should_return_a_default_amount_of_torrents_per_request_if_no_page_size_is_provided() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -152,7 +153,7 @@ mod for_guests { #[tokio::test] async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -215,7 +216,7 @@ mod for_guests { #[tokio::test] async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -240,7 +241,7 @@ mod for_guests { #[tokio::test] async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -260,7 +261,7 @@ mod for_guests { #[tokio::test] async fn it_should_not_allow_guests_to_delete_torrents() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -281,6 +282,7 @@ mod for_guests { mod for_authenticated_users { use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::torrent::fixtures::random_torrent; @@ -294,7 +296,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_authenticated_users_to_upload_new_torrents() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -323,7 +325,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let uploader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); @@ -342,7 +344,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -371,7 +373,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -401,7 +403,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -435,6 +437,8 @@ mod for_authenticated_users { } mod and_non_admins { + use torrust_index_backend::web::api; + use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; @@ -444,7 +448,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_not_allow_non_admins_to_delete_torrents() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -464,7 +468,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -497,6 +501,8 @@ mod for_authenticated_users { } mod and_torrent_owners { + use torrust_index_backend::web::api; + use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; @@ -507,7 +513,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_torrent_owners_to_update_their_torrents() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -543,6 +549,8 @@ mod for_authenticated_users { } mod and_admins { + use torrust_index_backend::web::api; + use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; @@ -553,7 +561,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); @@ -577,7 +585,7 @@ mod for_authenticated_users { #[tokio::test] async fn it_should_allow_admins_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 06a12f79..24abc0de 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -1,4 +1,6 @@ //! API contract for `user` context. +use torrust_index_backend::web::api; + use crate::common::client::Client; use crate::common::contexts::user::fixtures::random_user_registration; use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; @@ -39,7 +41,7 @@ the mailcatcher API. #[tokio::test] async fn it_should_allow_a_guest_user_to_register() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let form = random_user_registration(); @@ -56,7 +58,7 @@ async fn it_should_allow_a_guest_user_to_register() { #[tokio::test] async fn it_should_allow_a_registered_user_to_login() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let registered_user = new_registered_user(&env).await; @@ -81,7 +83,7 @@ async fn it_should_allow_a_registered_user_to_login() { #[tokio::test] async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let logged_in_user = new_logged_in_user(&env).await; @@ -104,7 +106,7 @@ async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { #[tokio::test] async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let logged_in_user = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); @@ -132,6 +134,8 @@ async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_w } mod banned_user_list { + use torrust_index_backend::web::api; + use crate::common::client::Client; use crate::common::contexts::user::forms::Username; use crate::common::contexts::user::responses::BannedUserResponse; @@ -141,7 +145,7 @@ mod banned_user_list { #[tokio::test] async fn it_should_allow_an_admin_to_ban_a_user() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -162,7 +166,7 @@ mod banned_user_list { #[tokio::test] async fn it_should_not_allow_a_non_admin_to_ban_a_user() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let logged_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); @@ -177,7 +181,7 @@ mod banned_user_list { #[tokio::test] async fn it_should_not_allow_a_guest_to_ban_a_user() { let mut env = TestEnv::new(); - env.start().await; + env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let registered_user = new_registered_user(&env).await; diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 43eb7af3..343e5512 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -1,5 +1,7 @@ use std::env; +use torrust_index_backend::web::api::Implementation; + use super::config::{init_shared_env_configuration, ENV_VAR_E2E_SHARED}; use crate::common::contexts::settings::Settings; use crate::environments::{isolated, shared}; @@ -53,7 +55,7 @@ impl TestEnv { /// It starts the test environment. It can be a shared or isolated test /// environment depending on the value of the `ENV_VAR_E2E_SHARED` env var. - pub async fn start(&mut self) { + pub async fn start(&mut self, api_implementation: Implementation) { let e2e_shared = ENV_VAR_E2E_SHARED; // bool if let Ok(_e2e_test_env_is_shared) = env::var(e2e_shared) { @@ -64,7 +66,7 @@ impl TestEnv { self.starting_settings = self.server_settings_for_shared_env().await; } else { // Using an isolated test env. - let isolated_env = isolated::TestEnv::running().await; + let isolated_env = isolated::TestEnv::running(api_implementation).await; self.isolated = Some(isolated_env); self.starting_settings = self.server_settings_for_isolated_env(); diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index 251f0481..a08f3592 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -2,7 +2,9 @@ use std::net::SocketAddr; use log::info; use tokio::sync::{oneshot, RwLock}; +use tokio::task::JoinHandle; use torrust_index_backend::config::Configuration; +use torrust_index_backend::web::api::Implementation; use torrust_index_backend::{app, config}; /// It launches the app and provides a way to stop it. @@ -25,41 +27,46 @@ impl AppStarter { } } + /// Starts the whole app with all its services. + /// /// # Panics /// /// Will panic if the app was dropped after spawning it. - pub async fn start(&mut self) { + pub async fn start(&mut self, api_implementation: Implementation) { let configuration = Configuration { settings: RwLock::new(self.configuration.clone()), config_path: self.config_path.clone(), }; // Open a channel to communicate back with this function - let (tx, rx) = oneshot::channel::(); + let (tx, rx) = oneshot::channel::(); // Launch the app in a separate task let app_handle = tokio::spawn(async move { - let app = app::run(configuration).await; + let app = app::run(configuration, &api_implementation).await; + + info!("Application started. API server listening on {}", app.api_socket_addr); // Send the socket address back to the main thread - tx.send(AppStarted { - socket_addr: app.socket_address, + tx.send(AppStartedMessage { + api_socket_addr: app.api_socket_addr, }) .expect("the app starter should not be dropped"); - app.api_server.await + match api_implementation { + Implementation::ActixWeb => app.actix_web_api_server.unwrap().await, + Implementation::Axum => app.axum_api_server.unwrap().await, + } }); // Wait until the app is started let socket_addr = match rx.await { - Ok(msg) => msg.socket_addr, + Ok(msg) => msg.api_socket_addr, Err(e) => panic!("the app was dropped: {e}"), }; let running_state = RunningState { app_handle, socket_addr }; - info!("Test environment started. Listening on {}", running_state.socket_addr); - // Update the app state self.running_state = Some(running_state); } @@ -91,13 +98,13 @@ impl AppStarter { } #[derive(Debug)] -pub struct AppStarted { - pub socket_addr: SocketAddr, +pub struct AppStartedMessage { + pub api_socket_addr: SocketAddr, } /// Stores the app state when it is running. pub struct RunningState { - app_handle: tokio::task::JoinHandle>, + app_handle: JoinHandle, tokio::task::JoinError>>, pub socket_addr: SocketAddr, } diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index e619e191..411a3149 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -1,6 +1,7 @@ use tempfile::TempDir; use torrust_index_backend::config; use torrust_index_backend::config::FREE_PORT; +use torrust_index_backend::web::api::Implementation; use super::app_starter::AppStarter; use crate::common::random; @@ -15,9 +16,9 @@ pub struct TestEnv { impl TestEnv { /// Provides a running app instance for integration tests. - pub async fn running() -> Self { + pub async fn running(api_implementation: Implementation) -> Self { let mut env = Self::default(); - env.start().await; + env.start(api_implementation).await; env } @@ -39,8 +40,8 @@ impl TestEnv { } /// Starts the app. - pub async fn start(&mut self) { - self.app_starter.start().await; + pub async fn start(&mut self, api_implementation: Implementation) { + self.app_starter.start(api_implementation).await; } /// Provides the whole server configuration. From 3edd5070c68508853b83c1f93d5f3fa50f98ad86 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 6 Jun 2023 11:45:16 +0100 Subject: [PATCH 200/357] docs: [#97] update README --- COPYRIGHT | 1 - README.md | 76 +++++++++++++++++++++++-------------------------------- 2 files changed, 32 insertions(+), 45 deletions(-) diff --git a/COPYRIGHT b/COPYRIGHT index 4c96c089..ec00ae29 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -9,4 +9,3 @@ licensed under the GNU Affero General Public License, Version 3.0 for all commits made after 5 years of merging. This license applies to the version of the files merged into the Torrust-Index-Backend project at the time of merging, and does not apply to subsequent updates or revisions to those files. The contributors to the Torrust-Index-Backend project disclaim all liability for any damages or losses that may arise from the use of the project. - diff --git a/README.md b/README.md index 3d62c011..73224ade 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,56 @@ # Torrust Index Backend -This repository serves as the backend for the [Torrust Index](https://github.com/torrust/torrust-index) project, that implements the [Torrust Index Application Interface](https://github.com/torrust/torrust-index-api-lib). +[![Development Checks](https://github.com/torrust/torrust-index-backend/actions/workflows/develop.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/develop.yml) [![Publish crate](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml) [![Publish Docker Image](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml) [![Publish Github Release](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml) [![Test Docker build](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml) -We also provide the [Torrust Index Frontend](https://github.com/torrust/torrust-index-frontend) project, that is our reference web-application that consumes the API provided here. +This repository serves as the backend for the [Torrust Index](https://github.com/torrust/torrust-index) project, which implements the [Torrust Index Application Interface](https://github.com/torrust/torrust-index-api-lib). -## Documentation - -You can read the Torrust Index documentation [here](https://torrust.com/torrust-index/install/#installing-the-backend). - -## Installation +We also provide the [Torrust Index Frontend](https://github.com/torrust/torrust-index-frontend) project, which is our reference web application that consumes the API provided here. -1. Setup [Rust / Cargo](https://www.rust-lang.org/) in your Environment. +![Torrust Architecture](https://raw.githubusercontent.com/torrust/.github/main/img/torrust-architecture.webp) -2. Clone this repo. +## Key Features -3. Set the database connection URI in the projects `/.env` file: +* [X] Rest API +* [X] Categories and tags +* [X] Image proxy cache - ```bash - cd torrust-index-backend - echo "DATABASE_URL=sqlite://data.db?mode=rwc" >> .env - ``` +## Getting Started -4. Install sqlx-cli and build the sqlite database: +Requirements: - ```bash - cargo install sqlx-cli - sqlx db setup - ``` +* Rust Stable `1.68` -5. Build the binaries: +You can follow the [documentation](https://docs.rs/torrust-index-backend) to install and use Torrust Index Backend in different ways, but if you want to give it a quick try, you can use the following commands: - ```bash - cargo build --release - ``` +```s +git clone https://github.com/torrust/torrust-index-backend.git \ + && cd torrust-index-backend \ + && cargo build --release +``` -6. Run the backend once to generate the `config.toml` file: +And then run `cargo run` twice. The first time to generate the `config.toml` file and the second time to run the backend with the default configuration. - ```bash - ./target/release/torrust-index-backend - ``` +After running the tracker the API will be available at . -7. Review and edit the default `/config.toml` file. - -> _Please view the [configuration documentation](https://torrust.github.io/torrust-tracker/CONFIG.html)._ - -8. Run the backend again: +## Documentation - ```bash - ./target/torrust-index-backend - ``` +The technical documentation is available at [docs.rs](https://docs.rs/torrust-index-backend). -## Contact and Contributing +## Contributing -Please consider the [Torrust Contribution Guide](https://github.com/torrust/.github/blob/main/info/contributing.md). +We welcome contributions from the community! -Please report issues: +How can you contribute? -* Torrust Index Backend specifically: [here](https://github.com/torrust/torrust-index-backend/issues). -* Torrust Index in general: [here](https://github.com/torrust/torrust-index/issues). +* Bug reports and feature requests. +* Code contributions. You can start by looking at the issues labeled ["good first issues"](https://github.com/torrust/torrust-index-backend/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). +* Documentation improvements. Check the [documentation](torrust-index-backend) for typos, errors, or missing information. +* Participation in the community. You can help by answering questions in the [discussions](https://github.com/torrust/torrust-index-backend/discussions). ---- +## License -## Credits & Sponsors +The project is licensed under a dual license. See [COPYRIGHT](./COPYRIGHT). -This project was developed by [Dutch Bits](https://dutchbits.nl) for [Nautilus Cyberneering GmbH](https://nautilus-cyberneering.de/). +## Acknowledgments -The project has been possible through the support and contribution of both Nautilus Cyberneering, its team and collaborators, as well as that of our great open source contributors. Thank you to you all! +This project was a joint effort by [Nautilus Cyberneering GmbH](https://nautilus-cyberneering.de/), [Dutch Bits](https://dutchbits.nl) and collaborators. Thank you to you all! From 0264e5c78e426317f2ca61301de7f50459db19c2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 8 Jun 2023 16:44:04 +0100 Subject: [PATCH 201/357] feat: [#177] new config option for log level ```toml log_level = "info" ``` The level can be: - `off` - `error` - `warn` - `info` - `debug` - `trace` --- config-idx-back.local.toml | 2 ++ config.local.toml | 2 ++ src/app.rs | 4 +++- src/bootstrap/logging.rs | 6 ++---- src/config.rs | 3 +++ src/console/commands/import_tracker_statistics.rs | 4 +++- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/config-idx-back.local.toml b/config-idx-back.local.toml index 5f93c0e6..9b6264f6 100644 --- a/config-idx-back.local.toml +++ b/config-idx-back.local.toml @@ -1,3 +1,5 @@ +log_level = "info" + [website] name = "Torrust" diff --git a/config.local.toml b/config.local.toml index 5a11fbea..3bb1093e 100644 --- a/config.local.toml +++ b/config.local.toml @@ -1,3 +1,5 @@ +log_level = "info" + [website] name = "Torrust" diff --git a/src/app.rs b/src/app.rs index a2bceb2f..872e3d9a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,7 +30,9 @@ pub struct Running { #[allow(clippy::too_many_lines)] pub async fn run(configuration: Configuration, api_implementation: &Implementation) -> Running { - logging::setup(); + let log_level = configuration.settings.read().await.log_level.clone(); + + logging::setup(&log_level); let configuration = Arc::new(configuration); diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs index 303c5775..8546720f 100644 --- a/src/bootstrap/logging.rs +++ b/src/bootstrap/logging.rs @@ -13,10 +13,8 @@ use log::{info, LevelFilter}; static INIT: Once = Once::new(); -pub fn setup() { - // todo: load log level from configuration. - - let level = config_level_or_default(&None); +pub fn setup(log_level: &Option) { + let level = config_level_or_default(log_level); if level == log::LevelFilter::Off { return; diff --git a/src/config.rs b/src/config.rs index 8348edd3..0db50ea9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -257,6 +257,9 @@ impl Default for ImageCache { /// The whole configuration for the backend. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct TorrustBackend { + /// Logging level. Possible values are: `Off`, `Error`, `Warn`, `Info`, + /// `Debug` and `Trace`. Default is `Info`. + pub log_level: Option, /// The website customizable values. pub website: Website, /// The tracker configuration. diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 2040ca95..579cca0c 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -85,7 +85,9 @@ pub async fn import() { let configuration = init_configuration().await; - logging::setup(); + let log_level = configuration.settings.read().await.log_level.clone(); + + logging::setup(&log_level); let cfg = Arc::new(configuration); From 68e2132092fcbbbc904c8d6b7d7b4be6b52cae90 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 8 Jun 2023 16:45:07 +0100 Subject: [PATCH 202/357] refactor: disable logging for testing The log has some errors we need to fix. See: https://github.com/torrust/torrust-index-backend/issues/176 --- tests/environments/isolated.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index 411a3149..a4b1ae45 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -70,7 +70,10 @@ impl Default for TestEnv { /// Provides a configuration with ephemeral data for testing. fn ephemeral(temp_dir: &TempDir) -> config::TorrustBackend { - let mut configuration = config::TorrustBackend::default(); + let mut configuration = config::TorrustBackend { + log_level: Some("off".to_owned()), // Change to `debug` for tests debugging + ..config::TorrustBackend::default() + }; // Ephemeral API port configuration.net.port = FREE_PORT; From 19243655216524e3f86f4e633ac01d01a7c53882 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 8 Jun 2023 17:15:50 +0100 Subject: [PATCH 203/357] refactor(api): [#178] axum API, 'about' context migrated --- src/web/api/v1/contexts/about/handlers.rs | 10 ++++++++++ src/web/api/v1/contexts/about/routes.rs | 12 ++++++++++-- tests/e2e/contexts/about/contract.rs | 12 ++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/web/api/v1/contexts/about/handlers.rs b/src/web/api/v1/contexts/about/handlers.rs index 99e4cd08..07d5977b 100644 --- a/src/web/api/v1/contexts/about/handlers.rs +++ b/src/web/api/v1/contexts/about/handlers.rs @@ -18,3 +18,13 @@ pub async fn about_page_handler(State(_app_data): State>) -> Respon ) .into_response() } + +#[allow(clippy::unused_async)] +pub async fn license_page_handler(State(_app_data): State>) -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + about::license_page(), + ) + .into_response() +} diff --git a/src/web/api/v1/contexts/about/routes.rs b/src/web/api/v1/contexts/about/routes.rs index fe36dd92..da53052c 100644 --- a/src/web/api/v1/contexts/about/routes.rs +++ b/src/web/api/v1/contexts/about/routes.rs @@ -6,10 +6,18 @@ use std::sync::Arc; use axum::routing::get; use axum::Router; -use super::handlers::about_page_handler; +use super::handlers::{about_page_handler, license_page_handler}; use crate::common::AppData; /// It adds the routes to the router for the [`about`](crate::web::api::v1::contexts::about) API context. pub fn add(prefix: &str, router: Router, app_data: Arc) -> Router { - router.route(&format!("{prefix}/about"), get(about_page_handler).with_state(app_data)) + router + .route( + &format!("{prefix}/about"), + get(about_page_handler).with_state(app_data.clone()), + ) + .route( + &format!("{prefix}/about/license"), + get(license_page_handler).with_state(app_data), + ) } diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs index d50f93e5..efe30227 100644 --- a/tests/e2e/contexts/about/contract.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -47,4 +47,16 @@ mod with_axum_implementation { assert_text_ok(&response); assert_response_title(&response, "About"); } + + #[tokio::test] + async fn it_should_load_the_license_page_at_the_api_entrypoint() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.license().await; + + assert_text_ok(&response); + assert_response_title(&response, "Licensing"); + } } From b97698aff614e7a52e246cbe0b96da67df5caf20 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 9 Jun 2023 13:11:08 +0100 Subject: [PATCH 204/357] test(api): [#187] add tests for new 'tag' context --- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- tests/common/client.rs | 17 +++ tests/common/contexts/mod.rs | 1 + tests/common/contexts/tag/fixtures.rs | 10 ++ tests/common/contexts/tag/forms.rs | 11 ++ tests/common/contexts/tag/mod.rs | 3 + tests/common/contexts/tag/responses.rs | 28 ++++ tests/e2e/contexts/mod.rs | 1 + tests/e2e/contexts/tag/contract.rs | 183 +++++++++++++++++++++++++ tests/e2e/contexts/tag/mod.rs | 2 + tests/e2e/contexts/tag/steps.rs | 39 ++++++ 12 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 tests/common/contexts/tag/fixtures.rs create mode 100644 tests/common/contexts/tag/forms.rs create mode 100644 tests/common/contexts/tag/mod.rs create mode 100644 tests/common/contexts/tag/responses.rs create mode 100644 tests/e2e/contexts/tag/contract.rs create mode 100644 tests/e2e/contexts/tag/mod.rs create mode 100644 tests/e2e/contexts/tag/steps.rs diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index fdc565d0..2b84696a 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -778,7 +778,7 @@ impl Database for Mysql { .bind(name) .fetch_one(&self.pool) .await - .map_err(|err| database::Error::TagNotFound) + .map_err(|_| database::Error::TagNotFound) } async fn get_tags(&self) -> Result, database::Error> { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 39156984..0a4b3869 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -768,7 +768,7 @@ impl Database for Sqlite { .bind(name) .fetch_one(&self.pool) .await - .map_err(|err| database::Error::TagNotFound) + .map_err(|_| database::Error::TagNotFound) } async fn get_tags(&self) -> Result, database::Error> { diff --git a/tests/common/client.rs b/tests/common/client.rs index 67bae6bc..25db78f5 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -4,6 +4,7 @@ use serde::Serialize; use super::connection_info::ConnectionInfo; use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::settings::form::UpdateSettings; +use super::contexts::tag::forms::{AddTagForm, DeleteTagForm}; use super::contexts::torrent::forms::UpdateTorrentFrom; use super::contexts::torrent::requests::InfoHash; use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; @@ -67,6 +68,22 @@ impl Client { self.http_client.delete_with_body("/category", &delete_category_form).await } + // Context: tag + + pub async fn get_tags(&self) -> TextResponse { + // code-review: some endpoint are using plural + // (for instance, `get_categories`) and some singular. + self.http_client.get("/tags", Query::empty()).await + } + + pub async fn add_tag(&self, add_tag_form: AddTagForm) -> TextResponse { + self.http_client.post("/tag", &add_tag_form).await + } + + pub async fn delete_tag(&self, delete_tag_form: DeleteTagForm) -> TextResponse { + self.http_client.delete_with_body("/tag", &delete_tag_form).await + } + // Context: root pub async fn root(&self) -> TextResponse { diff --git a/tests/common/contexts/mod.rs b/tests/common/contexts/mod.rs index a6f14141..fa791e5f 100644 --- a/tests/common/contexts/mod.rs +++ b/tests/common/contexts/mod.rs @@ -2,5 +2,6 @@ pub mod about; pub mod category; pub mod root; pub mod settings; +pub mod tag; pub mod torrent; pub mod user; diff --git a/tests/common/contexts/tag/fixtures.rs b/tests/common/contexts/tag/fixtures.rs new file mode 100644 index 00000000..39ac3081 --- /dev/null +++ b/tests/common/contexts/tag/fixtures.rs @@ -0,0 +1,10 @@ +use rand::Rng; + +pub fn random_tag_name() -> String { + format!("category name {}", random_id()) +} + +fn random_id() -> u64 { + let mut rng = rand::thread_rng(); + rng.gen_range(0..1_000_000) +} diff --git a/tests/common/contexts/tag/forms.rs b/tests/common/contexts/tag/forms.rs new file mode 100644 index 00000000..26d1395d --- /dev/null +++ b/tests/common/contexts/tag/forms.rs @@ -0,0 +1,11 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct AddTagForm { + pub name: String, +} + +#[derive(Serialize)] +pub struct DeleteTagForm { + pub tag_id: i64, +} diff --git a/tests/common/contexts/tag/mod.rs b/tests/common/contexts/tag/mod.rs new file mode 100644 index 00000000..6f27f51d --- /dev/null +++ b/tests/common/contexts/tag/mod.rs @@ -0,0 +1,3 @@ +pub mod fixtures; +pub mod forms; +pub mod responses; diff --git a/tests/common/contexts/tag/responses.rs b/tests/common/contexts/tag/responses.rs new file mode 100644 index 00000000..5029257e --- /dev/null +++ b/tests/common/contexts/tag/responses.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct AddedTagResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct DeletedTagResponse { + pub data: i64, // tag_id +} + +#[derive(Deserialize, Debug)] +pub struct ListResponse { + pub data: Vec, +} + +impl ListResponse { + pub fn find_tag_id(&self, tag_name: &str) -> i64 { + self.data.iter().find(|tag| tag.name == tag_name).unwrap().tag_id + } +} + +#[derive(Deserialize, Debug, PartialEq)] +pub struct ListItem { + pub tag_id: i64, + pub name: String, +} diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs index a6f14141..fa791e5f 100644 --- a/tests/e2e/contexts/mod.rs +++ b/tests/e2e/contexts/mod.rs @@ -2,5 +2,6 @@ pub mod about; pub mod category; pub mod root; pub mod settings; +pub mod tag; pub mod torrent; pub mod user; diff --git a/tests/e2e/contexts/tag/contract.rs b/tests/e2e/contexts/tag/contract.rs new file mode 100644 index 00000000..e209758a --- /dev/null +++ b/tests/e2e/contexts/tag/contract.rs @@ -0,0 +1,183 @@ +//! API contract for `tag` context. +use torrust_index_backend::web::api; + +use crate::common::asserts::assert_json_ok; +use crate::common::client::Client; +use crate::common::contexts::tag::fixtures::random_tag_name; +use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; +use crate::common::contexts::tag::responses::{AddedTagResponse, DeletedTagResponse, ListResponse}; +use crate::e2e::contexts::tag::steps::{add_random_tag, add_tag}; +use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; +use crate::e2e::environment::TestEnv; + +#[tokio::test] +async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_tags().await; + + assert_json_ok(&response); +} + +#[tokio::test] +async fn it_should_return_a_tag_list() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // Add a tag + let tag_name = random_tag_name(); + let response = add_tag(&tag_name, &env).await; + assert_eq!(response.status, 200); + + let response = client.get_tags().await; + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + // There should be at least the tag we added. + // Since this is an E2E test that could be executed in a shred env, + // there might be more tags. + assert!(!res.data.is_empty()); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_not_allow_adding_a_new_tag_to_unauthenticated_users() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client + .add_tag(AddTagForm { + name: "TAG NAME".to_string(), + }) + .await; + + assert_eq!(response.status, 401); +} + +#[tokio::test] +async fn it_should_not_allow_adding_a_new_tag_to_non_admins() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let response = client + .add_tag(AddTagForm { + name: "TAG NAME".to_string(), + }) + .await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +async fn it_should_allow_admins_to_add_new_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let tag_name = random_tag_name(); + + let response = client + .add_tag(AddTagForm { + name: tag_name.to_string(), + }) + .await; + + let res: AddedTagResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, tag_name); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_allow_adding_duplicated_tags() { + // code-review: is this an intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + // Add a tag + let random_tag_name = random_tag_name(); + let response = add_tag(&random_tag_name, &env).await; + assert_eq!(response.status, 200); + + // Try to add the same tag again + let response = add_tag(&random_tag_name, &env).await; + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_allow_adding_a_tag_with_an_empty_name() { + // code-review: is this an intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + let empty_tag_name = String::new(); + let response = add_tag(&empty_tag_name, &env).await; + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_allow_admins_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + let res: DeletedTagResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, tag_id); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_not_allow_non_admins_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + let logged_in_non_admin = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +async fn it_should_not_allow_guests_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_eq!(response.status, 401); +} diff --git a/tests/e2e/contexts/tag/mod.rs b/tests/e2e/contexts/tag/mod.rs new file mode 100644 index 00000000..2001efb8 --- /dev/null +++ b/tests/e2e/contexts/tag/mod.rs @@ -0,0 +1,2 @@ +pub mod contract; +pub mod steps; diff --git a/tests/e2e/contexts/tag/steps.rs b/tests/e2e/contexts/tag/steps.rs new file mode 100644 index 00000000..32bb767c --- /dev/null +++ b/tests/e2e/contexts/tag/steps.rs @@ -0,0 +1,39 @@ +use crate::common::client::Client; +use crate::common::contexts::tag::fixtures::random_tag_name; +use crate::common::contexts::tag::forms::AddTagForm; +use crate::common::contexts::tag::responses::ListResponse; +use crate::common::responses::TextResponse; +use crate::e2e::contexts::user::steps::new_logged_in_admin; +use crate::e2e::environment::TestEnv; + +pub async fn add_random_tag(env: &TestEnv) -> (i64, String) { + let tag_name = random_tag_name(); + + add_tag(&tag_name, env).await; + + let tag_id = get_tag_id(&tag_name, env).await; + + (tag_id, tag_name) +} + +pub async fn add_tag(tag_name: &str, env: &TestEnv) -> TextResponse { + let logged_in_admin = new_logged_in_admin(env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + client + .add_tag(AddTagForm { + name: tag_name.to_string(), + }) + .await +} + +pub async fn get_tag_id(tag_name: &str, env: &TestEnv) -> i64 { + let logged_in_admin = new_logged_in_admin(env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let response = client.get_tags().await; + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + res.find_tag_id(tag_name) +} From f693a02d4d679f42bae251ffcc7f02fd5e06e120 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 9 Jun 2023 15:21:00 +0100 Subject: [PATCH 205/357] refactor: [#187] extract tag::Service See https://github.com/torrust/torrust-index-backend/issues/157. --- src/app.rs | 5 ++ src/common.rs | 7 +++ src/databases/database.rs | 2 +- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/errors.rs | 16 ++++-- src/routes/tag.rs | 22 ++------ src/services/category.rs | 4 +- src/services/mod.rs | 1 + src/services/tag.rs | 113 ++++++++++++++++++++++++++++++++++++++ src/services/torrent.rs | 29 +--------- 11 files changed, 146 insertions(+), 57 deletions(-) create mode 100644 src/services/tag.rs diff --git a/src/app.rs b/src/app.rs index afdb8de9..b36098a9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::config::Configuration; use crate::databases::database; use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; +use crate::services::tag::{self, DbTagRepository}; use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, @@ -58,6 +59,7 @@ pub async fn run(configuration: Configuration, api_implementation: &Implementati // Repositories let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); + let tag_repository = Arc::new(DbTagRepository::new(database.clone())); let user_repository = Arc::new(DbUserRepository::new(database.clone())); let user_authentication_repository = Arc::new(DbUserAuthenticationRepository::new(database.clone())); let user_profile_repository = Arc::new(DbUserProfileRepository::new(database.clone())); @@ -76,6 +78,7 @@ pub async fn run(configuration: Configuration, api_implementation: &Implementati let mailer_service = Arc::new(mailer::Service::new(configuration.clone()).await); let image_cache_service: Arc = Arc::new(ImageCacheService::new(configuration.clone()).await); let category_service = Arc::new(category::Service::new(category_repository.clone(), user_repository.clone())); + let tag_service = Arc::new(tag::Service::new(tag_repository.clone(), user_repository.clone())); let proxy_service = Arc::new(proxy::Service::new(image_cache_service.clone(), user_repository.clone())); let settings_service = Arc::new(settings::Service::new(configuration.clone(), user_repository.clone())); let torrent_index = Arc::new(torrent::Index::new( @@ -123,6 +126,7 @@ pub async fn run(configuration: Configuration, api_implementation: &Implementati mailer_service, image_cache_service, category_repository, + tag_repository, user_repository, user_authentication_repository, user_profile_repository, @@ -134,6 +138,7 @@ pub async fn run(configuration: Configuration, api_implementation: &Implementati torrent_listing_generator, banned_user_list, category_service, + tag_service, proxy_service, settings_service, torrent_index, diff --git a/src/common.rs b/src/common.rs index db0361e3..94e28828 100644 --- a/src/common.rs +++ b/src/common.rs @@ -6,6 +6,7 @@ use crate::config::Configuration; use crate::databases::database::Database; use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebToken, Service}; use crate::services::category::{self, DbCategoryRepository}; +use crate::services::tag::{self, DbTagRepository}; use crate::services::torrent::{ DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, @@ -30,6 +31,7 @@ pub struct AppData { pub image_cache_manager: Arc, // Repositories pub category_repository: Arc, + pub tag_repository: Arc, pub user_repository: Arc, pub user_authentication_repository: Arc, pub user_profile_repository: Arc, @@ -42,6 +44,7 @@ pub struct AppData { pub banned_user_list: Arc, // Services pub category_service: Arc, + pub tag_service: Arc, pub proxy_service: Arc, pub settings_service: Arc, pub torrent_service: Arc, @@ -63,6 +66,7 @@ impl AppData { image_cache_manager: Arc, // Repositories category_repository: Arc, + tag_repository: Arc, user_repository: Arc, user_authentication_repository: Arc, user_profile_repository: Arc, @@ -75,6 +79,7 @@ impl AppData { banned_user_list: Arc, // Services category_service: Arc, + tag_service: Arc, proxy_service: Arc, settings_service: Arc, torrent_service: Arc, @@ -93,6 +98,7 @@ impl AppData { image_cache_manager, // Repositories category_repository, + tag_repository, user_repository, user_authentication_repository, user_profile_repository, @@ -105,6 +111,7 @@ impl AppData { banned_user_list, // Services category_service, + tag_service, proxy_service, settings_service, torrent_service, diff --git a/src/databases/database.rs b/src/databases/database.rs index a829424f..ea0c41a0 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -243,7 +243,7 @@ pub trait Database: Sync + Send { async fn add_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), Error>; /// Add multiple tags to a torrent at once. - async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), Error>; + async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &[TagId]) -> Result<(), Error>; /// Remove a tag from torrent. async fn delete_torrent_tag_link(&self, torrent_id: i64, tag_id: TagId) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 2b84696a..5ae75050 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -732,7 +732,7 @@ impl Database for Mysql { .map_err(|_| database::Error::Error) } - async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), database::Error> { + async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &[TagId]) -> Result<(), database::Error> { let mut transaction = self .pool .begin() diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 0a4b3869..81ad1763 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -722,7 +722,7 @@ impl Database for Sqlite { .map_err(|_| database::Error::Error) } - async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &Vec) -> Result<(), database::Error> { + async fn add_torrent_tag_links(&self, torrent_id: i64, tag_ids: &[TagId]) -> Result<(), database::Error> { let mut transaction = self .pool .begin() diff --git a/src/errors.rs b/src/errors.rs index c625d37b..668bf3ab 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -124,14 +124,17 @@ pub enum ServiceError { FailedToSendVerificationEmail, #[display(fmt = "Category already exists.")] - CategoryExists, + CategoryAlreadyExists, #[display(fmt = "Tag already exists.")] - TagExists, + TagAlreadyExists, #[display(fmt = "Category not found.")] CategoryNotFound, + #[display(fmt = "Tag not found.")] + TagNotFound, + #[display(fmt = "Database error.")] DatabaseError, } @@ -176,14 +179,15 @@ impl ResponseError for ServiceError { ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, - ServiceError::CategoryExists => StatusCode::BAD_REQUEST, - ServiceError::TagExists => StatusCode::BAD_REQUEST, + ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::EmailMissing => StatusCode::NOT_FOUND, ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::CategoryNotFound => StatusCode::NOT_FOUND, + ServiceError::TagNotFound => StatusCode::NOT_FOUND, } } @@ -223,9 +227,9 @@ impl From for ServiceError { database::Error::UsernameTaken => ServiceError::UsernameTaken, database::Error::EmailTaken => ServiceError::EmailTaken, database::Error::UserNotFound => ServiceError::UserNotFound, - database::Error::CategoryAlreadyExists => ServiceError::CategoryExists, + database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists, database::Error::CategoryNotFound => ServiceError::InvalidCategory, - database::Error::TagAlreadyExists => ServiceError::TagExists, + database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists, database::Error::TagNotFound => ServiceError::InvalidTag, database::Error::TorrentNotFound => ServiceError::TorrentNotFound, database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, diff --git a/src/routes/tag.rs b/src/routes/tag.rs index f317d5ca..b7de7b16 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -2,7 +2,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; use crate::common::WebAppData; -use crate::errors::{ServiceError, ServiceResult}; +use crate::errors::ServiceResult; use crate::models::response::OkResponse; use crate::models::torrent_tag::TagId; use crate::routes::API_VERSION; @@ -24,7 +24,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// /// This function will return an error if unable to get tags from database. pub async fn get_all(app_data: WebAppData) -> ServiceResult { - let tags = app_data.torrent_tag_repository.get_tags().await?; + let tags = app_data.tag_repository.get_all().await?; Ok(HttpResponse::Ok().json(OkResponse { data: tags })) } @@ -46,14 +46,7 @@ pub struct Create { pub async fn create(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { let user_id = app_data.auth.get_user_id_from_request(&req).await?; - let user = app_data.user_repository.get_compact(&user_id).await?; - - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } - - app_data.torrent_tag_repository.add_tag(&payload.name).await?; + app_data.tag_service.add_tag(&payload.name, &user_id).await?; Ok(HttpResponse::Ok().json(OkResponse { data: payload.name.to_string(), @@ -77,14 +70,7 @@ pub struct Delete { pub async fn delete(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { let user_id = app_data.auth.get_user_id_from_request(&req).await?; - let user = app_data.user_repository.get_compact(&user_id).await?; - - // check if user is administrator - if !user.administrator { - return Err(ServiceError::Unauthorized); - } - - app_data.torrent_tag_repository.delete_tag(&payload.tag_id).await?; + app_data.tag_service.delete_tag(&payload.tag_id, &user_id).await?; Ok(HttpResponse::Ok().json(OkResponse { data: payload.tag_id })) } diff --git a/src/services/category.rs b/src/services/category.rs index 2ff56d38..dbce9023 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -41,13 +41,13 @@ impl Service { match self.category_repository.add(category_name).await { Ok(id) => Ok(id), Err(e) => match e { - DatabaseError::CategoryAlreadyExists => Err(ServiceError::CategoryExists), + DatabaseError::CategoryAlreadyExists => Err(ServiceError::CategoryAlreadyExists), _ => Err(ServiceError::DatabaseError), }, } } - /// Deletes a new category. + /// Deletes a category. /// /// # Errors /// diff --git a/src/services/mod.rs b/src/services/mod.rs index 79693c9c..a8886af7 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -4,5 +4,6 @@ pub mod authentication; pub mod category; pub mod proxy; pub mod settings; +pub mod tag; pub mod torrent; pub mod user; diff --git a/src/services/tag.rs b/src/services/tag.rs new file mode 100644 index 00000000..b766a14b --- /dev/null +++ b/src/services/tag.rs @@ -0,0 +1,113 @@ +//! Tag service. +use std::sync::Arc; + +use super::user::DbUserRepository; +use crate::databases::database::{Database, Error as DatabaseError, Error}; +use crate::errors::ServiceError; +use crate::models::torrent_tag::{TagId, TorrentTag}; +use crate::models::user::UserId; + +pub struct Service { + tag_repository: Arc, + user_repository: Arc, +} + +impl Service { + #[must_use] + pub fn new(tag_repository: Arc, user_repository: Arc) -> Service { + Service { + tag_repository, + user_repository, + } + } + + /// Adds a new tag. + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is a database error. + pub async fn add_tag(&self, tag_name: &str, user_id: &UserId) -> Result<(), ServiceError> { + let user = self.user_repository.get_compact(user_id).await?; + + // Check if user is administrator + // todo: extract authorization service + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + match self.tag_repository.add(tag_name).await { + Ok(_) => Ok(()), + Err(e) => match e { + DatabaseError::TagAlreadyExists => Err(ServiceError::TagAlreadyExists), + _ => Err(ServiceError::DatabaseError), + }, + } + } + + /// Deletes a tag. + /// + /// # Errors + /// + /// It returns an error if: + /// + /// * The user does not have the required permissions. + /// * There is a database error. + pub async fn delete_tag(&self, tag_id: &TagId, user_id: &UserId) -> Result<(), ServiceError> { + let user = self.user_repository.get_compact(user_id).await?; + + // Check if user is administrator + // todo: extract authorization service + if !user.administrator { + return Err(ServiceError::Unauthorized); + } + + match self.tag_repository.delete(tag_id).await { + Ok(_) => Ok(()), + Err(e) => match e { + DatabaseError::TagNotFound => Err(ServiceError::TagNotFound), + _ => Err(ServiceError::DatabaseError), + }, + } + } +} + +pub struct DbTagRepository { + database: Arc>, +} + +impl DbTagRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It adds a new tag and returns the newly created tag. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn add(&self, tag_name: &str) -> Result<(), Error> { + self.database.add_tag(tag_name).await + } + + /// It returns all the tags. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn get_all(&self) -> Result, Error> { + self.database.get_tags().await + } + + /// It removes a tag and returns it. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn delete(&self, tag_id: &TagId) -> Result<(), Error> { + self.database.delete_tag(*tag_id).await + } +} diff --git a/src/services/torrent.rs b/src/services/torrent.rs index ada162dd..d5bdaa50 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -555,15 +555,6 @@ impl DbTorrentTagRepository { Self { database } } - /// It adds a new tag and returns the newly created tag. - /// - /// # Errors - /// - /// It returns an error if there is a database error. - pub async fn add_tag(&self, tag_name: &str) -> Result<(), Error> { - self.database.add_tag(tag_name).await - } - /// It adds a new torrent tag link. /// /// # Errors @@ -578,19 +569,10 @@ impl DbTorrentTagRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn link_torrent_to_tags(&self, torrent_id: &TorrentId, tag_ids: &Vec) -> Result<(), Error> { + pub async fn link_torrent_to_tags(&self, torrent_id: &TorrentId, tag_ids: &[TagId]) -> Result<(), Error> { self.database.add_torrent_tag_links(*torrent_id, tag_ids).await } - /// It returns all the tags. - /// - /// # Errors - /// - /// It returns an error if there is a database error. - pub async fn get_tags(&self) -> Result, Error> { - self.database.get_tags().await - } - /// It returns all the tags linked to a certain torrent ID. /// /// # Errors @@ -600,15 +582,6 @@ impl DbTorrentTagRepository { self.database.get_tags_for_torrent_id(*torrent_id).await } - /// It removes a tag and returns it. - /// - /// # Errors - /// - /// It returns an error if there is a database error. - pub async fn delete_tag(&self, tag_id: &TagId) -> Result<(), Error> { - self.database.delete_tag(*tag_id).await - } - /// It removes a torrent tag link. /// /// # Errors From b4c43da30f616dcd4055e23616fa23ef829b81a2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 9 Jun 2023 15:31:51 +0100 Subject: [PATCH 206/357] fix: [#189] make 'Development checks' workflow fail if E2E tests fail The workflow "Development checks" is not failing if E2E tests fail. This fixes that problem. --- docker/bin/run-e2e-tests.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh index 211912df..2b0c0812 100755 --- a/docker/bin/run-e2e-tests.sh +++ b/docker/bin/run-e2e-tests.sh @@ -36,13 +36,13 @@ wait_for_container_to_be_healthy() { } # Install tool to create torrent files -cargo install imdl +cargo install imdl || exit 1 -cp .env.local .env -./bin/install.sh +cp .env.local .env || exit 1 +./bin/install.sh || exit 1 # Start E2E testing environment -./docker/bin/e2e-env-up.sh +./docker/bin/e2e-env-up.sh || exit 1 wait_for_container_to_be_healthy torrust-mysql-1 10 3 # todo: implement healthchecks for tracker and backend and wait until they are healthy @@ -54,7 +54,7 @@ sleep 20s docker ps # Run E2E tests with shared app instance -TORRUST_IDX_BACK_E2E_SHARED=true cargo test +TORRUST_IDX_BACK_E2E_SHARED=true cargo test || exit 1 # Stop E2E testing environment ./docker/bin/e2e-env-down.sh From 6023b969f58e083336f3a709d2e247ccf9e524be Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 9 Jun 2023 16:20:37 +0100 Subject: [PATCH 207/357] fix: [#190] SQL error in SQLite The SQL query: ``` SELECT COUNT(*) as count FROM ( SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders), 0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers), 0) as signed) as leechers FROM torrust_torrents tt INNER JOIN torrust_user_profiles tp ON tt.uploader_id = tp.user_id INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE title LIKE ? GROUP BY tt.torrent_id ) AS count_table ``` should not use the `DATE_FORMAT` function in SQLite. --- src/databases/sqlite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 81ad1763..1837e49e 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -361,7 +361,7 @@ impl Database for Sqlite { }; let mut query_string = format!( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt From 79682a52085e2cc20dffc738a9afae03229106b5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jun 2023 16:51:12 +0100 Subject: [PATCH 208/357] refactor(api): [#183] Axum API, user context, registration --- .github/workflows/develop.yml | 2 + src/config.rs | 6 + src/errors.rs | 128 ++++++++++++---------- src/routes/user.rs | 13 +-- src/services/user.rs | 2 +- src/web/api/v1/contexts/about/routes.rs | 16 +-- src/web/api/v1/contexts/user/forms.rs | 9 ++ src/web/api/v1/contexts/user/handlers.rs | 46 ++++++++ src/web/api/v1/contexts/user/mod.rs | 4 + src/web/api/v1/contexts/user/responses.rs | 17 +++ src/web/api/v1/contexts/user/routes.rs | 15 +++ src/web/api/v1/mod.rs | 1 + src/web/api/v1/responses.rs | 25 +++++ src/web/api/v1/routes.rs | 14 +-- tests/common/contexts/user/asserts.rs | 9 ++ tests/common/contexts/user/fixtures.rs | 2 +- tests/common/contexts/user/mod.rs | 1 + tests/common/contexts/user/responses.rs | 10 ++ tests/e2e/config.rs | 3 + tests/e2e/contexts/user/contract.rs | 34 +++++- tests/e2e/contexts/user/steps.rs | 4 +- 21 files changed, 266 insertions(+), 95 deletions(-) create mode 100644 src/web/api/v1/contexts/user/forms.rs create mode 100644 src/web/api/v1/contexts/user/handlers.rs create mode 100644 src/web/api/v1/contexts/user/responses.rs create mode 100644 src/web/api/v1/contexts/user/routes.rs create mode 100644 src/web/api/v1/responses.rs create mode 100644 tests/common/contexts/user/asserts.rs diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 2ee931b2..b0a958d3 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -31,3 +31,5 @@ jobs: run: cargo llvm-cov nextest - name: E2E Tests run: ./docker/bin/run-e2e-tests.sh + env: + TORRUST_IDX_BACK_E2E_EXCLUDE_AXUM_IMPL: "true" diff --git a/src/config.rs b/src/config.rs index 0db50ea9..3901d987 100644 --- a/src/config.rs +++ b/src/config.rs @@ -422,6 +422,12 @@ impl Configuration { settings_lock.website.name.clone() } + + pub async fn get_api_base_url(&self) -> Option { + let settings_lock = self.settings.read().await; + + settings_lock.net.base_url.clone() + } } /// The public backend configuration. diff --git a/src/errors.rs b/src/errors.rs index 668bf3ab..6f880162 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -146,49 +146,7 @@ pub struct ErrorToResponse { impl ResponseError for ServiceError { fn status_code(&self) -> StatusCode { - #[allow(clippy::match_same_arms)] - match self { - ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN, - ServiceError::EmailInvalid => StatusCode::BAD_REQUEST, - ServiceError::NotAUrl => StatusCode::BAD_REQUEST, - ServiceError::WrongPasswordOrUsername => StatusCode::FORBIDDEN, - ServiceError::UsernameNotFound => StatusCode::NOT_FOUND, - ServiceError::UserNotFound => StatusCode::NOT_FOUND, - ServiceError::AccountNotFound => StatusCode::NOT_FOUND, - ServiceError::ProfanityError => StatusCode::BAD_REQUEST, - ServiceError::BlacklistError => StatusCode::BAD_REQUEST, - ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST, - ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST, - ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST, - ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST, - ServiceError::UsernameTaken => StatusCode::BAD_REQUEST, - ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST, - ServiceError::EmailTaken => StatusCode::BAD_REQUEST, - ServiceError::EmailNotVerified => StatusCode::FORBIDDEN, - ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED, - ServiceError::TokenExpired => StatusCode::UNAUTHORIZED, - ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED, - ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST, - ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST, - ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST, - ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, - ServiceError::BadRequest => StatusCode::BAD_REQUEST, - ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, - ServiceError::InvalidTag => StatusCode::BAD_REQUEST, - ServiceError::Unauthorized => StatusCode::FORBIDDEN, - ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, - ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST, - ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, - ServiceError::EmailMissing => StatusCode::NOT_FOUND, - ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR, - ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, - ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, - ServiceError::CategoryNotFound => StatusCode::NOT_FOUND, - ServiceError::TagNotFound => StatusCode::NOT_FOUND, - } + http_status_code_for_service_error(self) } fn error_response(&self) -> HttpResponse { @@ -220,22 +178,7 @@ impl From for ServiceError { impl From for ServiceError { fn from(e: database::Error) -> Self { - #[allow(clippy::match_same_arms)] - match e { - database::Error::Error => ServiceError::InternalServerError, - database::Error::ErrorWithText(_) => ServiceError::InternalServerError, - database::Error::UsernameTaken => ServiceError::UsernameTaken, - database::Error::EmailTaken => ServiceError::EmailTaken, - database::Error::UserNotFound => ServiceError::UserNotFound, - database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists, - database::Error::CategoryNotFound => ServiceError::InvalidCategory, - database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists, - database::Error::TagNotFound => ServiceError::InvalidTag, - database::Error::TorrentNotFound => ServiceError::TorrentNotFound, - database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, - database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists, - database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError, - } + map_database_error_to_service_error(&e) } } @@ -266,3 +209,70 @@ impl From for ServiceError { ServiceError::InternalServerError } } + +#[must_use] +pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { + #[allow(clippy::match_same_arms)] + match error { + ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN, + ServiceError::EmailInvalid => StatusCode::BAD_REQUEST, + ServiceError::NotAUrl => StatusCode::BAD_REQUEST, + ServiceError::WrongPasswordOrUsername => StatusCode::FORBIDDEN, + ServiceError::UsernameNotFound => StatusCode::NOT_FOUND, + ServiceError::UserNotFound => StatusCode::NOT_FOUND, + ServiceError::AccountNotFound => StatusCode::NOT_FOUND, + ServiceError::ProfanityError => StatusCode::BAD_REQUEST, + ServiceError::BlacklistError => StatusCode::BAD_REQUEST, + ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST, + ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST, + ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST, + ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST, + ServiceError::UsernameTaken => StatusCode::BAD_REQUEST, + ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST, + ServiceError::EmailTaken => StatusCode::BAD_REQUEST, + ServiceError::EmailNotVerified => StatusCode::FORBIDDEN, + ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED, + ServiceError::TokenExpired => StatusCode::UNAUTHORIZED, + ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED, + ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST, + ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST, + ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST, + ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, + ServiceError::BadRequest => StatusCode::BAD_REQUEST, + ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, + ServiceError::InvalidTag => StatusCode::BAD_REQUEST, + ServiceError::Unauthorized => StatusCode::FORBIDDEN, + ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::EmailMissing => StatusCode::NOT_FOUND, + ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::CategoryNotFound => StatusCode::NOT_FOUND, + ServiceError::TagNotFound => StatusCode::NOT_FOUND, + } +} + +#[must_use] +pub fn map_database_error_to_service_error(error: &database::Error) -> ServiceError { + #[allow(clippy::match_same_arms)] + match error { + database::Error::Error => ServiceError::InternalServerError, + database::Error::ErrorWithText(_) => ServiceError::InternalServerError, + database::Error::UsernameTaken => ServiceError::UsernameTaken, + database::Error::EmailTaken => ServiceError::EmailTaken, + database::Error::UserNotFound => ServiceError::UserNotFound, + database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists, + database::Error::CategoryNotFound => ServiceError::InvalidCategory, + database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists, + database::Error::TagNotFound => ServiceError::InvalidTag, + database::Error::TorrentNotFound => ServiceError::TorrentNotFound, + database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, + database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists, + database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError, + } +} diff --git a/src/routes/user.rs b/src/routes/user.rs index 5912334a..40030754 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -5,6 +5,7 @@ use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::{OkResponse, TokenResponse}; use crate::routes::API_VERSION; +use crate::web::api::v1::contexts::user::forms::RegistrationForm; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( @@ -26,14 +27,6 @@ pub fn init(cfg: &mut web::ServiceConfig) { ); } -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RegistrationForm { - pub username: String, - pub email: Option, - pub password: String, - pub confirm_password: String, -} - #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Login { pub login: String, @@ -56,8 +49,8 @@ pub async fn registration_handler( app_data: WebAppData, ) -> ServiceResult { let conn_info = req.connection_info().clone(); - // todo: we should add this in the configuration. It does not work is the - // server is behind a reverse proxy. + // todo: check if `base_url` option was define in settings `net->base_url`. + // It should have priority over request headers. let api_base_url = format!("{}://{}", conn_info.scheme(), conn_info.host()); let _user_id = app_data diff --git a/src/services/user.rs b/src/services/user.rs index 10a42b60..a0211546 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -13,8 +13,8 @@ use crate::errors::ServiceError; use crate::mailer; use crate::mailer::VerifyClaims; use crate::models::user::{UserCompact, UserId, UserProfile}; -use crate::routes::user::RegistrationForm; use crate::utils::regex::validate_email_address; +use crate::web::api::v1::contexts::user::forms::RegistrationForm; /// Since user email could be optional, we need a way to represent "no email" /// in the database. This function returns the string that should be used for diff --git a/src/web/api/v1/contexts/about/routes.rs b/src/web/api/v1/contexts/about/routes.rs index da53052c..d3877a3b 100644 --- a/src/web/api/v1/contexts/about/routes.rs +++ b/src/web/api/v1/contexts/about/routes.rs @@ -9,15 +9,9 @@ use axum::Router; use super::handlers::{about_page_handler, license_page_handler}; use crate::common::AppData; -/// It adds the routes to the router for the [`about`](crate::web::api::v1::contexts::about) API context. -pub fn add(prefix: &str, router: Router, app_data: Arc) -> Router { - router - .route( - &format!("{prefix}/about"), - get(about_page_handler).with_state(app_data.clone()), - ) - .route( - &format!("{prefix}/about/license"), - get(license_page_handler).with_state(app_data), - ) +/// Routes for the [`about`](crate::web::api::v1::contexts::about) API context. +pub fn router(app_data: Arc) -> Router { + Router::new() + .route("/", get(about_page_handler).with_state(app_data.clone())) + .route("/license", get(license_page_handler).with_state(app_data)) } diff --git a/src/web/api/v1/contexts/user/forms.rs b/src/web/api/v1/contexts/user/forms.rs new file mode 100644 index 00000000..78a92f06 --- /dev/null +++ b/src/web/api/v1/contexts/user/forms.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RegistrationForm { + pub username: String, + pub email: Option, + pub password: String, + pub confirm_password: String, +} diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs new file mode 100644 index 00000000..9fccff14 --- /dev/null +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -0,0 +1,46 @@ +//! API handlers for the the [`user`](crate::web::api::v1::contexts::user) API +//! context. +use std::sync::Arc; + +use axum::extract::{self, Host, State}; +use axum::Json; + +use super::forms::RegistrationForm; +use super::responses::{self, NewUser}; +use crate::common::AppData; +use crate::errors::ServiceError; +use crate::web::api::v1::responses::OkResponse; + +/// It handles the registration of a new user. +/// +/// # Errors +/// +/// It returns an error if the user could not be registered. +#[allow(clippy::unused_async)] +pub async fn registration_handler( + State(app_data): State>, + Host(host_from_header): Host, + extract::Json(registration_form): extract::Json, +) -> Result>, ServiceError> { + let api_base_url = app_data + .cfg + .get_api_base_url() + .await + .unwrap_or(api_base_url(&host_from_header)); + + match app_data + .registration_service + .register_user(®istration_form, &api_base_url) + .await + { + Ok(user_id) => Ok(responses::added_user(user_id)), + Err(error) => Err(error), + } +} + +/// It returns the base API URL without the port. For example: `http://localhost`. +fn api_base_url(host: &str) -> String { + // HTTPS is not supported yet. + // See https://github.com/torrust/torrust-index-backend/issues/131 + format!("http://{host}") +} diff --git a/src/web/api/v1/contexts/user/mod.rs b/src/web/api/v1/contexts/user/mod.rs index c7974a9c..3a4267c0 100644 --- a/src/web/api/v1/contexts/user/mod.rs +++ b/src/web/api/v1/contexts/user/mod.rs @@ -243,3 +243,7 @@ //! **WARNING**: The admin can ban themselves. If they do, they will not be able //! to unban themselves. The only way to unban themselves is to manually remove //! the user from the banned user list in the database. +pub mod forms; +pub mod handlers; +pub mod responses; +pub mod routes; diff --git a/src/web/api/v1/contexts/user/responses.rs b/src/web/api/v1/contexts/user/responses.rs new file mode 100644 index 00000000..79f5b14f --- /dev/null +++ b/src/web/api/v1/contexts/user/responses.rs @@ -0,0 +1,17 @@ +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::models::user::UserId; +use crate::web::api::v1::responses::OkResponse; + +#[derive(Serialize, Deserialize, Debug)] +pub struct NewUser { + pub user_id: UserId, +} + +/// Response after successfully creating a new user. +pub fn added_user(user_id: i64) -> Json> { + Json(OkResponse { + data: NewUser { user_id }, + }) +} diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/v1/contexts/user/routes.rs new file mode 100644 index 00000000..a517f1d2 --- /dev/null +++ b/src/web/api/v1/contexts/user/routes.rs @@ -0,0 +1,15 @@ +//! API routes for the [`user`](crate::web::api::v1::contexts::user) API context. +//! +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::user). +use std::sync::Arc; + +use axum::routing::post; +use axum::Router; + +use super::handlers::registration_handler; +use crate::common::AppData; + +/// Routes for the [`user`](crate::web::api::v1::contexts::user) API context. +pub fn router(app_data: Arc) -> Router { + Router::new().route("/register", post(registration_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/mod.rs b/src/web/api/v1/mod.rs index 9d94e076..67490e6e 100644 --- a/src/web/api/v1/mod.rs +++ b/src/web/api/v1/mod.rs @@ -6,4 +6,5 @@ //! information. pub mod auth; pub mod contexts; +pub mod responses; pub mod routes; diff --git a/src/web/api/v1/responses.rs b/src/web/api/v1/responses.rs new file mode 100644 index 00000000..de9701c0 --- /dev/null +++ b/src/web/api/v1/responses.rs @@ -0,0 +1,25 @@ +//! Generic responses for the API. +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +use crate::databases::database; +use crate::errors::{http_status_code_for_service_error, map_database_error_to_service_error, ServiceError}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct OkResponse { + pub data: T, +} + +impl IntoResponse for database::Error { + fn into_response(self) -> Response { + let service_error = map_database_error_to_service_error(&self); + + (http_status_code_for_service_error(&service_error), service_error.to_string()).into_response() + } +} + +impl IntoResponse for ServiceError { + fn into_response(self) -> Response { + (http_status_code_for_service_error(&self), self.to_string()).into_response() + } +} diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index c980bd1e..1787e3bb 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -3,20 +3,16 @@ use std::sync::Arc; use axum::Router; -use super::contexts::about; +use super::contexts::{about, user}; use crate::common::AppData; /// Add all API routes to the router. #[allow(clippy::needless_pass_by_value)] pub fn router(app_data: Arc) -> Router { - let router = Router::new(); + let user_routes = user::routes::router(app_data.clone()); + let about_routes = about::routes::router(app_data); - add(router, app_data) -} - -/// Add the routes for the v1 API. -fn add(router: Router, app_data: Arc) -> Router { - let v1_prefix = "/v1".to_string(); + let api_routes = Router::new().nest("/user", user_routes).nest("/about", about_routes); - about::routes::add(&v1_prefix, router, app_data) + Router::new().nest("/v1", api_routes) } diff --git a/tests/common/contexts/user/asserts.rs b/tests/common/contexts/user/asserts.rs new file mode 100644 index 00000000..c366a577 --- /dev/null +++ b/tests/common/contexts/user/asserts.rs @@ -0,0 +1,9 @@ +use crate::common::asserts::assert_json_ok; +use crate::common::contexts::user::responses::AddedUserResponse; +use crate::common::responses::TextResponse; + +pub fn assert_added_user_response(response: &TextResponse) { + let _added_user_response: AddedUserResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a AddedUserResponse", response.body)); + assert_json_ok(response); +} diff --git a/tests/common/contexts/user/fixtures.rs b/tests/common/contexts/user/fixtures.rs index 3eda8502..fea39e7f 100644 --- a/tests/common/contexts/user/fixtures.rs +++ b/tests/common/contexts/user/fixtures.rs @@ -2,7 +2,7 @@ use rand::Rng; use crate::common::contexts::user::forms::RegistrationForm; -pub fn random_user_registration() -> RegistrationForm { +pub fn random_user_registration_form() -> RegistrationForm { let user_id = random_user_id(); RegistrationForm { username: format!("username_{user_id}"), diff --git a/tests/common/contexts/user/mod.rs b/tests/common/contexts/user/mod.rs index 6f27f51d..cfe5dd24 100644 --- a/tests/common/contexts/user/mod.rs +++ b/tests/common/contexts/user/mod.rs @@ -1,3 +1,4 @@ +pub mod asserts; pub mod fixtures; pub mod forms; pub mod responses; diff --git a/tests/common/contexts/user/responses.rs b/tests/common/contexts/user/responses.rs index 8f6e84b2..1a9a3837 100644 --- a/tests/common/contexts/user/responses.rs +++ b/tests/common/contexts/user/responses.rs @@ -1,5 +1,15 @@ use serde::Deserialize; +#[derive(Deserialize, Debug)] +pub struct AddedUserResponse { + pub data: NewUserData, +} + +#[derive(Deserialize, Debug)] +pub struct NewUserData { + pub user_id: i64, +} + #[derive(Deserialize, Debug)] pub struct SuccessfulLoginResponse { pub data: LoggedInUserData, diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index f3179f43..abf056fd 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -14,6 +14,9 @@ pub const ENV_VAR_E2E_SHARED: &str = "TORRUST_IDX_BACK_E2E_SHARED"; /// The whole `config.toml` file content. It has priority over the config file. pub const ENV_VAR_E2E_CONFIG: &str = "TORRUST_IDX_BACK_E2E_CONFIG"; +/// If present, E2E tests for new Axum implementation will not be executed +pub const ENV_VAR_E2E_EXCLUDE_AXUM_IMPL: &str = "TORRUST_IDX_BACK_E2E_EXCLUDE_AXUM_IMPL"; + // Default values pub const ENV_VAR_E2E_DEFAULT_CONFIG_PATH: &str = "./config-idx-back.local.toml"; diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 24abc0de..c436d2c3 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -2,7 +2,7 @@ use torrust_index_backend::web::api; use crate::common::client::Client; -use crate::common::contexts::user::fixtures::random_user_registration; +use crate::common::contexts::user::fixtures::random_user_registration_form; use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; use crate::common::contexts::user::responses::{ SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, @@ -44,7 +44,7 @@ async fn it_should_allow_a_guest_user_to_register() { env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let form = random_user_registration(); + let form = random_user_registration_form(); let response = client.register_user(form).await; @@ -191,3 +191,33 @@ mod banned_user_list { assert_eq!(response.status, 401); } } + +mod with_axum_implementation { + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::user::asserts::assert_added_user_response; + use crate::common::contexts::user::fixtures::random_user_registration_form; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_a_guest_user_to_register() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let form = random_user_registration_form(); + + let response = client.register_user(form).await; + + assert_added_user_response(&response); + } +} diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/contexts/user/steps.rs index c58a5c59..f4892325 100644 --- a/tests/e2e/contexts/user/steps.rs +++ b/tests/e2e/contexts/user/steps.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use torrust_index_backend::databases::database; use crate::common::client::Client; -use crate::common::contexts::user::fixtures::random_user_registration; +use crate::common::contexts::user::fixtures::random_user_registration_form; use crate::common::contexts::user::forms::{LoginForm, RegisteredUser}; use crate::common::contexts::user::responses::{LoggedInUserData, SuccessfulLoginResponse}; use crate::e2e::environment::TestEnv; @@ -64,7 +64,7 @@ pub async fn new_logged_in_user(env: &TestEnv) -> LoggedInUserData { pub async fn new_registered_user(env: &TestEnv) -> RegisteredUser { let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let form = random_user_registration(); + let form = random_user_registration_form(); let registered_user = form.clone(); From 91522f4ea45239b37dceb5e80efda0667dd1d8b8 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jun 2023 18:53:48 +0100 Subject: [PATCH 209/357] feat: add cargo dependency tower-http With the new Axum implementation for the API the browser does not make request to the API becuase of the Cross-Origin Resource Sharing (CORS) policy. By default there is no header `Access-Control-Allow-Origin` and it does not allow the request. You can be permissive by adding a layer to the router: ``` Router::new().nest("/v1", api_routes).layer(CorsLayer::permissive()) ``` For the time being you need to change that line manually when you want to setup a dev env with the banckend and the frontend using different ports. --- Cargo.lock | 25 +++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 26 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e0bfde80..f1cbc4cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" + [[package]] name = "httparse" version = "1.8.0" @@ -3138,6 +3144,7 @@ dependencies = [ "thiserror", "tokio", "toml 0.7.3", + "tower-http", "urlencoding", "uuid", "which", @@ -3159,6 +3166,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index f18e02bf..63fde00c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ thiserror = "1.0" binascii = "0.1" axum = "0.6.18" hyper = "0.14.26" +tower-http = { version = "0.4.0", features = ["cors"]} [dev-dependencies] rand = "0.8" From a341e3814208babcc842c1d875045c61fd6b428d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jun 2023 19:23:38 +0100 Subject: [PATCH 210/357] refactor(api): [#183] Axum API, user context, email verification --- src/web/api/v1/contexts/user/handlers.rs | 15 ++++++++++++++- src/web/api/v1/contexts/user/routes.rs | 8 +++++--- src/web/api/v1/routes.rs | 7 +++++++ tests/e2e/contexts/user/contract.rs | 1 + 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index 9fccff14..da72c053 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -2,8 +2,9 @@ //! context. use std::sync::Arc; -use axum::extract::{self, Host, State}; +use axum::extract::{self, Host, Path, State}; use axum::Json; +use serde::Deserialize; use super::forms::RegistrationForm; use super::responses::{self, NewUser}; @@ -38,6 +39,18 @@ pub async fn registration_handler( } } +#[derive(Deserialize)] +pub struct TokenParam(String); + +/// It handles the verification of the email verification token. +#[allow(clippy::unused_async)] +pub async fn email_verification_handler(State(app_data): State>, Path(token): Path) -> String { + match app_data.registration_service.verify_email(&token.0).await { + Ok(_) => String::from("Email verified, you can close this page."), + Err(error) => error.to_string(), + } +} + /// It returns the base API URL without the port. For example: `http://localhost`. fn api_base_url(host: &str) -> String { // HTTPS is not supported yet. diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/v1/contexts/user/routes.rs index a517f1d2..bc4723bc 100644 --- a/src/web/api/v1/contexts/user/routes.rs +++ b/src/web/api/v1/contexts/user/routes.rs @@ -3,13 +3,15 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::user). use std::sync::Arc; -use axum::routing::post; +use axum::routing::{get, post}; use axum::Router; -use super::handlers::registration_handler; +use super::handlers::{email_verification_handler, registration_handler}; use crate::common::AppData; /// Routes for the [`user`](crate::web::api::v1::contexts::user) API context. pub fn router(app_data: Arc) -> Router { - Router::new().route("/register", post(registration_handler).with_state(app_data)) + Router::new() + .route("/register", post(registration_handler).with_state(app_data.clone())) + .route("/email/verify/:token", get(email_verification_handler).with_state(app_data)) } diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 1787e3bb..79185bf6 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use axum::Router; +//use tower_http::cors::CorsLayer; use super::contexts::{about, user}; use crate::common::AppData; @@ -14,5 +15,11 @@ pub fn router(app_data: Arc) -> Router { let api_routes = Router::new().nest("/user", user_routes).nest("/about", about_routes); + // For development purposes only. + // It allows calling the API on a different port. For example + // API: http://localhost:3000/v1 + // Webapp: http://localhost:8080 + //Router::new().nest("/v1", api_routes).layer(CorsLayer::permissive()) + Router::new().nest("/v1", api_routes) } diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index c436d2c3..9fa767be 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -209,6 +209,7 @@ mod with_axum_implementation { env.start(api::Implementation::Axum).await; if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); return; } From 3f639b36cdc76e17216287f5cadd268c99dcae12 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jun 2023 19:53:21 +0100 Subject: [PATCH 211/357] refactor(api): [#183] Axum API, user context, login --- src/routes/user.rs | 2 +- src/web/api/v1/contexts/user/forms.rs | 10 +++ src/web/api/v1/contexts/user/handlers.rs | 28 +++++++- src/web/api/v1/contexts/user/responses.rs | 24 ++++++- src/web/api/v1/contexts/user/routes.rs | 14 +++- tests/e2e/contexts/user/contract.rs | 83 ++++++++++++++++++----- 6 files changed, 137 insertions(+), 24 deletions(-) diff --git a/src/routes/user.rs b/src/routes/user.rs index 40030754..5daa8a67 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -50,7 +50,7 @@ pub async fn registration_handler( ) -> ServiceResult { let conn_info = req.connection_info().clone(); // todo: check if `base_url` option was define in settings `net->base_url`. - // It should have priority over request headers. + // It should have priority over request he let api_base_url = format!("{}://{}", conn_info.scheme(), conn_info.host()); let _user_id = app_data diff --git a/src/web/api/v1/contexts/user/forms.rs b/src/web/api/v1/contexts/user/forms.rs index 78a92f06..c7c41cdd 100644 --- a/src/web/api/v1/contexts/user/forms.rs +++ b/src/web/api/v1/contexts/user/forms.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +// Registration + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct RegistrationForm { pub username: String, @@ -7,3 +9,11 @@ pub struct RegistrationForm { pub password: String, pub confirm_password: String, } + +// Authentication + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LoginForm { + pub login: String, // todo: rename to `username` after finishing Axum API migration. + pub password: String, +} diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index da72c053..4bc3c656 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -6,12 +6,14 @@ use axum::extract::{self, Host, Path, State}; use axum::Json; use serde::Deserialize; -use super::forms::RegistrationForm; -use super::responses::{self, NewUser}; +use super::forms::{LoginForm, RegistrationForm}; +use super::responses::{self, NewUser, TokenResponse}; use crate::common::AppData; use crate::errors::ServiceError; use crate::web::api::v1::responses::OkResponse; +// Registration + /// It handles the registration of a new user. /// /// # Errors @@ -51,6 +53,28 @@ pub async fn email_verification_handler(State(app_data): State>, Pa } } +// Authentication + +/// It handles the user login. +/// +/// # Errors +/// +/// It returns an error if the user could not be registered. +#[allow(clippy::unused_async)] +pub async fn login_handler( + State(app_data): State>, + extract::Json(login_form): extract::Json, +) -> Result>, ServiceError> { + match app_data + .authentication_service + .login(&login_form.login, &login_form.password) + .await + { + Ok((token, user_compact)) => Ok(responses::logged_in_user(token, user_compact)), + Err(error) => Err(error), + } +} + /// It returns the base API URL without the port. For example: `http://localhost`. fn api_base_url(host: &str) -> String { // HTTPS is not supported yet. diff --git a/src/web/api/v1/contexts/user/responses.rs b/src/web/api/v1/contexts/user/responses.rs index 79f5b14f..fa3d8359 100644 --- a/src/web/api/v1/contexts/user/responses.rs +++ b/src/web/api/v1/contexts/user/responses.rs @@ -1,9 +1,11 @@ use axum::Json; use serde::{Deserialize, Serialize}; -use crate::models::user::UserId; +use crate::models::user::{UserCompact, UserId}; use crate::web::api::v1::responses::OkResponse; +// Registration + #[derive(Serialize, Deserialize, Debug)] pub struct NewUser { pub user_id: UserId, @@ -15,3 +17,23 @@ pub fn added_user(user_id: i64) -> Json> { data: NewUser { user_id }, }) } + +// Authentication + +#[derive(Serialize, Deserialize, Debug)] +pub struct TokenResponse { + pub token: String, + pub username: String, + pub admin: bool, +} + +/// Response after successfully log in a user. +pub fn logged_in_user(token: String, user_compact: UserCompact) -> Json> { + Json(OkResponse { + data: TokenResponse { + token, + username: user_compact.username, + admin: user_compact.administrator, + }, + }) +} diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/v1/contexts/user/routes.rs index bc4723bc..89365cd4 100644 --- a/src/web/api/v1/contexts/user/routes.rs +++ b/src/web/api/v1/contexts/user/routes.rs @@ -6,12 +6,22 @@ use std::sync::Arc; use axum::routing::{get, post}; use axum::Router; -use super::handlers::{email_verification_handler, registration_handler}; +use super::handlers::{email_verification_handler, login_handler, registration_handler}; use crate::common::AppData; /// Routes for the [`user`](crate::web::api::v1::contexts::user) API context. pub fn router(app_data: Arc) -> Router { Router::new() + // Registration .route("/register", post(registration_handler).with_state(app_data.clone())) - .route("/email/verify/:token", get(email_verification_handler).with_state(app_data)) + // code-review: should this be part of the REST API? + // - This endpoint should only verify the email. + // - There should be an independent service (web app) serving the email verification page. + // The wep app can user this endpoint to verify the email and render the page accordingly. + .route( + "/email/verify/:token", + get(email_verification_handler).with_state(app_data.clone()), + ) + // Authentication + .route("/login", post(login_handler).with_state(app_data)) } diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 9fa767be..50b8e08e 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -193,32 +193,79 @@ mod banned_user_list { } mod with_axum_implementation { - use std::env; - use torrust_index_backend::web::api; + mod registration { + use std::env; - use crate::common::client::Client; - use crate::common::contexts::user::asserts::assert_added_user_response; - use crate::common::contexts::user::fixtures::random_user_registration_form; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; - use crate::e2e::environment::TestEnv; + use torrust_index_backend::web::api; - #[tokio::test] - async fn it_should_allow_a_guest_user_to_register() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; + use crate::common::client::Client; + use crate::common::contexts::user::asserts::assert_added_user_response; + use crate::common::contexts::user::fixtures::random_user_registration_form; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_a_guest_user_to_register() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let form = random_user_registration_form(); + + let response = client.register_user(form).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; + assert_added_user_response(&response); } + } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + mod authentication { + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::user::forms::LoginForm; + use crate::common::contexts::user::responses::SuccessfulLoginResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::user::steps::new_registered_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_a_registered_user_to_login() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; - let form = random_user_registration_form(); + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } - let response = client.register_user(form).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - assert_added_user_response(&response); + let registered_user = new_registered_user(&env).await; + + let response = client + .login_user(LoginForm { + login: registered_user.username.clone(), + password: registered_user.password.clone(), + }) + .await; + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + let logged_in_user = res.data; + + assert_eq!(logged_in_user.username, registered_user.username); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); + } } } From b15616c1b8dfe22d62997d66b95d15f2a41a927d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jun 2023 14:52:56 +0100 Subject: [PATCH 212/357] refactor(api): [#183] Axum API, user context, verify JWT --- src/web/api/v1/contexts/user/forms.rs | 7 +++++- src/web/api/v1/contexts/user/handlers.rs | 23 +++++++++++++++++- src/web/api/v1/contexts/user/routes.rs | 5 ++-- tests/common/contexts/user/asserts.rs | 23 +++++++++++++++++- tests/e2e/contexts/user/contract.rs | 30 ++++++++++++++++-------- 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/web/api/v1/contexts/user/forms.rs b/src/web/api/v1/contexts/user/forms.rs index c7c41cdd..6365c4da 100644 --- a/src/web/api/v1/contexts/user/forms.rs +++ b/src/web/api/v1/contexts/user/forms.rs @@ -14,6 +14,11 @@ pub struct RegistrationForm { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct LoginForm { - pub login: String, // todo: rename to `username` after finishing Axum API migration. + pub login: String, // todo: rename to `username` pub password: String, } + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct JsonWebToken { + pub token: String, // // todo: rename to `encoded` or `value` +} diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index 4bc3c656..e167458e 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -6,7 +6,7 @@ use axum::extract::{self, Host, Path, State}; use axum::Json; use serde::Deserialize; -use super::forms::{LoginForm, RegistrationForm}; +use super::forms::{JsonWebToken, LoginForm, RegistrationForm}; use super::responses::{self, NewUser, TokenResponse}; use crate::common::AppData; use crate::errors::ServiceError; @@ -75,6 +75,27 @@ pub async fn login_handler( } } +/// It verifies a supplied JWT. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - Unable to verify the supplied payload as a valid JWT. +/// - The JWT is not invalid or expired. +#[allow(clippy::unused_async)] +pub async fn verify_token_handler( + State(app_data): State>, + extract::Json(token): extract::Json, +) -> Result>, ServiceError> { + match app_data.json_web_token.verify(&token.token).await { + Ok(_) => Ok(axum::Json(OkResponse { + data: "Token is valid.".to_string(), + })), + Err(error) => Err(error), + } +} + /// It returns the base API URL without the port. For example: `http://localhost`. fn api_base_url(host: &str) -> String { // HTTPS is not supported yet. diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/v1/contexts/user/routes.rs index 89365cd4..898dd710 100644 --- a/src/web/api/v1/contexts/user/routes.rs +++ b/src/web/api/v1/contexts/user/routes.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use axum::routing::{get, post}; use axum::Router; -use super::handlers::{email_verification_handler, login_handler, registration_handler}; +use super::handlers::{email_verification_handler, login_handler, registration_handler, verify_token_handler}; use crate::common::AppData; /// Routes for the [`user`](crate::web::api::v1::contexts::user) API context. @@ -23,5 +23,6 @@ pub fn router(app_data: Arc) -> Router { get(email_verification_handler).with_state(app_data.clone()), ) // Authentication - .route("/login", post(login_handler).with_state(app_data)) + .route("/login", post(login_handler).with_state(app_data.clone())) + .route("/token/verify", post(verify_token_handler).with_state(app_data)) } diff --git a/tests/common/contexts/user/asserts.rs b/tests/common/contexts/user/asserts.rs index c366a577..eb1a26cd 100644 --- a/tests/common/contexts/user/asserts.rs +++ b/tests/common/contexts/user/asserts.rs @@ -1,5 +1,6 @@ +use super::forms::RegistrationForm; use crate::common::asserts::assert_json_ok; -use crate::common::contexts::user::responses::AddedUserResponse; +use crate::common::contexts::user::responses::{AddedUserResponse, SuccessfulLoginResponse, TokenVerifiedResponse}; use crate::common::responses::TextResponse; pub fn assert_added_user_response(response: &TextResponse) { @@ -7,3 +8,23 @@ pub fn assert_added_user_response(response: &TextResponse) { .unwrap_or_else(|_| panic!("response {:#?} should be a AddedUserResponse", response.body)); assert_json_ok(response); } + +pub fn assert_successful_login_response(response: &TextResponse, registered_user: &RegistrationForm) { + let successful_login_response: SuccessfulLoginResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a SuccessfulLoginResponse", response.body)); + + let logged_in_user = successful_login_response.data; + + assert_eq!(logged_in_user.username, registered_user.username); + + assert_json_ok(response); +} + +pub fn assert_token_verified_response(response: &TextResponse) { + let token_verified_response: TokenVerifiedResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a TokenVerifiedResponse", response.body)); + + assert_eq!(token_verified_response.data, "Token is valid."); + + assert_json_ok(response); +} diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 50b8e08e..7b381a4d 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -231,10 +231,10 @@ mod with_axum_implementation { use torrust_index_backend::web::api; use crate::common::client::Client; - use crate::common::contexts::user::forms::LoginForm; - use crate::common::contexts::user::responses::SuccessfulLoginResponse; + use crate::common::contexts::user::asserts::{assert_successful_login_response, assert_token_verified_response}; + use crate::common::contexts::user::forms::{LoginForm, TokenVerificationForm}; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; - use crate::e2e::contexts::user::steps::new_registered_user; + use crate::e2e::contexts::user::steps::{new_logged_in_user, new_registered_user}; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -258,14 +258,24 @@ mod with_axum_implementation { }) .await; - let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); - let logged_in_user = res.data; + assert_successful_login_response(&response, ®istered_user); + } - assert_eq!(logged_in_user.username, registered_user.username); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); + #[tokio::test] + async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let logged_in_user = new_logged_in_user(&env).await; + + let response = client + .verify_token(TokenVerificationForm { + token: logged_in_user.token.clone(), + }) + .await; + + assert_token_verified_response(&response); } } } From 9564dec72203bca0de921b86a63f6bcd52e9b8f0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jun 2023 15:18:16 +0100 Subject: [PATCH 213/357] refactor(api): [#183] Axum API, user context, renew JWT --- src/web/api/v1/contexts/user/handlers.rs | 24 ++++++++++++++- src/web/api/v1/contexts/user/responses.rs | 13 +++++++- src/web/api/v1/contexts/user/routes.rs | 7 +++-- tests/common/contexts/user/asserts.rs | 21 ++++++++++++- tests/e2e/contexts/user/contract.rs | 36 +++++++++++++++++++++-- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index e167458e..4d10023b 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -59,7 +59,10 @@ pub async fn email_verification_handler(State(app_data): State>, Pa /// /// # Errors /// -/// It returns an error if the user could not be registered. +/// It returns an error if: +/// +/// - Unable to verify the supplied payload as a valid JWT. +/// - The JWT is not invalid or expired. #[allow(clippy::unused_async)] pub async fn login_handler( State(app_data): State>, @@ -96,6 +99,25 @@ pub async fn verify_token_handler( } } +/// It renews the JWT. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - Unable to parse the supplied payload as a valid JWT. +/// - The JWT is not invalid or expired. +#[allow(clippy::unused_async)] +pub async fn renew_token_handler( + State(app_data): State>, + extract::Json(token): extract::Json, +) -> Result>, ServiceError> { + match app_data.authentication_service.renew_token(&token.token).await { + Ok((token, user_compact)) => Ok(responses::renewed_token(token, user_compact)), + Err(error) => Err(error), + } +} + /// It returns the base API URL without the port. For example: `http://localhost`. fn api_base_url(host: &str) -> String { // HTTPS is not supported yet. diff --git a/src/web/api/v1/contexts/user/responses.rs b/src/web/api/v1/contexts/user/responses.rs index fa3d8359..731db068 100644 --- a/src/web/api/v1/contexts/user/responses.rs +++ b/src/web/api/v1/contexts/user/responses.rs @@ -27,7 +27,7 @@ pub struct TokenResponse { pub admin: bool, } -/// Response after successfully log in a user. +/// Response after successfully logging in a user. pub fn logged_in_user(token: String, user_compact: UserCompact) -> Json> { Json(OkResponse { data: TokenResponse { @@ -37,3 +37,14 @@ pub fn logged_in_user(token: String, user_compact: UserCompact) -> Json Json> { + Json(OkResponse { + data: TokenResponse { + token, + username: user_compact.username, + admin: user_compact.administrator, + }, + }) +} diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/v1/contexts/user/routes.rs index 898dd710..22eefa0e 100644 --- a/src/web/api/v1/contexts/user/routes.rs +++ b/src/web/api/v1/contexts/user/routes.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use axum::routing::{get, post}; use axum::Router; -use super::handlers::{email_verification_handler, login_handler, registration_handler, verify_token_handler}; +use super::handlers::{ + email_verification_handler, login_handler, registration_handler, renew_token_handler, verify_token_handler, +}; use crate::common::AppData; /// Routes for the [`user`](crate::web::api::v1::contexts::user) API context. @@ -24,5 +26,6 @@ pub fn router(app_data: Arc) -> Router { ) // Authentication .route("/login", post(login_handler).with_state(app_data.clone())) - .route("/token/verify", post(verify_token_handler).with_state(app_data)) + .route("/token/verify", post(verify_token_handler).with_state(app_data.clone())) + .route("/token/renew", post(renew_token_handler).with_state(app_data)) } diff --git a/tests/common/contexts/user/asserts.rs b/tests/common/contexts/user/asserts.rs index eb1a26cd..620096c3 100644 --- a/tests/common/contexts/user/asserts.rs +++ b/tests/common/contexts/user/asserts.rs @@ -1,6 +1,9 @@ use super::forms::RegistrationForm; +use super::responses::LoggedInUserData; use crate::common::asserts::assert_json_ok; -use crate::common::contexts::user::responses::{AddedUserResponse, SuccessfulLoginResponse, TokenVerifiedResponse}; +use crate::common::contexts::user::responses::{ + AddedUserResponse, SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, +}; use crate::common::responses::TextResponse; pub fn assert_added_user_response(response: &TextResponse) { @@ -28,3 +31,19 @@ pub fn assert_token_verified_response(response: &TextResponse) { assert_json_ok(response); } + +pub fn assert_token_renewal_response(response: &TextResponse, logged_in_user: &LoggedInUserData) { + let token_renewal_response: TokenRenewalResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a TokenRenewalResponse", response.body)); + + assert_eq!( + token_renewal_response.data, + TokenRenewalData { + token: logged_in_user.token.clone(), + username: logged_in_user.username.clone(), + admin: logged_in_user.admin, + } + ); + + assert_json_ok(response); +} diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 7b381a4d..cb53d879 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -231,8 +231,10 @@ mod with_axum_implementation { use torrust_index_backend::web::api; use crate::common::client::Client; - use crate::common::contexts::user::asserts::{assert_successful_login_response, assert_token_verified_response}; - use crate::common::contexts::user::forms::{LoginForm, TokenVerificationForm}; + use crate::common::contexts::user::asserts::{ + assert_successful_login_response, assert_token_renewal_response, assert_token_verified_response, + }; + use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::user::steps::{new_logged_in_user, new_registered_user}; use crate::e2e::environment::TestEnv; @@ -265,6 +267,12 @@ mod with_axum_implementation { async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let logged_in_user = new_logged_in_user(&env).await; @@ -277,5 +285,29 @@ mod with_axum_implementation { assert_token_verified_response(&response); } + + #[tokio::test] + async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week( + ) { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_user = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); + + let response = client + .renew_token(TokenRenewalForm { + token: logged_in_user.token.clone(), + }) + .await; + + assert_token_renewal_response(&response, &logged_in_user); + } } } From d3b5b1598af95ec9cc7c87e7eda4fca033164c06 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jun 2023 16:36:16 +0100 Subject: [PATCH 214/357] refactor(api): [#183] Axum API, user context, ban user --- src/auth.rs | 56 ++++++++++++++--- src/routes/category.rs | 4 +- src/routes/proxy.rs | 2 +- src/routes/settings.rs | 4 +- src/routes/tag.rs | 4 +- src/routes/torrent.rs | 10 +-- src/routes/user.rs | 2 +- src/web/api/v1/auth.rs | 13 ++++ src/web/api/v1/contexts/user/handlers.rs | 30 +++++++++ src/web/api/v1/contexts/user/routes.rs | 9 ++- src/web/api/v1/extractors/bearer_token.rs | 36 +++++++++++ src/web/api/v1/extractors/mod.rs | 1 + src/web/api/v1/mod.rs | 1 + tests/common/contexts/user/asserts.rs | 14 ++++- tests/e2e/contexts/user/contract.rs | 74 +++++++++++++++++++++++ 15 files changed, 236 insertions(+), 24 deletions(-) create mode 100644 src/web/api/v1/extractors/bearer_token.rs create mode 100644 src/web/api/v1/extractors/mod.rs diff --git a/src/auth.rs b/src/auth.rs index 722cf2f1..e782fd59 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,6 +5,12 @@ use actix_web::HttpRequest; use crate::errors::ServiceError; use crate::models::user::{UserClaims, UserCompact, UserId}; use crate::services::authentication::JsonWebToken; +use crate::web::api::v1::extractors::bearer_token::BearerToken; + +// todo: refactor this after finishing migration to Axum. +// - Extract service to handle Json Web Tokens: `new`, `sign_jwt`, `verify_jwt`. +// - Move the rest to `src/web/api/v1/auth.rs`. It's a helper for Axum handlers +// to get user id from request. pub struct Authentication { json_web_token: Arc, @@ -30,13 +36,25 @@ impl Authentication { self.json_web_token.verify(token).await } - /// Get Claims from Request + // Begin ActixWeb + + /// Get User id from `ActixWeb` Request + /// + /// # Errors + /// + /// This function will return an error if it can get claims from the request + pub async fn get_user_id_from_actix_web_request(&self, req: &HttpRequest) -> Result { + let claims = self.get_claims_from_actix_web_request(req).await?; + Ok(claims.user.user_id) + } + + /// Get Claims from `ActixWeb` Request /// /// # Errors /// - /// This function will return an `ServiceError::TokenNotFound` if `HeaderValue` is `None` - /// This function will pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. - pub async fn get_claims_from_request(&self, req: &HttpRequest) -> Result { + /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. + /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. + async fn get_claims_from_actix_web_request(&self, req: &HttpRequest) -> Result { match req.headers().get("Authorization") { Some(auth) => { let split: Vec<&str> = auth @@ -55,13 +73,37 @@ impl Authentication { } } - /// Get User id from Request + // End ActixWeb + + // Begin Axum + + /// Get User id from bearer token /// /// # Errors /// /// This function will return an error if it can get claims from the request - pub async fn get_user_id_from_request(&self, req: &HttpRequest) -> Result { - let claims = self.get_claims_from_request(req).await?; + pub async fn get_user_id_from_bearer_token(&self, maybe_token: &Option) -> Result { + let claims = self.get_claims_from_bearer_token(maybe_token).await?; Ok(claims.user.user_id) } + + /// Get Claims from bearer token + /// + /// # Errors + /// + /// This function will: + /// + /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. + /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. + async fn get_claims_from_bearer_token(&self, maybe_token: &Option) -> Result { + match maybe_token { + Some(token) => match self.verify_jwt(&token.value()).await { + Ok(claims) => Ok(claims), + Err(e) => Err(e), + }, + None => Err(ServiceError::TokenNotFound), + } + } + + // End Axum } diff --git a/src/routes/category.rs b/src/routes/category.rs index f087d2b8..30d3643a 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -41,7 +41,7 @@ pub struct Category { /// This function will return an error if unable to get user. /// This function will return an error if unable to insert into the database the new category. pub async fn add(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let _category_id = app_data.category_service.add_category(&payload.name, &user_id).await?; @@ -61,7 +61,7 @@ pub async fn delete(req: HttpRequest, payload: web::Json, app_data: We // And we should use the ID instead of the name, because the name could change // or we could add support for multiple languages. - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; app_data.category_service.delete_category(&payload.name, &user_id).await?; diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs index c985b53e..0ff99d08 100644 --- a/src/routes/proxy.rs +++ b/src/routes/proxy.rs @@ -21,7 +21,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// /// This function will return `Ok` only for now. pub async fn get_proxy_image(req: HttpRequest, app_data: WebAppData, path: web::Path) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); match user_id { Some(user_id) => { diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 806426b8..378a05dd 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -25,7 +25,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// /// This function will return an error if unable to get user from database. pub async fn get_all_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let all_settings = app_data.settings_service.get_all(&user_id).await?; @@ -46,7 +46,7 @@ pub async fn update_handler( payload: web::Json, app_data: WebAppData, ) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let new_settings = app_data.settings_service.update_all(payload.into_inner(), &user_id).await?; diff --git a/src/routes/tag.rs b/src/routes/tag.rs index b7de7b16..fb8f51bf 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -44,7 +44,7 @@ pub struct Create { /// * Get the compact user from the user id. /// * Add the new tag to the database. pub async fn create(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; app_data.tag_service.add_tag(&payload.name, &user_id).await?; @@ -68,7 +68,7 @@ pub struct Delete { /// * Get the compact user from the user id. /// * Delete the tag from the database. pub async fn delete(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; app_data.tag_service.delete_tag(&payload.tag_id, &user_id).await?; diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 86ddf2ba..40edc129 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -77,7 +77,7 @@ pub struct Update { /// This function will return an error if there was a problem uploading the /// torrent. pub async fn upload_torrent_handler(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let torrent_request = get_torrent_request_from_payload(payload).await?; @@ -99,7 +99,7 @@ pub async fn upload_torrent_handler(req: HttpRequest, payload: Multipart, app_da /// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); let torrent = app_data.torrent_service.get_torrent(&info_hash, user_id).await?; @@ -115,7 +115,7 @@ pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> /// This function will return an error if unable to get torrent info. pub async fn get_torrent_info_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); let torrent_response = app_data.torrent_service.get_torrent_info(&info_hash, user_id).await?; @@ -137,7 +137,7 @@ pub async fn update_torrent_info_handler( app_data: WebAppData, ) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let torrent_response = app_data .torrent_service @@ -158,7 +158,7 @@ pub async fn update_torrent_info_handler( /// * Delete the torrent. pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let deleted_torrent_response = app_data.torrent_service.delete_torrent(&info_hash, &user_id).await?; diff --git a/src/routes/user.rs b/src/routes/user.rs index 5daa8a67..020726b3 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -134,7 +134,7 @@ pub async fn email_verification_handler(req: HttpRequest, app_data: WebAppData) /// /// This function will return if the user could not be banned. pub async fn ban_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let to_be_banned_username = req.match_info().get("user").ok_or(ServiceError::InternalServerError)?; app_data.ban_service.ban_user(to_be_banned_username, &user_id).await?; diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs index b84adee9..545e3054 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/v1/auth.rs @@ -78,3 +78,16 @@ //! "data": "new category" //! } //! ``` + +use hyper::http::HeaderValue; + +/// Parses the token from the `Authorization` header. +pub fn parse_token(authorization: &HeaderValue) -> String { + let split: Vec<&str> = authorization + .to_str() + .expect("variable `auth` contains data that is not visible ASCII chars.") + .split("Bearer") + .collect(); + let token = split[1].trim(); + token.to_string() +} diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index 4d10023b..543b87f9 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -10,6 +10,7 @@ use super::forms::{JsonWebToken, LoginForm, RegistrationForm}; use super::responses::{self, NewUser, TokenResponse}; use crate::common::AppData; use crate::errors::ServiceError; +use crate::web::api::v1::extractors::bearer_token::Extract; use crate::web::api::v1::responses::OkResponse; // Registration @@ -99,6 +100,9 @@ pub async fn verify_token_handler( } } +#[derive(Deserialize)] +pub struct UsernameParam(pub String); + /// It renews the JWT. /// /// # Errors @@ -118,6 +122,32 @@ pub async fn renew_token_handler( } } +/// It bans a user from the index. +/// +/// # Errors +/// +/// This function will return if: +/// +/// - The JWT provided by the banning authority was not valid. +/// - The user could not be banned: it does not exist, etcetera. +#[allow(clippy::unused_async)] +pub async fn ban_handler( + State(app_data): State>, + Path(to_be_banned_username): Path, + Extract(maybe_bearer_token): Extract, +) -> Result>, ServiceError> { + // todo: add reason and `date_expiry` parameters to request + + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + match app_data.ban_service.ban_user(&to_be_banned_username.0, &user_id).await { + Ok(_) => Ok(axum::Json(OkResponse { + data: format!("Banned user: {}", to_be_banned_username.0), + })), + Err(error) => Err(error), + } +} + /// It returns the base API URL without the port. For example: `http://localhost`. fn api_base_url(host: &str) -> String { // HTTPS is not supported yet. diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/v1/contexts/user/routes.rs index 22eefa0e..b2a21624 100644 --- a/src/web/api/v1/contexts/user/routes.rs +++ b/src/web/api/v1/contexts/user/routes.rs @@ -3,11 +3,11 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::user). use std::sync::Arc; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::Router; use super::handlers::{ - email_verification_handler, login_handler, registration_handler, renew_token_handler, verify_token_handler, + ban_handler, email_verification_handler, login_handler, registration_handler, renew_token_handler, verify_token_handler, }; use crate::common::AppData; @@ -27,5 +27,8 @@ pub fn router(app_data: Arc) -> Router { // Authentication .route("/login", post(login_handler).with_state(app_data.clone())) .route("/token/verify", post(verify_token_handler).with_state(app_data.clone())) - .route("/token/renew", post(renew_token_handler).with_state(app_data)) + .route("/token/renew", post(renew_token_handler).with_state(app_data.clone())) + // User ban + // 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)) } diff --git a/src/web/api/v1/extractors/bearer_token.rs b/src/web/api/v1/extractors/bearer_token.rs new file mode 100644 index 00000000..1c9b5be9 --- /dev/null +++ b/src/web/api/v1/extractors/bearer_token.rs @@ -0,0 +1,36 @@ +use axum::async_trait; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::Response; +use serde::Deserialize; + +use crate::web::api::v1::auth::parse_token; + +pub struct Extract(pub Option); + +#[derive(Deserialize, Debug)] +pub struct BearerToken(String); + +impl BearerToken { + #[must_use] + pub fn value(&self) -> String { + self.0.clone() + } +} + +#[async_trait] +impl FromRequestParts for Extract +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let header = parts.headers.get("Authorization"); + + match header { + Some(header_value) => Ok(Extract(Some(BearerToken(parse_token(header_value))))), + None => Ok(Extract(None)), + } + } +} diff --git a/src/web/api/v1/extractors/mod.rs b/src/web/api/v1/extractors/mod.rs new file mode 100644 index 00000000..36d737ca --- /dev/null +++ b/src/web/api/v1/extractors/mod.rs @@ -0,0 +1 @@ +pub mod bearer_token; diff --git a/src/web/api/v1/mod.rs b/src/web/api/v1/mod.rs index 67490e6e..e9b3e9d6 100644 --- a/src/web/api/v1/mod.rs +++ b/src/web/api/v1/mod.rs @@ -6,5 +6,6 @@ //! information. pub mod auth; pub mod contexts; +pub mod extractors; pub mod responses; pub mod routes; diff --git a/tests/common/contexts/user/asserts.rs b/tests/common/contexts/user/asserts.rs index 620096c3..bcf92f5f 100644 --- a/tests/common/contexts/user/asserts.rs +++ b/tests/common/contexts/user/asserts.rs @@ -2,7 +2,7 @@ use super::forms::RegistrationForm; use super::responses::LoggedInUserData; use crate::common::asserts::assert_json_ok; use crate::common::contexts::user::responses::{ - AddedUserResponse, SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, + AddedUserResponse, BannedUserResponse, SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, }; use crate::common::responses::TextResponse; @@ -47,3 +47,15 @@ pub fn assert_token_renewal_response(response: &TextResponse, logged_in_user: &L assert_json_ok(response); } + +pub fn assert_banned_user_response(response: &TextResponse, registered_user: &RegistrationForm) { + let banned_user_response: BannedUserResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a BannedUserResponse", response.body)); + + assert_eq!( + banned_user_response.data, + format!("Banned user: {}", registered_user.username) + ); + + assert_json_ok(response); +} diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index cb53d879..73eff810 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -310,4 +310,78 @@ mod with_axum_implementation { assert_token_renewal_response(&response, &logged_in_user); } } + + mod banned_user_list { + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::user::asserts::assert_banned_user_response; + use crate::common::contexts::user::forms::Username; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_an_admin_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_banned_user_response(&response, ®istered_user); + } + + #[tokio::test] + async fn it_should_not_allow_a_non_admin_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_not_allow_a_guest_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 401); + } + } } From 6f9c1a227da4d418dd887395a69fb05dd5507448 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 12 Jun 2023 15:52:58 +0100 Subject: [PATCH 215/357] refactor: API category context tetests --- tests/e2e/contexts/category/contract.rs | 75 ++++++++++++++----------- tests/e2e/contexts/category/steps.rs | 10 ++++ 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index b5682327..43d854c9 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -6,17 +6,10 @@ use crate::common::client::Client; use crate::common::contexts::category::fixtures::random_category_name; use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use crate::common::contexts::category::responses::{AddedCategoryResponse, ListResponse}; -use crate::e2e::contexts::category::steps::add_category; +use crate::e2e::contexts::category::steps::{add_category, add_random_category}; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; -/* todo: - - it should allow adding a new category to authenticated clients - - it should not allow adding a new category with an empty name - - it should allow adding a new category with an optional icon - - ... -*/ - #[tokio::test] async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { let mut env = TestEnv::new(); @@ -34,17 +27,15 @@ async fn it_should_return_a_category_list() { env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - // Add a category - let category_name = random_category_name(); - let response = add_category(&category_name, &env).await; - assert_eq!(response.status, 200); + add_random_category(&env).await; let response = client.get_categories().await; let res: ListResponse = serde_json::from_str(&response.body).unwrap(); // There should be at least the category we added. - // Since this is an E2E test, there might be more categories. + // Since this is an E2E test and it could be run in a shared test env, + // there might be more categories. assert!(res.data.len() > 1); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "application/json"); @@ -114,17 +105,42 @@ async fn it_should_allow_admins_to_add_new_categories() { } #[tokio::test] -async fn it_should_not_allow_adding_duplicated_categories() { +async fn it_should_allow_adding_empty_categories() { + // code-review: this is a bit weird, is it a intended behavior? + let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; - // Add a category - let random_category_name = random_category_name(); - let response = add_category(&random_category_name, &env).await; + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let category_name = String::new(); + + let response = client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, category_name); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_not_allow_adding_duplicated_categories() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + let added_category_name = add_random_category(&env).await; // Try to add the same category again - let response = add_category(&random_category_name, &env).await; + let response = add_category(&added_category_name, &env).await; assert_eq!(response.status, 400); } @@ -136,21 +152,18 @@ async fn it_should_allow_admins_to_delete_categories() { let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - // Add a category - let category_name = random_category_name(); - let response = add_category(&category_name, &env).await; - assert_eq!(response.status, 200); + let added_category_name = add_random_category(&env).await; let response = client .delete_category(DeleteCategoryForm { - name: category_name.to_string(), + name: added_category_name.to_string(), icon: None, }) .await; let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); - assert_eq!(res.data, category_name); + assert_eq!(res.data, added_category_name); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "application/json"); } @@ -162,17 +175,14 @@ async fn it_should_not_allow_non_admins_to_delete_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; - // Add a category - let category_name = random_category_name(); - let response = add_category(&category_name, &env).await; - assert_eq!(response.status, 200); + let added_category_name = add_random_category(&env).await; let logged_in_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); let response = client .delete_category(DeleteCategoryForm { - name: category_name.to_string(), + name: added_category_name.to_string(), icon: None, }) .await; @@ -186,14 +196,11 @@ async fn it_should_not_allow_guests_to_delete_categories() { env.start(api::Implementation::ActixWeb).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - // Add a category - let category_name = random_category_name(); - let response = add_category(&category_name, &env).await; - assert_eq!(response.status, 200); + let added_category_name = add_random_category(&env).await; let response = client .delete_category(DeleteCategoryForm { - name: category_name.to_string(), + name: added_category_name.to_string(), icon: None, }) .await; diff --git a/tests/e2e/contexts/category/steps.rs b/tests/e2e/contexts/category/steps.rs index 2150a7a8..321cdddc 100644 --- a/tests/e2e/contexts/category/steps.rs +++ b/tests/e2e/contexts/category/steps.rs @@ -1,9 +1,19 @@ use crate::common::client::Client; +use crate::common::contexts::category::fixtures::random_category_name; use crate::common::contexts::category::forms::AddCategoryForm; +use crate::common::contexts::category::responses::AddedCategoryResponse; use crate::common::responses::TextResponse; use crate::e2e::contexts::user::steps::new_logged_in_admin; use crate::e2e::environment::TestEnv; +/// Add a random category and return its name. +pub async fn add_random_category(env: &TestEnv) -> String { + let category_name = random_category_name(); + let response = add_category(&category_name, env).await; + let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); + res.data +} + pub async fn add_category(category_name: &str, env: &TestEnv) -> TextResponse { let logged_in_admin = new_logged_in_admin(env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); From bb6d9bf72697d1d25d53759f145a30d8eb9dbdac Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 12 Jun 2023 16:59:02 +0100 Subject: [PATCH 216/357] refactor(api): [#179] Axum API, category context, get all categories --- src/web/api/v1/contexts/category/handlers.rs | 33 ++++++++++++++++++++ src/web/api/v1/contexts/category/mod.rs | 2 ++ src/web/api/v1/contexts/category/routes.rs | 15 +++++++++ src/web/api/v1/routes.rs | 13 ++++---- tests/e2e/contexts/category/contract.rs | 20 ++++++++++++ 5 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 src/web/api/v1/contexts/category/handlers.rs create mode 100644 src/web/api/v1/contexts/category/routes.rs diff --git a/src/web/api/v1/contexts/category/handlers.rs b/src/web/api/v1/contexts/category/handlers.rs new file mode 100644 index 00000000..4cc34dc3 --- /dev/null +++ b/src/web/api/v1/contexts/category/handlers.rs @@ -0,0 +1,33 @@ +//! API handlers for the the [`category`](crate::web::api::v1::contexts::category) API +//! context. +use std::sync::Arc; + +use axum::extract::State; +use axum::response::Json; + +use crate::common::AppData; +use crate::databases::database::{self, Category}; +use crate::web::api::v1::responses; + +/// It handles the request to get all the categories. +/// +/// It returns: +/// +/// - `200` response with a json containing the category list [`Vec`](crate::databases::database::Category). +/// - Other error status codes if there is a database error. +/// +/// Refer to the [API endpoint documentation](crate::web::api::v1::contexts::category) +/// for more information about this endpoint. +/// +/// # Errors +/// +/// It returns an error if there is a database error. +#[allow(clippy::unused_async)] +pub async fn get_all_handler( + State(app_data): State>, +) -> Result>>, database::Error> { + match app_data.category_repository.get_all().await { + Ok(categories) => Ok(Json(responses::OkResponse { data: categories })), + Err(error) => Err(error), + } +} diff --git a/src/web/api/v1/contexts/category/mod.rs b/src/web/api/v1/contexts/category/mod.rs index 68cf07e3..65d74e49 100644 --- a/src/web/api/v1/contexts/category/mod.rs +++ b/src/web/api/v1/contexts/category/mod.rs @@ -138,3 +138,5 @@ //! Refer to [`OkResponse`](crate::models::response::OkResponse) for more //! information about the response attributes. The response contains only the //! name of the deleted category. +pub mod handlers; +pub mod routes; diff --git a/src/web/api/v1/contexts/category/routes.rs b/src/web/api/v1/contexts/category/routes.rs new file mode 100644 index 00000000..c405714a --- /dev/null +++ b/src/web/api/v1/contexts/category/routes.rs @@ -0,0 +1,15 @@ +//! API routes for the [`category`](crate::web::api::v1::contexts::category) API context. +//! +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::category). +use std::sync::Arc; + +use axum::routing::get; +use axum::Router; + +use super::handlers::get_all_handler; +use crate::common::AppData; + +/// Routes for the [`category`](crate::web::api::v1::contexts::category) API context. +pub fn router(app_data: Arc) -> Router { + Router::new().route("/", get(get_all_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 79185bf6..f7cbf5e2 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -4,22 +4,23 @@ use std::sync::Arc; use axum::Router; //use tower_http::cors::CorsLayer; -use super::contexts::{about, user}; +use super::contexts::about; +use super::contexts::{category, user}; use crate::common::AppData; /// Add all API routes to the router. #[allow(clippy::needless_pass_by_value)] pub fn router(app_data: Arc) -> Router { - let user_routes = user::routes::router(app_data.clone()); - let about_routes = about::routes::router(app_data); + let api_routes = Router::new() + .nest("/user", user::routes::router(app_data.clone())) + .nest("/about", about::routes::router(app_data.clone())) + .nest("/category", category::routes::router(app_data)); - let api_routes = Router::new().nest("/user", user_routes).nest("/about", about_routes); + Router::new().nest("/v1", api_routes) // For development purposes only. // It allows calling the API on a different port. For example // API: http://localhost:3000/v1 // Webapp: http://localhost:8080 //Router::new().nest("/v1", api_routes).layer(CorsLayer::permissive()) - - Router::new().nest("/v1", api_routes) } diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index 43d854c9..a26df3b5 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -207,3 +207,23 @@ async fn it_should_not_allow_guests_to_delete_categories() { assert_eq!(response.status, 401); } + +mod with_axum_implementation { + use torrust_index_backend::web::api; + + use crate::common::asserts::assert_json_ok; + use crate::common::client::Client; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_categories().await; + + assert_json_ok(&response); + } +} From f63bf050cec0f3806f326a8c38a6aae197656e21 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jun 2023 12:00:37 +0100 Subject: [PATCH 217/357] refactor(api): [#179] Axum API, category context, add category --- src/web/api/v1/contexts/category/forms.rs | 7 + src/web/api/v1/contexts/category/handlers.rs | 30 +++- src/web/api/v1/contexts/category/mod.rs | 2 + src/web/api/v1/contexts/category/responses.rs | 12 ++ src/web/api/v1/contexts/category/routes.rs | 8 +- tests/common/contexts/category/asserts.rs | 12 ++ tests/common/contexts/category/mod.rs | 1 + tests/e2e/contexts/category/contract.rs | 153 ++++++++++++++++++ tests/e2e/contexts/category/steps.rs | 7 +- tests/e2e/contexts/user/steps.rs | 5 +- 10 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 src/web/api/v1/contexts/category/forms.rs create mode 100644 src/web/api/v1/contexts/category/responses.rs create mode 100644 tests/common/contexts/category/asserts.rs diff --git a/src/web/api/v1/contexts/category/forms.rs b/src/web/api/v1/contexts/category/forms.rs new file mode 100644 index 00000000..ecddcadb --- /dev/null +++ b/src/web/api/v1/contexts/category/forms.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct CategoryForm { + pub name: String, + pub icon: Option, +} diff --git a/src/web/api/v1/contexts/category/handlers.rs b/src/web/api/v1/contexts/category/handlers.rs index 4cc34dc3..12e29532 100644 --- a/src/web/api/v1/contexts/category/handlers.rs +++ b/src/web/api/v1/contexts/category/handlers.rs @@ -2,12 +2,16 @@ //! context. use std::sync::Arc; -use axum::extract::State; +use axum::extract::{self, State}; use axum::response::Json; +use super::forms::CategoryForm; +use super::responses::added_category; use crate::common::AppData; use crate::databases::database::{self, Category}; -use crate::web::api::v1::responses; +use crate::errors::ServiceError; +use crate::web::api::v1::extractors::bearer_token::Extract; +use crate::web::api::v1::responses::{self, OkResponse}; /// It handles the request to get all the categories. /// @@ -31,3 +35,25 @@ pub async fn get_all_handler( Err(error) => Err(error), } } + +/// It adds a new category. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The user does not have permissions to create a new category. +/// - There is a database error. +#[allow(clippy::unused_async)] +pub async fn add_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + extract::Json(category_form): extract::Json, +) -> Result>, ServiceError> { + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + match app_data.category_service.add_category(&category_form.name, &user_id).await { + Ok(_) => Ok(added_category(&category_form.name)), + Err(error) => Err(error), + } +} diff --git a/src/web/api/v1/contexts/category/mod.rs b/src/web/api/v1/contexts/category/mod.rs index 65d74e49..804faf27 100644 --- a/src/web/api/v1/contexts/category/mod.rs +++ b/src/web/api/v1/contexts/category/mod.rs @@ -138,5 +138,7 @@ //! Refer to [`OkResponse`](crate::models::response::OkResponse) for more //! information about the response attributes. The response contains only the //! name of the deleted category. +pub mod forms; pub mod handlers; +pub mod responses; pub mod routes; diff --git a/src/web/api/v1/contexts/category/responses.rs b/src/web/api/v1/contexts/category/responses.rs new file mode 100644 index 00000000..97b0ebb7 --- /dev/null +++ b/src/web/api/v1/contexts/category/responses.rs @@ -0,0 +1,12 @@ +//! API responses for the the [`category`](crate::web::api::v1::contexts::category) API +//! context. +use axum::Json; + +use crate::web::api::v1::responses::OkResponse; + +/// Response after successfully creating a new category. +pub fn added_category(category_name: &str) -> Json> { + Json(OkResponse { + data: category_name.to_string(), + }) +} diff --git a/src/web/api/v1/contexts/category/routes.rs b/src/web/api/v1/contexts/category/routes.rs index c405714a..e34d2ef4 100644 --- a/src/web/api/v1/contexts/category/routes.rs +++ b/src/web/api/v1/contexts/category/routes.rs @@ -3,13 +3,15 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::category). use std::sync::Arc; -use axum::routing::get; +use axum::routing::{get, post}; use axum::Router; -use super::handlers::get_all_handler; +use super::handlers::{add_handler, get_all_handler}; use crate::common::AppData; /// Routes for the [`category`](crate::web::api::v1::contexts::category) API context. pub fn router(app_data: Arc) -> Router { - Router::new().route("/", get(get_all_handler).with_state(app_data)) + Router::new() + .route("/", get(get_all_handler).with_state(app_data.clone())) + .route("/", post(add_handler).with_state(app_data)) } diff --git a/tests/common/contexts/category/asserts.rs b/tests/common/contexts/category/asserts.rs new file mode 100644 index 00000000..2e39d9fd --- /dev/null +++ b/tests/common/contexts/category/asserts.rs @@ -0,0 +1,12 @@ +use crate::common::asserts::assert_json_ok; +use crate::common::contexts::category::responses::AddedCategoryResponse; +use crate::common::responses::TextResponse; + +pub fn assert_added_category_response(response: &TextResponse, category_name: &str) { + let added_category_response: AddedCategoryResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a AddedCategoryResponse", response.body)); + + assert_eq!(added_category_response.data, category_name); + + assert_json_ok(response); +} diff --git a/tests/common/contexts/category/mod.rs b/tests/common/contexts/category/mod.rs index 6f27f51d..cfe5dd24 100644 --- a/tests/common/contexts/category/mod.rs +++ b/tests/common/contexts/category/mod.rs @@ -1,3 +1,4 @@ +pub mod asserts; pub mod fixtures; pub mod forms; pub mod responses; diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index a26df3b5..336a35c8 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -209,10 +209,19 @@ async fn it_should_not_allow_guests_to_delete_categories() { } mod with_axum_implementation { + use std::env; + use torrust_index_backend::web::api; use crate::common::asserts::assert_json_ok; use crate::common::client::Client; + use crate::common::contexts::category::asserts::assert_added_category_response; + use crate::common::contexts::category::fixtures::random_category_name; + use crate::common::contexts::category::forms::AddCategoryForm; + use crate::common::contexts::category::responses::ListResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::category::steps::{add_category, add_random_category}; + use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -226,4 +235,148 @@ mod with_axum_implementation { assert_json_ok(&response); } + + #[tokio::test] + async fn it_should_return_a_category_list() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + add_random_category(&env).await; + + let response = client.get_categories().await; + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + // There should be at least the category we added. + // Since this is an E2E test and it could be run in a shared test env, + // there might be more categories. + assert!(res.data.len() > 1); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); + } + + #[tokio::test] + async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client + .add_category(AddCategoryForm { + name: "CATEGORY NAME".to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 401); + } + + #[tokio::test] + async fn it_should_not_allow_adding_a_new_category_to_non_admins() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let response = client + .add_category(AddCategoryForm { + name: "CATEGORY NAME".to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_allow_admins_to_add_new_categories() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let category_name = random_category_name(); + + let response = client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + assert_added_category_response(&response, &category_name); + } + + #[tokio::test] + async fn it_should_allow_adding_empty_categories() { + // code-review: this is a bit weird, is it a intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let category_name = String::new(); + + let response = client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + assert_added_category_response(&response, &category_name); + } + + #[tokio::test] + async fn it_should_not_allow_adding_duplicated_categories() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let added_category_name = add_random_category(&env).await; + + // Try to add the same category again + let response = add_category(&added_category_name, &env).await; + + assert_eq!(response.status, 400); + } } diff --git a/tests/e2e/contexts/category/steps.rs b/tests/e2e/contexts/category/steps.rs index 321cdddc..ab000dab 100644 --- a/tests/e2e/contexts/category/steps.rs +++ b/tests/e2e/contexts/category/steps.rs @@ -9,13 +9,18 @@ use crate::e2e::environment::TestEnv; /// Add a random category and return its name. pub async fn add_random_category(env: &TestEnv) -> String { let category_name = random_category_name(); + let response = add_category(&category_name, env).await; - let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); + + let res: AddedCategoryResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a AddedCategoryResponse", response.body)); + res.data } pub async fn add_category(category_name: &str, env: &TestEnv) -> TextResponse { let logged_in_admin = new_logged_in_admin(env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); client diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/contexts/user/steps.rs index f4892325..f0c8a531 100644 --- a/tests/e2e/contexts/user/steps.rs +++ b/tests/e2e/contexts/user/steps.rs @@ -17,7 +17,10 @@ pub async fn new_logged_in_admin(env: &TestEnv) -> LoggedInUserData { .expect("Database error."), ); - let user_profile = database.get_user_profile_from_username(&user.username).await.unwrap(); + let user_profile = database + .get_user_profile_from_username(&user.username) + .await + .unwrap_or_else(|_| panic!("user {user:#?} should have a profile.")); database.grant_admin_role(user_profile.user_id).await.unwrap(); From b4a7ea6e923a6a00594c137b51666b12061b092a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jun 2023 12:23:06 +0100 Subject: [PATCH 218/357] refactor(api): [#179] Axum API, category context, delete category --- src/web/api/v1/contexts/category/forms.rs | 4 +- src/web/api/v1/contexts/category/handlers.rs | 32 +++++++- src/web/api/v1/contexts/category/responses.rs | 7 ++ src/web/api/v1/contexts/category/routes.rs | 7 +- tests/common/contexts/category/asserts.rs | 11 ++- tests/common/contexts/category/responses.rs | 5 ++ tests/e2e/contexts/category/contract.rs | 78 ++++++++++++++++++- 7 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/web/api/v1/contexts/category/forms.rs b/src/web/api/v1/contexts/category/forms.rs index ecddcadb..1ad7767a 100644 --- a/src/web/api/v1/contexts/category/forms.rs +++ b/src/web/api/v1/contexts/category/forms.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug)] -pub struct CategoryForm { +pub struct AddCategoryForm { pub name: String, pub icon: Option, } + +pub type DeleteCategoryForm = AddCategoryForm; diff --git a/src/web/api/v1/contexts/category/handlers.rs b/src/web/api/v1/contexts/category/handlers.rs index 12e29532..3d09008a 100644 --- a/src/web/api/v1/contexts/category/handlers.rs +++ b/src/web/api/v1/contexts/category/handlers.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use axum::extract::{self, State}; use axum::response::Json; -use super::forms::CategoryForm; -use super::responses::added_category; +use super::forms::{AddCategoryForm, DeleteCategoryForm}; +use super::responses::{added_category, deleted_category}; use crate::common::AppData; use crate::databases::database::{self, Category}; use crate::errors::ServiceError; @@ -48,7 +48,7 @@ pub async fn get_all_handler( pub async fn add_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, - extract::Json(category_form): extract::Json, + extract::Json(category_form): extract::Json, ) -> Result>, ServiceError> { let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; @@ -57,3 +57,29 @@ pub async fn add_handler( Err(error) => Err(error), } } + +/// It deletes a category. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The user does not have permissions to delete category. +/// - There is a database error. +#[allow(clippy::unused_async)] +pub async fn delete_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + extract::Json(category_form): extract::Json, +) -> Result>, ServiceError> { + // code-review: why do we need to send the whole category object to delete it? + // And we should use the ID instead of the name, because the name could change + // or we could add support for multiple languages. + + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + match app_data.category_service.delete_category(&category_form.name, &user_id).await { + Ok(_) => Ok(deleted_category(&category_form.name)), + Err(error) => Err(error), + } +} diff --git a/src/web/api/v1/contexts/category/responses.rs b/src/web/api/v1/contexts/category/responses.rs index 97b0ebb7..cb372801 100644 --- a/src/web/api/v1/contexts/category/responses.rs +++ b/src/web/api/v1/contexts/category/responses.rs @@ -10,3 +10,10 @@ pub fn added_category(category_name: &str) -> Json> { data: category_name.to_string(), }) } + +/// Response after successfully deleting a new category. +pub fn deleted_category(category_name: &str) -> Json> { + Json(OkResponse { + data: category_name.to_string(), + }) +} diff --git a/src/web/api/v1/contexts/category/routes.rs b/src/web/api/v1/contexts/category/routes.rs index e34d2ef4..2d762c47 100644 --- a/src/web/api/v1/contexts/category/routes.rs +++ b/src/web/api/v1/contexts/category/routes.rs @@ -3,15 +3,16 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::category). use std::sync::Arc; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::Router; -use super::handlers::{add_handler, get_all_handler}; +use super::handlers::{add_handler, delete_handler, get_all_handler}; use crate::common::AppData; /// Routes for the [`category`](crate::web::api::v1::contexts::category) API context. pub fn router(app_data: Arc) -> Router { Router::new() .route("/", get(get_all_handler).with_state(app_data.clone())) - .route("/", post(add_handler).with_state(app_data)) + .route("/", post(add_handler).with_state(app_data.clone())) + .route("/", delete(delete_handler).with_state(app_data)) } diff --git a/tests/common/contexts/category/asserts.rs b/tests/common/contexts/category/asserts.rs index 2e39d9fd..ae47bdca 100644 --- a/tests/common/contexts/category/asserts.rs +++ b/tests/common/contexts/category/asserts.rs @@ -1,5 +1,5 @@ use crate::common::asserts::assert_json_ok; -use crate::common::contexts::category::responses::AddedCategoryResponse; +use crate::common::contexts::category::responses::{AddedCategoryResponse, DeletedCategoryResponse}; use crate::common::responses::TextResponse; pub fn assert_added_category_response(response: &TextResponse, category_name: &str) { @@ -10,3 +10,12 @@ pub fn assert_added_category_response(response: &TextResponse, category_name: &s assert_json_ok(response); } + +pub fn assert_deleted_category_response(response: &TextResponse, category_name: &str) { + let deleted_category_response: DeletedCategoryResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a DeletedCategoryResponse", response.body)); + + assert_eq!(deleted_category_response.data, category_name); + + assert_json_ok(response); +} diff --git a/tests/common/contexts/category/responses.rs b/tests/common/contexts/category/responses.rs index a345d523..cbadb631 100644 --- a/tests/common/contexts/category/responses.rs +++ b/tests/common/contexts/category/responses.rs @@ -5,6 +5,11 @@ pub struct AddedCategoryResponse { pub data: String, } +#[derive(Deserialize)] +pub struct DeletedCategoryResponse { + pub data: String, +} + #[derive(Deserialize, Debug)] pub struct ListResponse { pub data: Vec, diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index 336a35c8..d1970063 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -215,9 +215,9 @@ mod with_axum_implementation { use crate::common::asserts::assert_json_ok; use crate::common::client::Client; - use crate::common::contexts::category::asserts::assert_added_category_response; + use crate::common::contexts::category::asserts::{assert_added_category_response, assert_deleted_category_response}; use crate::common::contexts::category::fixtures::random_category_name; - use crate::common::contexts::category::forms::AddCategoryForm; + use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use crate::common::contexts::category::responses::ListResponse; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::category::steps::{add_category, add_random_category}; @@ -379,4 +379,78 @@ mod with_axum_implementation { assert_eq!(response.status, 400); } + + #[tokio::test] + async fn it_should_allow_admins_to_delete_categories() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let added_category_name = add_random_category(&env).await; + + let response = client + .delete_category(DeleteCategoryForm { + name: added_category_name.to_string(), + icon: None, + }) + .await; + + assert_deleted_category_response(&response, &added_category_name); + } + + #[tokio::test] + async fn it_should_not_allow_non_admins_to_delete_categories() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let added_category_name = add_random_category(&env).await; + + let logged_in_non_admin = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); + + let response = client + .delete_category(DeleteCategoryForm { + name: added_category_name.to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_not_allow_guests_to_delete_categories() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let added_category_name = add_random_category(&env).await; + + let response = client + .delete_category(DeleteCategoryForm { + name: added_category_name.to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 401); + } } From 878bb7b3b55d40b21549e0bdfa706595311a3792 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jun 2023 12:36:00 +0100 Subject: [PATCH 219/357] refactor(api): [#198] Axum API, root endpoints --- src/web/api/v1/routes.rs | 11 ++++++++--- tests/e2e/contexts/root/contract.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index f7cbf5e2..4e563885 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -1,22 +1,27 @@ //! Route initialization for the v1 API. use std::sync::Arc; +use axum::routing::get; use axum::Router; //use tower_http::cors::CorsLayer; use super::contexts::about; +use super::contexts::about::handlers::about_page_handler; use super::contexts::{category, user}; use crate::common::AppData; /// Add all API routes to the router. #[allow(clippy::needless_pass_by_value)] pub fn router(app_data: Arc) -> Router { - let api_routes = Router::new() + let v1_api_routes = Router::new() + .route("/", get(about_page_handler).with_state(app_data.clone())) .nest("/user", user::routes::router(app_data.clone())) .nest("/about", about::routes::router(app_data.clone())) - .nest("/category", category::routes::router(app_data)); + .nest("/category", category::routes::router(app_data.clone())); - Router::new().nest("/v1", api_routes) + Router::new() + .route("/", get(about_page_handler).with_state(app_data)) + .nest("/v1", v1_api_routes) // For development purposes only. // It allows calling the API on a different port. For example diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs index 10a2bf6c..ab5269b4 100644 --- a/tests/e2e/contexts/root/contract.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -16,3 +16,24 @@ async fn it_should_load_the_about_page_at_the_api_entrypoint() { assert_text_ok(&response); assert_response_title(&response, "About"); } + +mod with_axum_implementation { + use torrust_index_backend::web::api; + + use crate::common::asserts::{assert_response_title, assert_text_ok}; + use crate::common::client::Client; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_load_the_about_page_at_the_api_entrypoint() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.root().await; + + assert_text_ok(&response); + assert_response_title(&response, "About"); + } +} From b53ce8d3f3b90720077867ac5510d63f6b0cde09 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jun 2023 13:19:11 +0100 Subject: [PATCH 220/357] refactor(api): [#198] Axum API, tag context --- src/web/api/v1/contexts/mod.rs | 2 + src/web/api/v1/contexts/tag/forms.rs | 15 ++ src/web/api/v1/contexts/tag/handlers.rs | 82 ++++++++ src/web/api/v1/contexts/tag/mod.rs | 123 ++++++++++++ src/web/api/v1/contexts/tag/responses.rs | 20 ++ src/web/api/v1/contexts/tag/routes.rs | 24 +++ src/web/api/v1/routes.rs | 11 +- tests/common/contexts/tag/asserts.rs | 23 +++ tests/common/contexts/tag/mod.rs | 1 + tests/common/contexts/tag/responses.rs | 16 +- tests/e2e/contexts/tag/contract.rs | 231 +++++++++++++++++++++++ 11 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 src/web/api/v1/contexts/tag/forms.rs create mode 100644 src/web/api/v1/contexts/tag/handlers.rs create mode 100644 src/web/api/v1/contexts/tag/mod.rs create mode 100644 src/web/api/v1/contexts/tag/responses.rs create mode 100644 src/web/api/v1/contexts/tag/routes.rs create mode 100644 tests/common/contexts/tag/asserts.rs diff --git a/src/web/api/v1/contexts/mod.rs b/src/web/api/v1/contexts/mod.rs index 7119d40d..f6ef4069 100644 --- a/src/web/api/v1/contexts/mod.rs +++ b/src/web/api/v1/contexts/mod.rs @@ -6,6 +6,7 @@ //! `Category` | Torrent categories | [`v1`](crate::web::api::v1::contexts::category) //! `Proxy` | Image proxy cache | [`v1`](crate::web::api::v1::contexts::proxy) //! `Settings` | Index settings | [`v1`](crate::web::api::v1::contexts::settings) +//! `Tag` | Torrent tags | [`v1`](crate::web::api::v1::contexts::tag) //! `Torrent` | Indexed torrents | [`v1`](crate::web::api::v1::contexts::torrent) //! `User` | Users | [`v1`](crate::web::api::v1::contexts::user) //! @@ -13,5 +14,6 @@ pub mod about; pub mod category; pub mod proxy; pub mod settings; +pub mod tag; pub mod torrent; pub mod user; diff --git a/src/web/api/v1/contexts/tag/forms.rs b/src/web/api/v1/contexts/tag/forms.rs new file mode 100644 index 00000000..12c751ad --- /dev/null +++ b/src/web/api/v1/contexts/tag/forms.rs @@ -0,0 +1,15 @@ +//! API forms for the the [`tag`](crate::web::api::v1::contexts::tag) API +//! context. +use serde::{Deserialize, Serialize}; + +use crate::models::torrent_tag::TagId; + +#[derive(Serialize, Deserialize, Debug)] +pub struct AddTagForm { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeleteTagForm { + pub tag_id: TagId, +} diff --git a/src/web/api/v1/contexts/tag/handlers.rs b/src/web/api/v1/contexts/tag/handlers.rs new file mode 100644 index 00000000..507e80a9 --- /dev/null +++ b/src/web/api/v1/contexts/tag/handlers.rs @@ -0,0 +1,82 @@ +//! API handlers for the [`tag`](crate::web::api::v1::contexts::tag) API +//! context. +use std::sync::Arc; + +use axum::extract::{self, State}; +use axum::response::Json; + +use super::forms::{AddTagForm, DeleteTagForm}; +use super::responses::{added_tag, deleted_tag}; +use crate::common::AppData; +use crate::databases::database; +use crate::errors::ServiceError; +use crate::models::torrent_tag::TorrentTag; +use crate::web::api::v1::extractors::bearer_token::Extract; +use crate::web::api::v1::responses::{self, OkResponse}; + +/// It handles the request to get all the tags. +/// +/// It returns: +/// +/// - `200` response with a json containing the tag list [`Vec`](crate::models::torrent_tag::TorrentTag). +/// - Other error status codes if there is a database error. +/// +/// Refer to the [API endpoint documentation](crate::web::api::v1::contexts::tag) +/// for more information about this endpoint. +/// +/// # Errors +/// +/// It returns an error if there is a database error. +#[allow(clippy::unused_async)] +pub async fn get_all_handler( + State(app_data): State>, +) -> Result>>, database::Error> { + match app_data.tag_repository.get_all().await { + Ok(tags) => Ok(Json(responses::OkResponse { data: tags })), + Err(error) => Err(error), + } +} + +/// It adds a new tag. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The user does not have permissions to create a new tag. +/// - There is a database error. +#[allow(clippy::unused_async)] +pub async fn add_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + extract::Json(add_tag_form): extract::Json, +) -> Result>, ServiceError> { + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + match app_data.tag_service.add_tag(&add_tag_form.name, &user_id).await { + Ok(_) => Ok(added_tag(&add_tag_form.name)), + Err(error) => Err(error), + } +} + +/// It deletes a tag. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The user does not have permissions to delete tags. +/// - There is a database error. +#[allow(clippy::unused_async)] +pub async fn delete_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + extract::Json(delete_tag_form): extract::Json, +) -> Result>, ServiceError> { + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + match app_data.tag_service.delete_tag(&delete_tag_form.tag_id, &user_id).await { + Ok(_) => Ok(deleted_tag(delete_tag_form.tag_id)), + Err(error) => Err(error), + } +} diff --git a/src/web/api/v1/contexts/tag/mod.rs b/src/web/api/v1/contexts/tag/mod.rs new file mode 100644 index 00000000..3e969590 --- /dev/null +++ b/src/web/api/v1/contexts/tag/mod.rs @@ -0,0 +1,123 @@ +//! API context: `tag`. +//! +//! This API context is responsible for handling torrent tags. +//! +//! # Endpoints +//! +//! - [Get all tags](#get-all-tags) +//! - [Add a tag](#add-a-tag) +//! - [Delete a tag](#delete-a-tag) +//! +//! **NOTICE**: We don't support multiple languages yet, so the tag is always +//! in English. +//! +//! # Get all tags +//! +//! `GET /v1/tag` +//! +//! Returns all torrent tags. +//! +//! **Example request** +//! +//! ```bash +//! curl "http://127.0.0.1:3000/v1/tags" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": [ +//! { +//! "tag_id": 1, +//! "name": "anime" +//! }, +//! { +//! "tag_id": 2, +//! "name": "manga" +//! } +//! ] +//! } +//! ``` +//! **Resource** +//! +//! Refer to the [`Tag`](crate::databases::database::Tag) +//! struct for more information about the response attributes. +//! +//! # Add a tag +//! +//! `POST /v1/tag` +//! +//! It adds a new tag. +//! +//! **POST params** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `name` | `String` | The tag name | Yes | `new tag` +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request POST \ +//! --data '{"name":"new tag"}' \ +//! http://127.0.0.1:3000/v1/tag +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": "new tag" +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to [`OkResponse`](crate::models::response::OkResponse) for more +//! information about the response attributes. The response contains only the +//! name of the newly created tag. +//! +//! # Delete a tag +//! +//! `DELETE /v1/tag` +//! +//! It deletes a tag. +//! +//! **POST params** +//! +//! Name | Type | Description | Required | Example +//! ---|---|---|---|--- +//! `tag_id` | `i64` | The internal tag ID | Yes | `1` +//! +//! **Example request** +//! +//! ```bash +//! curl \ +//! --header "Content-Type: application/json" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --request DELETE \ +//! --data '{"tag_id":1}' \ +//! http://127.0.0.1:3000/v1/tag +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "data": 1 +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to [`OkResponse`](crate::models::response::OkResponse) for more +//! information about the response attributes. The response contains only the +//! name of the deleted tag. +pub mod forms; +pub mod handlers; +pub mod responses; +pub mod routes; diff --git a/src/web/api/v1/contexts/tag/responses.rs b/src/web/api/v1/contexts/tag/responses.rs new file mode 100644 index 00000000..7b4d4120 --- /dev/null +++ b/src/web/api/v1/contexts/tag/responses.rs @@ -0,0 +1,20 @@ +//! API responses for the [`tag`](crate::web::api::v1::contexts::tag) API +//! context. +use axum::Json; + +use crate::models::torrent_tag::TagId; +use crate::web::api::v1::responses::OkResponse; + +/// Response after successfully creating a new tag. +pub fn added_tag(tag_name: &str) -> Json> { + Json(OkResponse { + data: tag_name.to_string(), + }) +} + +/// Response after successfully deleting a tag. +pub fn deleted_tag(tag_id: TagId) -> Json> { + Json(OkResponse { + data: tag_id.to_string(), + }) +} diff --git a/src/web/api/v1/contexts/tag/routes.rs b/src/web/api/v1/contexts/tag/routes.rs new file mode 100644 index 00000000..4d72970a --- /dev/null +++ b/src/web/api/v1/contexts/tag/routes.rs @@ -0,0 +1,24 @@ +//! API routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +//! +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::tag). +use std::sync::Arc; + +use axum::routing::{delete, get, post}; +use axum::Router; + +use super::handlers::{add_handler, delete_handler, get_all_handler}; +use crate::common::AppData; + +// code-review: should we use `tags` also for single resources? + +/// Routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +pub fn router_for_single_resources(app_data: Arc) -> Router { + Router::new() + .route("/", post(add_handler).with_state(app_data.clone())) + .route("/", delete(delete_handler).with_state(app_data)) +} + +/// Routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +pub fn router_for_multiple_resources(app_data: Arc) -> Router { + Router::new().route("/", get(get_all_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 4e563885..1b347d18 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -4,20 +4,25 @@ use std::sync::Arc; use axum::routing::get; use axum::Router; -//use tower_http::cors::CorsLayer; -use super::contexts::about; use super::contexts::about::handlers::about_page_handler; +//use tower_http::cors::CorsLayer; +use super::contexts::{about, tag}; use super::contexts::{category, user}; use crate::common::AppData; /// Add all API routes to the router. #[allow(clippy::needless_pass_by_value)] pub fn router(app_data: Arc) -> Router { + // code-review: should we use plural for the resource prefix: `users`, `categories`, `tags`? + // See: https://stackoverflow.com/questions/6845772/should-i-use-singular-or-plural-name-convention-for-rest-resources + let v1_api_routes = Router::new() .route("/", get(about_page_handler).with_state(app_data.clone())) .nest("/user", user::routes::router(app_data.clone())) .nest("/about", about::routes::router(app_data.clone())) - .nest("/category", category::routes::router(app_data.clone())); + .nest("/category", category::routes::router(app_data.clone())) + .nest("/tag", tag::routes::router_for_single_resources(app_data.clone())) + .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())); Router::new() .route("/", get(about_page_handler).with_state(app_data)) diff --git a/tests/common/contexts/tag/asserts.rs b/tests/common/contexts/tag/asserts.rs new file mode 100644 index 00000000..cd796e91 --- /dev/null +++ b/tests/common/contexts/tag/asserts.rs @@ -0,0 +1,23 @@ +use torrust_index_backend::models::torrent_tag::TagId; + +use crate::common::asserts::assert_json_ok; +use crate::common::contexts::tag::responses::{AddedTagResponse, DeletedTagResponse}; +use crate::common::responses::TextResponse; + +pub fn assert_added_tag_response(response: &TextResponse, tag_name: &str) { + let added_tag_response: AddedTagResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a AddedTagResponse", response.body)); + + assert_eq!(added_tag_response.data, tag_name); + + assert_json_ok(response); +} + +pub fn assert_deleted_tag_response(response: &TextResponse, tag_id: TagId) { + let deleted_tag_response: DeletedTagResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a DeletedTagResponse", response.body)); + + assert_eq!(deleted_tag_response.data, tag_id); + + assert_json_ok(response); +} diff --git a/tests/common/contexts/tag/mod.rs b/tests/common/contexts/tag/mod.rs index 6f27f51d..cfe5dd24 100644 --- a/tests/common/contexts/tag/mod.rs +++ b/tests/common/contexts/tag/mod.rs @@ -1,3 +1,4 @@ +pub mod asserts; pub mod fixtures; pub mod forms; pub mod responses; diff --git a/tests/common/contexts/tag/responses.rs b/tests/common/contexts/tag/responses.rs index 5029257e..df4cc9ff 100644 --- a/tests/common/contexts/tag/responses.rs +++ b/tests/common/contexts/tag/responses.rs @@ -1,5 +1,19 @@ use serde::Deserialize; +// code-review: we should always include a API resource in the `data`attribute. +// +// ``` +// pub struct DeletedTagResponse { +// pub data: DeletedTag, +// } +// +// pub struct DeletedTag { +// pub tag_id: i64, +// } +// ``` +// +// This way the API client knows what's the meaning of the `data` attribute. + #[derive(Deserialize)] pub struct AddedTagResponse { pub data: String, @@ -7,7 +21,7 @@ pub struct AddedTagResponse { #[derive(Deserialize)] pub struct DeletedTagResponse { - pub data: i64, // tag_id + pub data: i64, } #[derive(Deserialize, Debug)] diff --git a/tests/e2e/contexts/tag/contract.rs b/tests/e2e/contexts/tag/contract.rs index e209758a..53b4356d 100644 --- a/tests/e2e/contexts/tag/contract.rs +++ b/tests/e2e/contexts/tag/contract.rs @@ -181,3 +181,234 @@ async fn it_should_not_allow_guests_to_delete_tags() { assert_eq!(response.status, 401); } + +mod with_axum_implementation { + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::asserts::assert_json_ok; + use crate::common::client::Client; + use crate::common::contexts::tag::asserts::{assert_added_tag_response, assert_deleted_tag_response}; + use crate::common::contexts::tag::fixtures::random_tag_name; + use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; + use crate::common::contexts::tag::responses::ListResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::tag::steps::{add_random_tag, add_tag}; + use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_tags().await; + + assert_json_ok(&response); + } + + #[tokio::test] + async fn it_should_return_a_tag_list() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // Add a tag + let tag_name = random_tag_name(); + let response = add_tag(&tag_name, &env).await; + assert_eq!(response.status, 200); + + let response = client.get_tags().await; + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + // There should be at least the tag we added. + // Since this is an E2E test that could be executed in a shred env, + // there might be more tags. + assert!(!res.data.is_empty()); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); + } + + #[tokio::test] + async fn it_should_not_allow_adding_a_new_tag_to_unauthenticated_users() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client + .add_tag(AddTagForm { + name: "TAG NAME".to_string(), + }) + .await; + + assert_eq!(response.status, 401); + } + + #[tokio::test] + async fn it_should_not_allow_adding_a_new_tag_to_non_admins() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let response = client + .add_tag(AddTagForm { + name: "TAG NAME".to_string(), + }) + .await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_allow_admins_to_add_new_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let tag_name = random_tag_name(); + + let response = client + .add_tag(AddTagForm { + name: tag_name.to_string(), + }) + .await; + + assert_added_tag_response(&response, &tag_name); + } + + #[tokio::test] + async fn it_should_allow_adding_duplicated_tags() { + // code-review: is this an intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + // Add a tag + let random_tag_name = random_tag_name(); + let response = add_tag(&random_tag_name, &env).await; + assert_eq!(response.status, 200); + + // Try to add the same tag again + let response = add_tag(&random_tag_name, &env).await; + assert_eq!(response.status, 200); + } + + #[tokio::test] + async fn it_should_allow_adding_a_tag_with_an_empty_name() { + // code-review: is this an intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let empty_tag_name = String::new(); + let response = add_tag(&empty_tag_name, &env).await; + assert_eq!(response.status, 200); + } + + #[tokio::test] + async fn it_should_allow_admins_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_deleted_tag_response(&response, tag_id); + } + + #[tokio::test] + async fn it_should_not_allow_non_admins_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_non_admin = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_not_allow_guests_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_eq!(response.status, 401); + } +} From dc469c431f3c40a06e2fb08ecf0908a61dfccf95 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jun 2023 15:26:46 +0100 Subject: [PATCH 221/357] refactor(api): [#181] Axum API, settings contex --- src/web/api/v1/contexts/settings/handlers.rs | 68 +++++++++++ src/web/api/v1/contexts/settings/mod.rs | 2 + src/web/api/v1/contexts/settings/routes.rs | 19 +++ src/web/api/v1/routes.rs | 5 +- tests/e2e/contexts/settings/contract.rs | 118 +++++++++++++++++++ 5 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/web/api/v1/contexts/settings/handlers.rs create mode 100644 src/web/api/v1/contexts/settings/routes.rs diff --git a/src/web/api/v1/contexts/settings/handlers.rs b/src/web/api/v1/contexts/settings/handlers.rs new file mode 100644 index 00000000..17144c63 --- /dev/null +++ b/src/web/api/v1/contexts/settings/handlers.rs @@ -0,0 +1,68 @@ +//! API handlers for the the [`category`](crate::web::api::v1::contexts::category) API +//! context. +use std::sync::Arc; + +use axum::extract::{self, State}; +use axum::response::Json; + +use crate::common::AppData; +use crate::config::{ConfigurationPublic, TorrustBackend}; +use crate::errors::ServiceError; +use crate::web::api::v1::extractors::bearer_token::Extract; +use crate::web::api::v1::responses::{self, OkResponse}; + +/// Get all settings. +/// +/// # Errors +/// +/// This function will return an error if the user does not have permission to +/// view all the settings. +#[allow(clippy::unused_async)] +pub async fn get_all_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, +) -> Result>, ServiceError> { + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + let all_settings = app_data.settings_service.get_all(&user_id).await?; + + Ok(Json(responses::OkResponse { data: all_settings })) +} + +/// Get public Settings. +#[allow(clippy::unused_async)] +pub async fn get_public_handler(State(app_data): State>) -> Json> { + let public_settings = app_data.settings_service.get_public().await; + + Json(responses::OkResponse { data: public_settings }) +} + +/// Get website name. +#[allow(clippy::unused_async)] +pub async fn get_site_name_handler(State(app_data): State>) -> Json> { + let site_name = app_data.settings_service.get_site_name().await; + + Json(responses::OkResponse { data: site_name }) +} + +/// Update all the settings. +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// - The user does not have permission to update the settings. +/// - The settings could not be updated because they were loaded from env vars. +/// See +#[allow(clippy::unused_async)] +pub async fn update_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + extract::Json(torrust_backend): extract::Json, +) -> Result>, ServiceError> { + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + let new_settings = app_data.settings_service.update_all(torrust_backend, &user_id).await?; + + Ok(Json(responses::OkResponse { data: new_settings })) +} diff --git a/src/web/api/v1/contexts/settings/mod.rs b/src/web/api/v1/contexts/settings/mod.rs index 70cd94b2..40f511f2 100644 --- a/src/web/api/v1/contexts/settings/mod.rs +++ b/src/web/api/v1/contexts/settings/mod.rs @@ -167,3 +167,5 @@ //! //! Refer to the [`ConfigurationPublic`](crate::config::ConfigurationPublic) //! struct for more information about the response attributes. +pub mod handlers; +pub mod routes; diff --git a/src/web/api/v1/contexts/settings/routes.rs b/src/web/api/v1/contexts/settings/routes.rs new file mode 100644 index 00000000..baffa4c2 --- /dev/null +++ b/src/web/api/v1/contexts/settings/routes.rs @@ -0,0 +1,19 @@ +//! API routes for the [`settings`](crate::web::api::v1::contexts::settings) API context. +//! +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::settings). +use std::sync::Arc; + +use axum::routing::{get, post}; +use axum::Router; + +use super::handlers::{get_all_handler, get_public_handler, get_site_name_handler, update_handler}; +use crate::common::AppData; + +/// Routes for the [`category`](crate::web::api::v1::contexts::category) API context. +pub fn router(app_data: Arc) -> Router { + Router::new() + .route("/", get(get_all_handler).with_state(app_data.clone())) + .route("/name", get(get_site_name_handler).with_state(app_data.clone())) + .route("/public", get(get_public_handler).with_state(app_data.clone())) + .route("/", post(update_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 1b347d18..008722c9 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -6,7 +6,7 @@ use axum::Router; use super::contexts::about::handlers::about_page_handler; //use tower_http::cors::CorsLayer; -use super::contexts::{about, tag}; +use super::contexts::{about, settings, tag}; use super::contexts::{category, user}; use crate::common::AppData; @@ -22,7 +22,8 @@ pub fn router(app_data: Arc) -> Router { .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())) - .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())); + .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())) + .nest("/settings", settings::routes::router(app_data.clone())); Router::new() .route("/", get(about_page_handler).with_state(app_data)) diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 0802512a..174ae6c1 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -94,3 +94,121 @@ async fn it_should_allow_admins_to_update_all_the_settings() { } assert_eq!(response.status, 200); } + +mod with_axum_implementation { + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::asserts::assert_json_ok; + use crate::common::client::Client; + use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::user::steps::new_logged_in_admin; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_guests_to_get_the_public_settings() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_public_settings().await; + + let res: PublicSettingsResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a PublicSettingsResponse", response.body)); + + assert_eq!( + res.data, + Public { + website_name: env.server_settings().unwrap().website.name, + tracker_url: env.server_settings().unwrap().tracker.url, + tracker_mode: env.server_settings().unwrap().tracker.mode, + email_on_signup: env.server_settings().unwrap().auth.email_on_signup, + } + ); + + assert_json_ok(&response); + } + + #[tokio::test] + async fn it_should_allow_guests_to_get_the_site_name() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_site_name().await; + + let res: SiteNameResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, "Torrust"); + + assert_json_ok(&response); + } + + #[tokio::test] + async fn it_should_allow_admins_to_get_all_the_settings() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let response = client.get_settings().await; + + let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, env.server_settings().unwrap()); + + assert_json_ok(&response); + } + + #[tokio::test] + async fn it_should_allow_admins_to_update_all_the_settings() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.is_isolated() { + // This test can't be executed in a non-isolated environment because + // it will change the settings for all the other tests. + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let mut new_settings = env.server_settings().unwrap(); + + new_settings.website.name = "UPDATED NAME".to_string(); + + let response = client.update_settings(&new_settings).await; + + let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, new_settings); + + assert_json_ok(&response); + } +} From d7f1e34c4d8b2db836e683c17fa566ba54c0e8d0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jun 2023 16:53:11 +0100 Subject: [PATCH 222/357] feat(api): add multipart feature for Axum package We are migrating the API to Axum. We need the `multipart` feature to handle the upload torrent file POST. --- Cargo.lock | 19 +++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f1cbc4cc..17616181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -1675,6 +1676,24 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "multer" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin 0.9.8", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 63fde00c..a8099b25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ text-to-png = "0.2" indexmap = "1.9" thiserror = "1.0" binascii = "0.1" -axum = "0.6.18" +axum = { version = "0.6.18", features = ["multipart"]} hyper = "0.14.26" tower-http = { version = "0.4.0", features = ["cors"]} From 538ebcc18a28f698b85227a677358943a81141fe Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jun 2023 16:58:41 +0100 Subject: [PATCH 223/357] refactor(api): [#182] Axum API, torrent context, upload torrent file --- src/errors.rs | 5 + src/web/api/v1/contexts/category/handlers.rs | 10 +- src/web/api/v1/contexts/category/responses.rs | 10 +- src/web/api/v1/contexts/settings/handlers.rs | 18 +- src/web/api/v1/contexts/tag/handlers.rs | 10 +- src/web/api/v1/contexts/tag/responses.rs | 10 +- src/web/api/v1/contexts/torrent/handlers.rs | 131 ++++ src/web/api/v1/contexts/torrent/mod.rs | 3 + src/web/api/v1/contexts/torrent/responses.rs | 22 + src/web/api/v1/contexts/torrent/routes.rs | 15 + src/web/api/v1/contexts/user/handlers.rs | 16 +- src/web/api/v1/contexts/user/responses.rs | 14 +- src/web/api/v1/responses.rs | 36 +- src/web/api/v1/routes.rs | 5 +- tests/common/asserts.rs | 17 +- tests/common/contexts/category/asserts.rs | 6 +- tests/common/contexts/tag/asserts.rs | 6 +- tests/common/contexts/user/asserts.rs | 12 +- tests/e2e/contexts/category/contract.rs | 8 +- tests/e2e/contexts/settings/contract.rs | 10 +- tests/e2e/contexts/tag/contract.rs | 8 +- tests/e2e/contexts/torrent/contract.rs | 736 ++++++++++++++++++ 22 files changed, 1030 insertions(+), 78 deletions(-) create mode 100644 src/web/api/v1/contexts/torrent/handlers.rs create mode 100644 src/web/api/v1/contexts/torrent/responses.rs create mode 100644 src/web/api/v1/contexts/torrent/routes.rs diff --git a/src/errors.rs b/src/errors.rs index 6f880162..02404896 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -139,6 +139,9 @@ pub enum ServiceError { DatabaseError, } +// Begin ActixWeb error handling +// todo: remove after migration to Axum + #[derive(Serialize, Deserialize)] pub struct ErrorToResponse { pub error: String, @@ -156,6 +159,8 @@ impl ResponseError for ServiceError { } } +// End ActixWeb error handling + impl From for ServiceError { fn from(e: sqlx::Error) -> Self { eprintln!("{e:?}"); diff --git a/src/web/api/v1/contexts/category/handlers.rs b/src/web/api/v1/contexts/category/handlers.rs index 3d09008a..981c4aad 100644 --- a/src/web/api/v1/contexts/category/handlers.rs +++ b/src/web/api/v1/contexts/category/handlers.rs @@ -11,7 +11,7 @@ use crate::common::AppData; use crate::databases::database::{self, Category}; use crate::errors::ServiceError; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self, OkResponse}; +use crate::web::api::v1::responses::{self, OkResponseData}; /// It handles the request to get all the categories. /// @@ -29,9 +29,9 @@ use crate::web::api::v1::responses::{self, OkResponse}; #[allow(clippy::unused_async)] pub async fn get_all_handler( State(app_data): State>, -) -> Result>>, database::Error> { +) -> Result>>, database::Error> { match app_data.category_repository.get_all().await { - Ok(categories) => Ok(Json(responses::OkResponse { data: categories })), + Ok(categories) => Ok(Json(responses::OkResponseData { data: categories })), Err(error) => Err(error), } } @@ -49,7 +49,7 @@ pub async fn add_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(category_form): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; match app_data.category_service.add_category(&category_form.name, &user_id).await { @@ -71,7 +71,7 @@ pub async fn delete_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(category_form): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { // code-review: why do we need to send the whole category object to delete it? // And we should use the ID instead of the name, because the name could change // or we could add support for multiple languages. diff --git a/src/web/api/v1/contexts/category/responses.rs b/src/web/api/v1/contexts/category/responses.rs index cb372801..b1e20d19 100644 --- a/src/web/api/v1/contexts/category/responses.rs +++ b/src/web/api/v1/contexts/category/responses.rs @@ -2,18 +2,18 @@ //! context. use axum::Json; -use crate::web::api::v1::responses::OkResponse; +use crate::web::api::v1::responses::OkResponseData; /// Response after successfully creating a new category. -pub fn added_category(category_name: &str) -> Json> { - Json(OkResponse { +pub fn added_category(category_name: &str) -> Json> { + Json(OkResponseData { data: category_name.to_string(), }) } /// Response after successfully deleting a new category. -pub fn deleted_category(category_name: &str) -> Json> { - Json(OkResponse { +pub fn deleted_category(category_name: &str) -> Json> { + Json(OkResponseData { data: category_name.to_string(), }) } diff --git a/src/web/api/v1/contexts/settings/handlers.rs b/src/web/api/v1/contexts/settings/handlers.rs index 17144c63..feca203a 100644 --- a/src/web/api/v1/contexts/settings/handlers.rs +++ b/src/web/api/v1/contexts/settings/handlers.rs @@ -9,7 +9,7 @@ use crate::common::AppData; use crate::config::{ConfigurationPublic, TorrustBackend}; use crate::errors::ServiceError; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self, OkResponse}; +use crate::web::api::v1::responses::{self, OkResponseData}; /// Get all settings. /// @@ -21,28 +21,28 @@ use crate::web::api::v1::responses::{self, OkResponse}; pub async fn get_all_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; let all_settings = app_data.settings_service.get_all(&user_id).await?; - Ok(Json(responses::OkResponse { data: all_settings })) + Ok(Json(responses::OkResponseData { data: all_settings })) } /// Get public Settings. #[allow(clippy::unused_async)] -pub async fn get_public_handler(State(app_data): State>) -> Json> { +pub async fn get_public_handler(State(app_data): State>) -> Json> { let public_settings = app_data.settings_service.get_public().await; - Json(responses::OkResponse { data: public_settings }) + Json(responses::OkResponseData { data: public_settings }) } /// Get website name. #[allow(clippy::unused_async)] -pub async fn get_site_name_handler(State(app_data): State>) -> Json> { +pub async fn get_site_name_handler(State(app_data): State>) -> Json> { let site_name = app_data.settings_service.get_site_name().await; - Json(responses::OkResponse { data: site_name }) + Json(responses::OkResponseData { data: site_name }) } /// Update all the settings. @@ -59,10 +59,10 @@ pub async fn update_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(torrust_backend): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; let new_settings = app_data.settings_service.update_all(torrust_backend, &user_id).await?; - Ok(Json(responses::OkResponse { data: new_settings })) + Ok(Json(responses::OkResponseData { data: new_settings })) } diff --git a/src/web/api/v1/contexts/tag/handlers.rs b/src/web/api/v1/contexts/tag/handlers.rs index 507e80a9..7944c4a4 100644 --- a/src/web/api/v1/contexts/tag/handlers.rs +++ b/src/web/api/v1/contexts/tag/handlers.rs @@ -12,7 +12,7 @@ use crate::databases::database; use crate::errors::ServiceError; use crate::models::torrent_tag::TorrentTag; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self, OkResponse}; +use crate::web::api::v1::responses::{self, OkResponseData}; /// It handles the request to get all the tags. /// @@ -30,9 +30,9 @@ use crate::web::api::v1::responses::{self, OkResponse}; #[allow(clippy::unused_async)] pub async fn get_all_handler( State(app_data): State>, -) -> Result>>, database::Error> { +) -> Result>>, database::Error> { match app_data.tag_repository.get_all().await { - Ok(tags) => Ok(Json(responses::OkResponse { data: tags })), + Ok(tags) => Ok(Json(responses::OkResponseData { data: tags })), Err(error) => Err(error), } } @@ -50,7 +50,7 @@ pub async fn add_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(add_tag_form): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; match app_data.tag_service.add_tag(&add_tag_form.name, &user_id).await { @@ -72,7 +72,7 @@ pub async fn delete_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(delete_tag_form): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; match app_data.tag_service.delete_tag(&delete_tag_form.tag_id, &user_id).await { diff --git a/src/web/api/v1/contexts/tag/responses.rs b/src/web/api/v1/contexts/tag/responses.rs index 7b4d4120..3b44d51d 100644 --- a/src/web/api/v1/contexts/tag/responses.rs +++ b/src/web/api/v1/contexts/tag/responses.rs @@ -3,18 +3,18 @@ use axum::Json; use crate::models::torrent_tag::TagId; -use crate::web::api::v1::responses::OkResponse; +use crate::web::api::v1::responses::OkResponseData; /// Response after successfully creating a new tag. -pub fn added_tag(tag_name: &str) -> Json> { - Json(OkResponse { +pub fn added_tag(tag_name: &str) -> Json> { + Json(OkResponseData { data: tag_name.to_string(), }) } /// Response after successfully deleting a tag. -pub fn deleted_tag(tag_id: TagId) -> Json> { - Json(OkResponse { +pub fn deleted_tag(tag_id: TagId) -> Json> { + Json(OkResponseData { data: tag_id.to_string(), }) } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs new file mode 100644 index 00000000..794895f6 --- /dev/null +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -0,0 +1,131 @@ +//! API handlers for the [`torrent`](crate::web::api::v1::contexts::torrent) API +//! context. +use std::io::{Cursor, Write}; +use std::sync::Arc; + +use axum::extract::{Multipart, State}; +use axum::response::{IntoResponse, Response}; + +use super::responses::new_torrent_response; +use crate::common::AppData; +use crate::errors::ServiceError; +use crate::models::torrent::TorrentRequest; +use crate::models::torrent_tag::TagId; +use crate::routes::torrent::Create; +use crate::utils::parse_torrent; +use crate::web::api::v1::extractors::bearer_token::Extract; + +/// Upload a new torrent file to the Index +/// +/// # Errors +/// +/// This function will return an error if +/// +/// - The user does not have permission to upload the torrent file. +/// - The submitted torrent file is not a valid torrent file. +#[allow(clippy::unused_async)] +pub async fn upload_torrent_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + multipart: Multipart, +) -> Response { + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(err) => return err.into_response(), + }; + + let torrent_request = match get_torrent_request_from_payload(multipart).await { + Ok(torrent_request) => torrent_request, + Err(err) => return err.into_response(), + }; + + let info_hash = torrent_request.torrent.info_hash().clone(); + + match app_data.torrent_service.add_torrent(torrent_request, user_id).await { + Ok(torrent_id) => new_torrent_response(torrent_id, &info_hash).into_response(), + Err(error) => error.into_response(), + } +} + +/// Extracts the [`TorrentRequest`] from the multipart form payload. +async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { + let torrent_buffer = vec![0u8]; + let mut torrent_cursor = Cursor::new(torrent_buffer); + + let mut title = String::new(); + let mut description = String::new(); + let mut category = String::new(); + let mut tags: Vec = vec![]; + + while let Some(mut field) = payload.next_field().await.unwrap() { + let name = field.name().unwrap().clone(); + + match name { + "title" => { + let data = field.bytes().await.unwrap(); + if data.is_empty() { + continue; + }; + title = String::from_utf8(data.to_vec()).map_err(|_| ServiceError::BadRequest)?; + } + "description" => { + let data = field.bytes().await.unwrap(); + if data.is_empty() { + continue; + }; + description = String::from_utf8(data.to_vec()).map_err(|_| ServiceError::BadRequest)?; + } + "category" => { + let data = field.bytes().await.unwrap(); + if data.is_empty() { + continue; + }; + category = String::from_utf8(data.to_vec()).map_err(|_| ServiceError::BadRequest)?; + } + "tags" => { + let data = field.bytes().await.unwrap(); + if data.is_empty() { + continue; + }; + let string_data = String::from_utf8(data.to_vec()).map_err(|_| ServiceError::BadRequest)?; + tags = serde_json::from_str(&string_data).map_err(|_| ServiceError::BadRequest)?; + } + "torrent" => { + let content_type = field.content_type().unwrap().clone(); + + if content_type != "application/x-bittorrent" { + return Err(ServiceError::InvalidFileType); + } + + while let Some(chunk) = field.chunk().await.map_err(|_| (ServiceError::BadRequest))? { + torrent_cursor.write_all(&chunk)?; + } + } + _ => {} + } + } + + let fields = Create { + title, + description, + category, + tags, + }; + + fields.verify()?; + + let position = usize::try_from(torrent_cursor.position()).map_err(|_| ServiceError::InvalidTorrentFile)?; + let inner = torrent_cursor.get_ref(); + + let torrent = parse_torrent::decode_torrent(&inner[..position]).map_err(|_| ServiceError::InvalidTorrentFile)?; + + // Make sure that the pieces key has a length that is a multiple of 20 + // code-review: I think we could put this inside the service. + if let Some(pieces) = torrent.info.pieces.as_ref() { + if pieces.as_ref().len() % 20 != 0 { + return Err(ServiceError::InvalidTorrentPiecesLength); + } + } + + Ok(TorrentRequest { fields, torrent }) +} diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs index 77d08b8a..3e533153 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -328,3 +328,6 @@ //! //! Refer to the [`DeletedTorrentResponse`](crate::models::response::DeletedTorrentResponse) //! struct for more information about the response attributes. +pub mod handlers; +pub mod responses; +pub mod routes; diff --git a/src/web/api/v1/contexts/torrent/responses.rs b/src/web/api/v1/contexts/torrent/responses.rs new file mode 100644 index 00000000..9e338f32 --- /dev/null +++ b/src/web/api/v1/contexts/torrent/responses.rs @@ -0,0 +1,22 @@ +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::models::torrent::TorrentId; +use crate::web::api::v1::responses::OkResponseData; + +#[allow(clippy::module_name_repetitions)] +#[derive(Serialize, Deserialize, Debug)] +pub struct NewTorrentResponseData { + pub torrent_id: TorrentId, + pub info_hash: String, +} + +/// Response after successfully uploading a new torrent. +pub fn new_torrent_response(torrent_id: TorrentId, info_hash: &str) -> Json> { + Json(OkResponseData { + data: NewTorrentResponseData { + torrent_id, + info_hash: info_hash.to_owned(), + }, + }) +} diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs new file mode 100644 index 00000000..344fd995 --- /dev/null +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -0,0 +1,15 @@ +//! API routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +//! +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::tag). +use std::sync::Arc; + +use axum::routing::post; +use axum::Router; + +use super::handlers::upload_torrent_handler; +use crate::common::AppData; + +/// Routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +pub fn router_for_single_resources(app_data: Arc) -> Router { + Router::new().route("/upload", post(upload_torrent_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index 543b87f9..0ab4e995 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -11,7 +11,7 @@ use super::responses::{self, NewUser, TokenResponse}; use crate::common::AppData; use crate::errors::ServiceError; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::OkResponse; +use crate::web::api::v1::responses::OkResponseData; // Registration @@ -25,7 +25,7 @@ pub async fn registration_handler( State(app_data): State>, Host(host_from_header): Host, extract::Json(registration_form): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { let api_base_url = app_data .cfg .get_api_base_url() @@ -68,7 +68,7 @@ pub async fn email_verification_handler(State(app_data): State>, Pa pub async fn login_handler( State(app_data): State>, extract::Json(login_form): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { match app_data .authentication_service .login(&login_form.login, &login_form.password) @@ -91,9 +91,9 @@ pub async fn login_handler( pub async fn verify_token_handler( State(app_data): State>, extract::Json(token): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { match app_data.json_web_token.verify(&token.token).await { - Ok(_) => Ok(axum::Json(OkResponse { + Ok(_) => Ok(axum::Json(OkResponseData { data: "Token is valid.".to_string(), })), Err(error) => Err(error), @@ -115,7 +115,7 @@ pub struct UsernameParam(pub String); pub async fn renew_token_handler( State(app_data): State>, extract::Json(token): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { match app_data.authentication_service.renew_token(&token.token).await { Ok((token, user_compact)) => Ok(responses::renewed_token(token, user_compact)), Err(error) => Err(error), @@ -135,13 +135,13 @@ pub async fn ban_handler( State(app_data): State>, Path(to_be_banned_username): Path, Extract(maybe_bearer_token): Extract, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { // todo: add reason and `date_expiry` parameters to request let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; match app_data.ban_service.ban_user(&to_be_banned_username.0, &user_id).await { - Ok(_) => Ok(axum::Json(OkResponse { + Ok(_) => Ok(axum::Json(OkResponseData { data: format!("Banned user: {}", to_be_banned_username.0), })), Err(error) => Err(error), diff --git a/src/web/api/v1/contexts/user/responses.rs b/src/web/api/v1/contexts/user/responses.rs index 731db068..17a06bdf 100644 --- a/src/web/api/v1/contexts/user/responses.rs +++ b/src/web/api/v1/contexts/user/responses.rs @@ -2,7 +2,7 @@ use axum::Json; use serde::{Deserialize, Serialize}; use crate::models::user::{UserCompact, UserId}; -use crate::web::api::v1::responses::OkResponse; +use crate::web::api::v1::responses::OkResponseData; // Registration @@ -12,8 +12,8 @@ pub struct NewUser { } /// Response after successfully creating a new user. -pub fn added_user(user_id: i64) -> Json> { - Json(OkResponse { +pub fn added_user(user_id: i64) -> Json> { + Json(OkResponseData { data: NewUser { user_id }, }) } @@ -28,8 +28,8 @@ pub struct TokenResponse { } /// Response after successfully logging in a user. -pub fn logged_in_user(token: String, user_compact: UserCompact) -> Json> { - Json(OkResponse { +pub fn logged_in_user(token: String, user_compact: UserCompact) -> Json> { + Json(OkResponseData { data: TokenResponse { token, username: user_compact.username, @@ -39,8 +39,8 @@ pub fn logged_in_user(token: String, user_compact: UserCompact) -> Json Json> { - Json(OkResponse { +pub fn renewed_token(token: String, user_compact: UserCompact) -> Json> { + Json(OkResponseData { data: TokenResponse { token, username: user_compact.username, diff --git a/src/web/api/v1/responses.rs b/src/web/api/v1/responses.rs index de9701c0..3adb7442 100644 --- a/src/web/api/v1/responses.rs +++ b/src/web/api/v1/responses.rs @@ -1,25 +1,49 @@ //! Generic responses for the API. use axum::response::{IntoResponse, Response}; +use hyper::{header, StatusCode}; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::databases::database; use crate::errors::{http_status_code_for_service_error, map_database_error_to_service_error, ServiceError}; #[derive(Serialize, Deserialize, Debug)] -pub struct OkResponse { +pub struct OkResponseData { pub data: T, } +#[derive(Serialize, Deserialize, Debug)] +pub struct ErrorResponseData { + pub error: String, +} + +impl IntoResponse for ServiceError { + fn into_response(self) -> Response { + json_error_response( + http_status_code_for_service_error(&self), + &ErrorResponseData { error: self.to_string() }, + ) + } +} + impl IntoResponse for database::Error { fn into_response(self) -> Response { let service_error = map_database_error_to_service_error(&self); - (http_status_code_for_service_error(&service_error), service_error.to_string()).into_response() + json_error_response( + http_status_code_for_service_error(&service_error), + &ErrorResponseData { + error: service_error.to_string(), + }, + ) } } -impl IntoResponse for ServiceError { - fn into_response(self) -> Response { - (http_status_code_for_service_error(&self), self.to_string()).into_response() - } +fn json_error_response(status_code: StatusCode, error_response_data: &ErrorResponseData) -> Response { + ( + status_code, + [(header::CONTENT_TYPE, "application/json")], + json!(error_response_data).to_string(), + ) + .into_response() } diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 008722c9..10c27d20 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -6,7 +6,7 @@ use axum::Router; use super::contexts::about::handlers::about_page_handler; //use tower_http::cors::CorsLayer; -use super::contexts::{about, settings, tag}; +use super::contexts::{about, settings, tag, torrent}; use super::contexts::{category, user}; use crate::common::AppData; @@ -23,7 +23,8 @@ pub fn router(app_data: Arc) -> Router { .nest("/category", category::routes::router(app_data.clone())) .nest("/tag", tag::routes::router_for_single_resources(app_data.clone())) .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())) - .nest("/settings", settings::routes::router(app_data.clone())); + .nest("/settings", settings::routes::router(app_data.clone())) + .nest("/torrent", torrent::routes::router_for_single_resources(app_data.clone())); Router::new() .route("/", get(about_page_handler).with_state(app_data)) diff --git a/tests/common/asserts.rs b/tests/common/asserts.rs index 60df0956..cd326d5f 100644 --- a/tests/common/asserts.rs +++ b/tests/common/asserts.rs @@ -1,5 +1,7 @@ // Text responses +use torrust_index_backend::web::api::v1::responses::ErrorResponseData; + use super::responses::TextResponse; pub fn assert_response_title(response: &TextResponse, title: &str) { @@ -27,9 +29,22 @@ pub fn _assert_text_bad_request(response: &TextResponse) { // JSON responses -pub fn assert_json_ok(response: &TextResponse) { +pub fn assert_json_ok_response(response: &TextResponse) { + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } assert_eq!(response.status, 200); +} + +pub fn assert_json_error_response(response: &TextResponse, error: &str) { + assert_eq!(response.body, "{\"error\":\"This torrent title has already been used.\"}"); + + let error_response_data: ErrorResponseData = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a ErrorResponseData", response.body)); + + assert_eq!(error_response_data.error, error); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "application/json"); } + assert_eq!(response.status, 400); } diff --git a/tests/common/contexts/category/asserts.rs b/tests/common/contexts/category/asserts.rs index ae47bdca..2568531d 100644 --- a/tests/common/contexts/category/asserts.rs +++ b/tests/common/contexts/category/asserts.rs @@ -1,4 +1,4 @@ -use crate::common::asserts::assert_json_ok; +use crate::common::asserts::assert_json_ok_response; use crate::common::contexts::category::responses::{AddedCategoryResponse, DeletedCategoryResponse}; use crate::common::responses::TextResponse; @@ -8,7 +8,7 @@ pub fn assert_added_category_response(response: &TextResponse, category_name: &s assert_eq!(added_category_response.data, category_name); - assert_json_ok(response); + assert_json_ok_response(response); } pub fn assert_deleted_category_response(response: &TextResponse, category_name: &str) { @@ -17,5 +17,5 @@ pub fn assert_deleted_category_response(response: &TextResponse, category_name: assert_eq!(deleted_category_response.data, category_name); - assert_json_ok(response); + assert_json_ok_response(response); } diff --git a/tests/common/contexts/tag/asserts.rs b/tests/common/contexts/tag/asserts.rs index cd796e91..3d1347df 100644 --- a/tests/common/contexts/tag/asserts.rs +++ b/tests/common/contexts/tag/asserts.rs @@ -1,6 +1,6 @@ use torrust_index_backend::models::torrent_tag::TagId; -use crate::common::asserts::assert_json_ok; +use crate::common::asserts::assert_json_ok_response; use crate::common::contexts::tag::responses::{AddedTagResponse, DeletedTagResponse}; use crate::common::responses::TextResponse; @@ -10,7 +10,7 @@ pub fn assert_added_tag_response(response: &TextResponse, tag_name: &str) { assert_eq!(added_tag_response.data, tag_name); - assert_json_ok(response); + assert_json_ok_response(response); } pub fn assert_deleted_tag_response(response: &TextResponse, tag_id: TagId) { @@ -19,5 +19,5 @@ pub fn assert_deleted_tag_response(response: &TextResponse, tag_id: TagId) { assert_eq!(deleted_tag_response.data, tag_id); - assert_json_ok(response); + assert_json_ok_response(response); } diff --git a/tests/common/contexts/user/asserts.rs b/tests/common/contexts/user/asserts.rs index bcf92f5f..dfa5352b 100644 --- a/tests/common/contexts/user/asserts.rs +++ b/tests/common/contexts/user/asserts.rs @@ -1,6 +1,6 @@ use super::forms::RegistrationForm; use super::responses::LoggedInUserData; -use crate::common::asserts::assert_json_ok; +use crate::common::asserts::assert_json_ok_response; use crate::common::contexts::user::responses::{ AddedUserResponse, BannedUserResponse, SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, }; @@ -9,7 +9,7 @@ use crate::common::responses::TextResponse; pub fn assert_added_user_response(response: &TextResponse) { let _added_user_response: AddedUserResponse = serde_json::from_str(&response.body) .unwrap_or_else(|_| panic!("response {:#?} should be a AddedUserResponse", response.body)); - assert_json_ok(response); + assert_json_ok_response(response); } pub fn assert_successful_login_response(response: &TextResponse, registered_user: &RegistrationForm) { @@ -20,7 +20,7 @@ pub fn assert_successful_login_response(response: &TextResponse, registered_user assert_eq!(logged_in_user.username, registered_user.username); - assert_json_ok(response); + assert_json_ok_response(response); } pub fn assert_token_verified_response(response: &TextResponse) { @@ -29,7 +29,7 @@ pub fn assert_token_verified_response(response: &TextResponse) { assert_eq!(token_verified_response.data, "Token is valid."); - assert_json_ok(response); + assert_json_ok_response(response); } pub fn assert_token_renewal_response(response: &TextResponse, logged_in_user: &LoggedInUserData) { @@ -45,7 +45,7 @@ pub fn assert_token_renewal_response(response: &TextResponse, logged_in_user: &L } ); - assert_json_ok(response); + assert_json_ok_response(response); } pub fn assert_banned_user_response(response: &TextResponse, registered_user: &RegistrationForm) { @@ -57,5 +57,5 @@ pub fn assert_banned_user_response(response: &TextResponse, registered_user: &Re format!("Banned user: {}", registered_user.username) ); - assert_json_ok(response); + assert_json_ok_response(response); } diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index d1970063..de6b57e6 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -1,7 +1,7 @@ //! API contract for `category` context. use torrust_index_backend::web::api; -use crate::common::asserts::assert_json_ok; +use crate::common::asserts::assert_json_ok_response; use crate::common::client::Client; use crate::common::contexts::category::fixtures::random_category_name; use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; @@ -18,7 +18,7 @@ async fn it_should_return_an_empty_category_list_when_there_are_no_categories() let response = client.get_categories().await; - assert_json_ok(&response); + assert_json_ok_response(&response); } #[tokio::test] @@ -213,7 +213,7 @@ mod with_axum_implementation { use torrust_index_backend::web::api; - use crate::common::asserts::assert_json_ok; + use crate::common::asserts::assert_json_ok_response; use crate::common::client::Client; use crate::common::contexts::category::asserts::{assert_added_category_response, assert_deleted_category_response}; use crate::common::contexts::category::fixtures::random_category_name; @@ -233,7 +233,7 @@ mod with_axum_implementation { let response = client.get_categories().await; - assert_json_ok(&response); + assert_json_ok_response(&response); } #[tokio::test] diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 174ae6c1..97e6082e 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -100,7 +100,7 @@ mod with_axum_implementation { use torrust_index_backend::web::api; - use crate::common::asserts::assert_json_ok; + use crate::common::asserts::assert_json_ok_response; use crate::common::client::Client; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; @@ -134,7 +134,7 @@ mod with_axum_implementation { } ); - assert_json_ok(&response); + assert_json_ok_response(&response); } #[tokio::test] @@ -155,7 +155,7 @@ mod with_axum_implementation { assert_eq!(res.data, "Torrust"); - assert_json_ok(&response); + assert_json_ok_response(&response); } #[tokio::test] @@ -177,7 +177,7 @@ mod with_axum_implementation { assert_eq!(res.data, env.server_settings().unwrap()); - assert_json_ok(&response); + assert_json_ok_response(&response); } #[tokio::test] @@ -209,6 +209,6 @@ mod with_axum_implementation { assert_eq!(res.data, new_settings); - assert_json_ok(&response); + assert_json_ok_response(&response); } } diff --git a/tests/e2e/contexts/tag/contract.rs b/tests/e2e/contexts/tag/contract.rs index 53b4356d..a64d7359 100644 --- a/tests/e2e/contexts/tag/contract.rs +++ b/tests/e2e/contexts/tag/contract.rs @@ -1,7 +1,7 @@ //! API contract for `tag` context. use torrust_index_backend::web::api; -use crate::common::asserts::assert_json_ok; +use crate::common::asserts::assert_json_ok_response; use crate::common::client::Client; use crate::common::contexts::tag::fixtures::random_tag_name; use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; @@ -18,7 +18,7 @@ async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { let response = client.get_tags().await; - assert_json_ok(&response); + assert_json_ok_response(&response); } #[tokio::test] @@ -187,7 +187,7 @@ mod with_axum_implementation { use torrust_index_backend::web::api; - use crate::common::asserts::assert_json_ok; + use crate::common::asserts::assert_json_ok_response; use crate::common::client::Client; use crate::common::contexts::tag::asserts::{assert_added_tag_response, assert_deleted_tag_response}; use crate::common::contexts::tag::fixtures::random_tag_name; @@ -212,7 +212,7 @@ mod with_axum_implementation { let response = client.get_tags().await; - assert_json_ok(&response); + assert_json_ok_response(&response); } #[tokio::test] diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 955f5154..d6fb197e 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -621,3 +621,739 @@ mod for_authenticated_users { } } } + +mod with_axum_implementation { + + mod for_guests { + /* + + use std::env; + + use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::category::fixtures::software_predefined_category_id; + use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::common::contexts::torrent::requests::InfoHash; + use crate::common::contexts::torrent::responses::{ + Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, + }; + use crate::common::http::{Query, QueryParam}; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::torrent::asserts::expected_torrent; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_guests_to_get_torrents() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.get_torrents(Query::empty()).await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + assert!(torrent_list_response.data.total > 0); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_to_get_torrents_with_pagination() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + // Given we insert two torrents + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request only one torrent per page + let response = client + .get_torrents(Query::with_params([QueryParam::new("page_size", "1")].to_vec())) + .await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should have only one torrent per page + assert_eq!(torrent_list_response.data.results.len(), 1); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_to_limit_the_number_of_torrents_per_request() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + let max_torrent_page_size = 30; + + // Given we insert one torrent more than the page size limit + for _ in 0..max_torrent_page_size { + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request more torrents than the page size limit + let response = client + .get_torrents(Query::with_params( + [QueryParam::new("page_size", &format!("{}", (max_torrent_page_size + 1)))].to_vec(), + )) + .await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should get only the page size limit + assert_eq!(torrent_list_response.data.results.len(), max_torrent_page_size); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_return_a_default_amount_of_torrents_per_request_if_no_page_size_is_provided() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + let default_torrent_page_size = 10; + + // Given we insert one torrent more than the default page size + for _ in 0..default_torrent_page_size { + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request more torrents than the default page size limit + let response = client.get_torrents(Query::empty()).await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should get only the default number of torrents per page + assert_eq!(torrent_list_response.data.results.len(), default_torrent_page_size); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.get_torrent(&test_torrent.info_hash()).await; + + let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); + + let tracker_url = env.server_settings().unwrap().tracker.url; + let encoded_tracker_url = urlencoding::encode(&tracker_url); + + let expected_torrent = TorrentDetails { + torrent_id: uploaded_torrent.torrent_id, + uploader: uploader.username, + info_hash: test_torrent.file_info.info_hash.to_uppercase(), + title: test_torrent.index_info.title.clone(), + description: test_torrent.index_info.description, + category: Category { + category_id: software_predefined_category_id(), + name: test_torrent.index_info.category, + num_torrents: 19, // Ignored in assertion + }, + upload_date: "2023-04-27 07:56:08".to_string(), // Ignored in assertion + file_size: test_torrent.file_info.content_size, + seeders: 0, + leechers: 0, + files: vec![File { + path: vec![test_torrent.file_info.files[0].clone()], + // Using one file torrent for testing: content_size = first file size + length: test_torrent.file_info.content_size, + md5sum: None, + }], + // code-review: why is this duplicated? It seems that is adding the + // same tracker twice because first ti adds all trackers and then + // it adds the tracker with the personal announce url, if the user + // is logged in. If the user is not logged in, it adds the default + // tracker again, and it ends up with two trackers. + trackers: vec![tracker_url.clone(), tracker_url.clone()], + magnet_link: format!( + // cspell:disable-next-line + "magnet:?xt=urn:btih:{}&dn={}&tr={}&tr={}", + test_torrent.file_info.info_hash.to_uppercase(), + urlencoding::encode(&test_torrent.index_info.title), + encoded_tracker_url, + encoded_tracker_url + ), + }; + + assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.download_torrent(&test_torrent.info_hash()).await; + + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + let uploaded_torrent = + decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); + let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; + assert_eq!(torrent, expected_torrent); + assert!(response.is_bittorrent_and_ok()); + } + + #[tokio::test] + async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let non_existing_info_hash: InfoHash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); + + let response = client.download_torrent(&non_existing_info_hash).await; + + // code-review: should this be 404? + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn it_should_not_allow_guests_to_delete_torrents() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.delete_torrent(&test_torrent.info_hash()).await; + + assert_eq!(response.status, 401); + } + + */ + } + + mod for_authenticated_users { + + use std::env; + + //use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::web::api; + + //use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; + use crate::common::asserts::assert_json_error_response; + use crate::common::client::Client; + use crate::common::contexts::torrent::fixtures::random_torrent; + use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; + use crate::common::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + //use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_authenticated_users_to_upload_new_torrents() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let test_torrent = random_torrent(); + let info_hash = test_torrent.info_hash().clone(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!( + uploaded_torrent_response.data.info_hash.to_lowercase(), + info_hash.to_lowercase() + ); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + test_torrent.index_info.category = "non-existing-category".to_string(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same title as the first one + let mut second_torrent = random_torrent(); + second_torrent.index_info.title = first_torrent_title; + let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_json_error_response(&response, "This torrent title has already been used."); + } + + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let mut first_torrent_clone = first_torrent.clone(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same info-hash as the first one. + // We need to change the title otherwise the torrent will be rejected + // because of the duplicate title. + first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); + let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + /* + + #[tokio::test] + async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + // Given a previously uploaded torrent + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + + // And a logged in user who is going to download the torrent + let downloader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); + + // When the user downloads the torrent + let response = client.download_torrent(&test_torrent.info_hash()).await; + + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + + // Then the torrent should have the personal announce URL + let tracker_key = get_user_tracker_key(&downloader, &env) + .await + .expect("uploader should have a valid tracker key"); + + let tracker_url = env.server_settings().unwrap().tracker.url; + + assert_eq!( + torrent.announce.unwrap(), + build_announce_url(&tracker_url, &Some(tracker_key)) + ); + } + + */ + + mod and_non_admins { + /* + + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_not_allow_non_admins_to_delete_torrents() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let response = client.delete_torrent(&test_torrent.info_hash()).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + // Given a users uploads a torrent + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + // Then another non admin user should not be able to update the torrent + let not_the_uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), ¬_the_uploader.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + &test_torrent.info_hash(), + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + assert_eq!(response.status, 403); + } + + */ + } + + mod and_torrent_owners { + /* + + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::new_logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_torrent_owners_to_update_their_torrents() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + &test_torrent.info_hash(), + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + + */ + } + + mod and_admins { + /* + + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); + + let response = client.delete_torrent(&test_torrent.info_hash()).await; + + let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(deleted_torrent_response.data.torrent_id, uploaded_torrent.torrent_id); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_admins_to_update_someone_elses_torrents() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + &test_torrent.info_hash(), + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + + */ + } + } +} From ed533b7dcdd12ecdedd39c164ff9090493f45590 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jun 2023 12:07:59 +0100 Subject: [PATCH 224/357] refactor(api): [#182] Axum API, torrent context, download torrent file --- src/auth.rs | 2 +- src/web/api/v1/contexts/torrent/handlers.rs | 65 +++++++++++++++++++- src/web/api/v1/contexts/torrent/responses.rs | 7 +++ src/web/api/v1/contexts/torrent/routes.rs | 14 +++-- tests/e2e/contexts/torrent/contract.rs | 29 ++++----- 5 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index e782fd59..4550a5ae 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -77,7 +77,7 @@ impl Authentication { // Begin Axum - /// Get User id from bearer token + /// Get logged-in user ID from bearer token /// /// # Errors /// diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 794895f6..fd0f945f 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -1,19 +1,23 @@ //! API handlers for the [`torrent`](crate::web::api::v1::contexts::torrent) API //! context. use std::io::{Cursor, Write}; +use std::str::FromStr; use std::sync::Arc; -use axum::extract::{Multipart, State}; +use axum::extract::{Multipart, Path, State}; use axum::response::{IntoResponse, Response}; +use serde::Deserialize; -use super::responses::new_torrent_response; +use super::responses::{new_torrent_response, torrent_file_response}; use crate::common::AppData; use crate::errors::ServiceError; +use crate::models::info_hash::InfoHash; use crate::models::torrent::TorrentRequest; use crate::models::torrent_tag::TagId; +use crate::models::user::UserId; use crate::routes::torrent::Create; use crate::utils::parse_torrent; -use crate::web::api::v1::extractors::bearer_token::Extract; +use crate::web::api::v1::extractors::bearer_token::{BearerToken, Extract}; /// Upload a new torrent file to the Index /// @@ -47,7 +51,62 @@ pub async fn upload_torrent_handler( } } +#[derive(Deserialize)] +pub struct InfoHashParam(pub String); + +#[allow(clippy::unused_async)] +pub async fn download_torrent_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + Path(info_hash): Path, +) -> Response { + let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + + let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { + Ok(opt_user_id) => opt_user_id, + Err(err) => return err.into_response(), + }; + + let torrent = match app_data.torrent_service.get_torrent(&info_hash, opt_user_id).await { + Ok(torrent) => torrent, + Err(err) => return err.into_response(), + }; + + let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { return ServiceError::InternalServerError.into_response() }; + + torrent_file_response(bytes) +} + +/// If the user is logged in, returns the user's ID. Otherwise, returns `None`. +/// +/// # Errors +/// +/// It returns an error if we cannot get the user from the bearer token. +async fn get_optional_logged_in_user( + maybe_bearer_token: Option, + app_data: Arc, +) -> Result, ServiceError> { + match maybe_bearer_token { + Some(bearer_token) => match app_data.auth.get_user_id_from_bearer_token(&Some(bearer_token)).await { + Ok(user_id) => Ok(Some(user_id)), + Err(err) => Err(err), + }, + None => Ok(None), + } +} + /// Extracts the [`TorrentRequest`] from the multipart form payload. +/// +/// # Errors +/// +/// It will return an error if: +/// +/// - The text fields do not contain a valid UTF8 string. +/// - The torrent file data is not valid because: +/// - The content type is not `application/x-bittorrent`. +/// - The multipart content is invalid. +/// - The torrent file pieces key has a length that is not a multiple of 20. +/// - The binary data cannot be decoded as a torrent file. async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let torrent_buffer = vec![0u8]; let mut torrent_cursor = Cursor::new(torrent_buffer); diff --git a/src/web/api/v1/contexts/torrent/responses.rs b/src/web/api/v1/contexts/torrent/responses.rs index 9e338f32..3197c5a0 100644 --- a/src/web/api/v1/contexts/torrent/responses.rs +++ b/src/web/api/v1/contexts/torrent/responses.rs @@ -1,4 +1,6 @@ +use axum::response::{IntoResponse, Response}; use axum::Json; +use hyper::{header, StatusCode}; use serde::{Deserialize, Serialize}; use crate::models::torrent::TorrentId; @@ -20,3 +22,8 @@ pub fn new_torrent_response(torrent_id: TorrentId, info_hash: &str) -> Json) -> Response { + (StatusCode::OK, [(header::CONTENT_TYPE, "application/x-bittorrent")], bytes).into_response() +} diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs index 344fd995..0130872f 100644 --- a/src/web/api/v1/contexts/torrent/routes.rs +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -1,15 +1,17 @@ -//! API routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +//! API routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context. //! -//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::tag). +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::torrent). use std::sync::Arc; -use axum::routing::post; +use axum::routing::{get, post}; use axum::Router; -use super::handlers::upload_torrent_handler; +use super::handlers::{download_torrent_handler, upload_torrent_handler}; use crate::common::AppData; -/// Routes for the [`tag`](crate::web::api::v1::contexts::tag) API context. +/// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context. pub fn router_for_single_resources(app_data: Arc) -> Router { - Router::new().route("/upload", post(upload_torrent_handler).with_state(app_data)) + Router::new() + .route("/upload", post(upload_torrent_handler).with_state(app_data.clone())) + .route("/download/:info_hash", get(download_torrent_handler).with_state(app_data)) } diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index d6fb197e..474c6011 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -625,7 +625,6 @@ mod for_authenticated_users { mod with_axum_implementation { mod for_guests { - /* use std::env; @@ -633,19 +632,21 @@ mod with_axum_implementation { use torrust_index_backend::web::api; use crate::common::client::Client; - use crate::common::contexts::category::fixtures::software_predefined_category_id; - use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; + //use crate::common::contexts::category::fixtures::software_predefined_category_id; + //use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; use crate::common::contexts::torrent::requests::InfoHash; - use crate::common::contexts::torrent::responses::{ - Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, - }; - use crate::common::http::{Query, QueryParam}; + //use crate::common::contexts::torrent::responses::{ + // Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, + //}; + //use crate::common::http::{Query, QueryParam}; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::asserts::expected_torrent; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; + /* + #[tokio::test] async fn it_should_allow_guests_to_get_torrents() { let mut env = TestEnv::new(); @@ -853,6 +854,8 @@ mod with_axum_implementation { assert!(response.is_json_and_ok()); } + */ + #[tokio::test] async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { let mut env = TestEnv::new(); @@ -908,6 +911,8 @@ mod with_axum_implementation { assert_eq!(response.status, 400); } + /* + #[tokio::test] async fn it_should_not_allow_guests_to_delete_torrents() { let mut env = TestEnv::new(); @@ -940,17 +945,17 @@ mod with_axum_implementation { use std::env; - //use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; - //use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; use crate::common::asserts::assert_json_error_response; use crate::common::client::Client; use crate::common::contexts::torrent::fixtures::random_torrent; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; - //use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; + use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -1080,8 +1085,6 @@ mod with_axum_implementation { assert_eq!(response.status, 400); } - /* - #[tokio::test] async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { let mut env = TestEnv::new(); @@ -1123,8 +1126,6 @@ mod with_axum_implementation { ); } - */ - mod and_non_admins { /* From b998a16e5d165300649718e73044793025417272 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jun 2023 12:28:25 +0100 Subject: [PATCH 225/357] refactor(api): [#182] Axum API, torrent context, search for torrents --- src/web/api/v1/contexts/torrent/handlers.rs | 13 ++++++++++++- src/web/api/v1/contexts/torrent/routes.rs | 9 +++++++-- src/web/api/v1/routes.rs | 8 +++++--- tests/e2e/contexts/torrent/contract.rs | 7 ++++--- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index fd0f945f..aaf6043f 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -4,8 +4,9 @@ use std::io::{Cursor, Write}; use std::str::FromStr; use std::sync::Arc; -use axum::extract::{Multipart, Path, State}; +use axum::extract::{Multipart, Path, Query, State}; use axum::response::{IntoResponse, Response}; +use axum::Json; use serde::Deserialize; use super::responses::{new_torrent_response, torrent_file_response}; @@ -16,8 +17,10 @@ use crate::models::torrent::TorrentRequest; use crate::models::torrent_tag::TagId; use crate::models::user::UserId; use crate::routes::torrent::Create; +use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; use crate::web::api::v1::extractors::bearer_token::{BearerToken, Extract}; +use crate::web::api::v1::responses::OkResponseData; /// Upload a new torrent file to the Index /// @@ -77,6 +80,14 @@ pub async fn download_torrent_handler( torrent_file_response(bytes) } +#[allow(clippy::unused_async)] +pub async fn get_torrents_handler(State(app_data): State>, Query(criteria): Query) -> Response { + match app_data.torrent_service.generate_torrent_info_listing(&criteria).await { + Ok(torrents_response) => Json(OkResponseData { data: torrents_response }).into_response(), + Err(err) => err.into_response(), + } +} + /// If the user is logged in, returns the user's ID. Otherwise, returns `None`. /// /// # Errors diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs index 0130872f..5618ef5b 100644 --- a/src/web/api/v1/contexts/torrent/routes.rs +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -6,12 +6,17 @@ use std::sync::Arc; use axum::routing::{get, post}; use axum::Router; -use super::handlers::{download_torrent_handler, upload_torrent_handler}; +use super::handlers::{download_torrent_handler, get_torrents_handler, upload_torrent_handler}; use crate::common::AppData; -/// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context. +/// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for single resources. pub fn router_for_single_resources(app_data: Arc) -> Router { Router::new() .route("/upload", post(upload_torrent_handler).with_state(app_data.clone())) .route("/download/:info_hash", get(download_torrent_handler).with_state(app_data)) } + +/// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for multiple resources. +pub fn router_for_multiple_resources(app_data: Arc) -> Router { + Router::new().route("/", get(get_torrents_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 10c27d20..2667bf1b 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -24,15 +24,17 @@ pub fn router(app_data: Arc) -> Router { .nest("/tag", tag::routes::router_for_single_resources(app_data.clone())) .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())) .nest("/settings", settings::routes::router(app_data.clone())) - .nest("/torrent", torrent::routes::router_for_single_resources(app_data.clone())); + .nest("/torrent", torrent::routes::router_for_single_resources(app_data.clone())) + .nest("/torrents", torrent::routes::router_for_multiple_resources(app_data.clone())); Router::new() .route("/", get(about_page_handler).with_state(app_data)) .nest("/v1", v1_api_routes) - // For development purposes only. + // + //.layer(CorsLayer::permissive()) // Uncomment this line and the `use` import. + // // It allows calling the API on a different port. For example // API: http://localhost:3000/v1 // Webapp: http://localhost:8080 - //Router::new().nest("/v1", api_routes).layer(CorsLayer::permissive()) } diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 474c6011..2f35786c 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -635,18 +635,17 @@ mod with_axum_implementation { //use crate::common::contexts::category::fixtures::software_predefined_category_id; //use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; use crate::common::contexts::torrent::requests::InfoHash; + use crate::common::contexts::torrent::responses::TorrentListResponse; //use crate::common::contexts::torrent::responses::{ // Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, //}; - //use crate::common::http::{Query, QueryParam}; + use crate::common::http::{Query, QueryParam}; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::asserts::expected_torrent; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; - /* - #[tokio::test] async fn it_should_allow_guests_to_get_torrents() { let mut env = TestEnv::new(); @@ -786,6 +785,8 @@ mod with_axum_implementation { assert!(response.is_json_and_ok()); } + /* + #[tokio::test] async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { let mut env = TestEnv::new(); From 4bed98acb02ddeebbf0797fbe5cce0531a340dad Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jun 2023 12:42:11 +0100 Subject: [PATCH 226/357] refactor(api): [#182] Axum API, torrent context, get torrent info --- src/web/api/v1/contexts/torrent/handlers.rs | 19 +++++++++++++++++++ src/web/api/v1/contexts/torrent/routes.rs | 5 ++++- tests/e2e/contexts/torrent/contract.rs | 15 +++++---------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index aaf6043f..ccb4a7a9 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -88,6 +88,25 @@ pub async fn get_torrents_handler(State(app_data): State>, Query(cr } } +#[allow(clippy::unused_async)] +pub async fn get_torrent_info_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + Path(info_hash): Path, +) -> Response { + let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + + let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { + Ok(opt_user_id) => opt_user_id, + Err(err) => return err.into_response(), + }; + + match app_data.torrent_service.get_torrent_info(&info_hash, opt_user_id).await { + Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(), + Err(err) => err.into_response(), + } +} + /// If the user is logged in, returns the user's ID. Otherwise, returns `None`. /// /// # Errors diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs index 5618ef5b..61df6cac 100644 --- a/src/web/api/v1/contexts/torrent/routes.rs +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -6,14 +6,17 @@ use std::sync::Arc; use axum::routing::{get, post}; use axum::Router; -use super::handlers::{download_torrent_handler, get_torrents_handler, upload_torrent_handler}; +use super::handlers::{download_torrent_handler, get_torrent_info_handler, get_torrents_handler, upload_torrent_handler}; use crate::common::AppData; /// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for single resources. pub fn router_for_single_resources(app_data: Arc) -> Router { + let torrent_info_routes = Router::new().route("/", get(get_torrent_info_handler).with_state(app_data.clone())); + Router::new() .route("/upload", post(upload_torrent_handler).with_state(app_data.clone())) .route("/download/:info_hash", get(download_torrent_handler).with_state(app_data)) + .nest("/:info_hash", torrent_info_routes) } /// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for multiple resources. diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 2f35786c..9aa8825f 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -632,13 +632,12 @@ mod with_axum_implementation { use torrust_index_backend::web::api; use crate::common::client::Client; - //use crate::common::contexts::category::fixtures::software_predefined_category_id; - //use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::common::contexts::category::fixtures::software_predefined_category_id; + use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; use crate::common::contexts::torrent::requests::InfoHash; - use crate::common::contexts::torrent::responses::TorrentListResponse; - //use crate::common::contexts::torrent::responses::{ - // Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, - //}; + use crate::common::contexts::torrent::responses::{ + Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, + }; use crate::common::http::{Query, QueryParam}; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::asserts::expected_torrent; @@ -785,8 +784,6 @@ mod with_axum_implementation { assert!(response.is_json_and_ok()); } - /* - #[tokio::test] async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { let mut env = TestEnv::new(); @@ -855,8 +852,6 @@ mod with_axum_implementation { assert!(response.is_json_and_ok()); } - */ - #[tokio::test] async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { let mut env = TestEnv::new(); From ca257ff9783e23e37949350a31f9bcb1c3df8d1b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jun 2023 12:59:03 +0100 Subject: [PATCH 227/357] refactor(api): [#182] Axum API, torrent context, update torrent info --- src/web/api/v1/contexts/torrent/forms.rs | 10 ++++ src/web/api/v1/contexts/torrent/handlers.rs | 62 ++++++++++++++++++++- src/web/api/v1/contexts/torrent/mod.rs | 1 + src/web/api/v1/contexts/torrent/routes.rs | 10 +++- tests/e2e/contexts/torrent/contract.rs | 20 +++---- 5 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 src/web/api/v1/contexts/torrent/forms.rs diff --git a/src/web/api/v1/contexts/torrent/forms.rs b/src/web/api/v1/contexts/torrent/forms.rs new file mode 100644 index 00000000..df840c70 --- /dev/null +++ b/src/web/api/v1/contexts/torrent/forms.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; + +use crate::models::torrent_tag::TagId; + +#[derive(Debug, Deserialize)] +pub struct UpdateTorrentInfoForm { + pub title: Option, + pub description: Option, + pub tags: Option>, +} diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index ccb4a7a9..6d13e7fe 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -4,11 +4,12 @@ use std::io::{Cursor, Write}; use std::str::FromStr; use std::sync::Arc; -use axum::extract::{Multipart, Path, Query, State}; +use axum::extract::{self, Multipart, Path, Query, State}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; +use super::forms::UpdateTorrentInfoForm; use super::responses::{new_torrent_response, torrent_file_response}; use crate::common::AppData; use crate::errors::ServiceError; @@ -57,6 +58,11 @@ pub async fn upload_torrent_handler( #[derive(Deserialize)] pub struct InfoHashParam(pub String); +/// Returns the torrent as a byte stream `application/x-bittorrent`. +/// +/// # Errors +/// +/// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. #[allow(clippy::unused_async)] pub async fn download_torrent_handler( State(app_data): State>, @@ -80,6 +86,13 @@ pub async fn download_torrent_handler( torrent_file_response(bytes) } +/// It returns a list of torrents matching the search criteria. +/// +/// Eg: `/torrents?categories=music,other,movie&search=bunny&sort=size_DESC` +/// +/// # Errors +/// +/// It returns an error if the database query fails. #[allow(clippy::unused_async)] pub async fn get_torrents_handler(State(app_data): State>, Query(criteria): Query) -> Response { match app_data.torrent_service.generate_torrent_info_listing(&criteria).await { @@ -88,6 +101,14 @@ pub async fn get_torrents_handler(State(app_data): State>, Query(cr } } +/// Get Torrent from the Index +/// +/// # Errors +/// +/// This function returns an error if: +/// +/// - The info-hash is not valid. +/// - Ot there was a problem getting the torrent info from the database. #[allow(clippy::unused_async)] pub async fn get_torrent_info_handler( State(app_data): State>, @@ -107,6 +128,45 @@ pub async fn get_torrent_info_handler( } } +/// Update a the torrent info +/// +/// # Errors +/// +/// This function will return an error if unable to: +/// +/// * Get the user id from the request. +/// * Get the torrent info-hash from the request. +/// * Update the torrent info. +#[allow(clippy::unused_async)] +pub async fn update_torrent_info_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + Path(info_hash): Path, + extract::Json(update_torrent_info_form): extract::Json, +) -> Response { + let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(err) => return err.into_response(), + }; + + match app_data + .torrent_service + .update_torrent_info( + &info_hash, + &update_torrent_info_form.title, + &update_torrent_info_form.description, + &update_torrent_info_form.tags, + &user_id, + ) + .await + { + Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(), + Err(err) => err.into_response(), + } +} + /// If the user is logged in, returns the user's ID. Otherwise, returns `None`. /// /// # Errors diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs index 3e533153..78351f78 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -328,6 +328,7 @@ //! //! Refer to the [`DeletedTorrentResponse`](crate::models::response::DeletedTorrentResponse) //! struct for more information about the response attributes. +pub mod forms; pub mod handlers; pub mod responses; pub mod routes; diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs index 61df6cac..0d272412 100644 --- a/src/web/api/v1/contexts/torrent/routes.rs +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -3,15 +3,19 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::torrent). use std::sync::Arc; -use axum::routing::{get, post}; +use axum::routing::{get, post, put}; use axum::Router; -use super::handlers::{download_torrent_handler, get_torrent_info_handler, get_torrents_handler, upload_torrent_handler}; +use super::handlers::{ + download_torrent_handler, get_torrent_info_handler, get_torrents_handler, update_torrent_info_handler, upload_torrent_handler, +}; use crate::common::AppData; /// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for single resources. pub fn router_for_single_resources(app_data: Arc) -> Router { - let torrent_info_routes = Router::new().route("/", get(get_torrent_info_handler).with_state(app_data.clone())); + let torrent_info_routes = Router::new() + .route("/", get(get_torrent_info_handler).with_state(app_data.clone())) + .route("/", put(update_torrent_info_handler).with_state(app_data.clone())); Router::new() .route("/upload", post(upload_torrent_handler).with_state(app_data.clone())) diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 9aa8825f..28dc1f7f 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -1123,7 +1123,6 @@ mod with_axum_implementation { } mod and_non_admins { - /* use std::env; @@ -1136,6 +1135,8 @@ mod with_axum_implementation { use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; + /* + #[tokio::test] async fn it_should_not_allow_non_admins_to_delete_torrents() { let mut env = TestEnv::new(); @@ -1161,6 +1162,8 @@ mod with_axum_implementation { assert_eq!(response.status, 403); } + */ + #[tokio::test] async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); @@ -1199,12 +1202,9 @@ mod with_axum_implementation { assert_eq!(response.status, 403); } - - */ } mod and_torrent_owners { - /* use std::env; @@ -1259,12 +1259,9 @@ mod with_axum_implementation { assert_eq!(torrent.description, new_description); assert!(response.is_json_and_ok()); } - - */ } mod and_admins { - /* use std::env; @@ -1272,12 +1269,15 @@ mod with_axum_implementation { use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + //use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; + /* + #[tokio::test] async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { let mut env = TestEnv::new(); @@ -1307,6 +1307,8 @@ mod with_axum_implementation { assert!(response.is_json_and_ok()); } + */ + #[tokio::test] async fn it_should_allow_admins_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); @@ -1349,8 +1351,6 @@ mod with_axum_implementation { assert_eq!(torrent.description, new_description); assert!(response.is_json_and_ok()); } - - */ } } } From 24394ea52bd597fb2c4ca4ac4b34c62ee3bd59c2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jun 2023 13:05:42 +0100 Subject: [PATCH 228/357] refactor(api): [#182] Axum API, torrent context, delete torrent info --- src/web/api/v1/contexts/torrent/handlers.rs | 31 +++++++++++++++++++++ src/web/api/v1/contexts/torrent/routes.rs | 8 ++++-- tests/e2e/contexts/torrent/contract.rs | 15 +--------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 6d13e7fe..d9821d06 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -167,6 +167,37 @@ pub async fn update_torrent_info_handler( } } +/// Delete a torrent. +/// +/// # Errors +/// +/// This function will return an error if unable to: +/// +/// * Get the user ID from the request. +/// * Get the torrent info-hash from the request. +/// * Delete the torrent. +#[allow(clippy::unused_async)] +pub async fn delete_torrent_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + Path(info_hash): Path, +) -> Response { + let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(err) => return err.into_response(), + }; + + match app_data.torrent_service.delete_torrent(&info_hash, &user_id).await { + Ok(deleted_torrent_response) => Json(OkResponseData { + data: deleted_torrent_response, + }) + .into_response(), + Err(err) => err.into_response(), + } +} + /// If the user is logged in, returns the user's ID. Otherwise, returns `None`. /// /// # Errors diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs index 0d272412..a5c7ed78 100644 --- a/src/web/api/v1/contexts/torrent/routes.rs +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -3,11 +3,12 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::torrent). use std::sync::Arc; -use axum::routing::{get, post, put}; +use axum::routing::{delete, get, post, put}; use axum::Router; use super::handlers::{ - download_torrent_handler, get_torrent_info_handler, get_torrents_handler, update_torrent_info_handler, upload_torrent_handler, + delete_torrent_handler, download_torrent_handler, get_torrent_info_handler, get_torrents_handler, + update_torrent_info_handler, upload_torrent_handler, }; use crate::common::AppData; @@ -15,7 +16,8 @@ use crate::common::AppData; pub fn router_for_single_resources(app_data: Arc) -> Router { let torrent_info_routes = Router::new() .route("/", get(get_torrent_info_handler).with_state(app_data.clone())) - .route("/", put(update_torrent_info_handler).with_state(app_data.clone())); + .route("/", put(update_torrent_info_handler).with_state(app_data.clone())) + .route("/", delete(delete_torrent_handler).with_state(app_data.clone())); Router::new() .route("/upload", post(upload_torrent_handler).with_state(app_data.clone())) diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 28dc1f7f..c14acf67 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -907,8 +907,6 @@ mod with_axum_implementation { assert_eq!(response.status, 400); } - /* - #[tokio::test] async fn it_should_not_allow_guests_to_delete_torrents() { let mut env = TestEnv::new(); @@ -933,8 +931,6 @@ mod with_axum_implementation { assert_eq!(response.status, 401); } - - */ } mod for_authenticated_users { @@ -1135,8 +1131,6 @@ mod with_axum_implementation { use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; - /* - #[tokio::test] async fn it_should_not_allow_non_admins_to_delete_torrents() { let mut env = TestEnv::new(); @@ -1162,8 +1156,6 @@ mod with_axum_implementation { assert_eq!(response.status, 403); } - */ - #[tokio::test] async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); @@ -1269,15 +1261,12 @@ mod with_axum_implementation { use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - //use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; - use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; + use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; - /* - #[tokio::test] async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { let mut env = TestEnv::new(); @@ -1307,8 +1296,6 @@ mod with_axum_implementation { assert!(response.is_json_and_ok()); } - */ - #[tokio::test] async fn it_should_allow_admins_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); From 0af2cb7c943bd7faff60462520ba13179ecea73a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jun 2023 13:58:31 +0100 Subject: [PATCH 229/357] refactor(api): [#180] Axum API, proxy context --- src/web/api/v1/contexts/proxy/handlers.rs | 47 ++++++++++++++++++++++ src/web/api/v1/contexts/proxy/mod.rs | 3 ++ src/web/api/v1/contexts/proxy/responses.rs | 8 ++++ src/web/api/v1/contexts/proxy/routes.rs | 15 +++++++ src/web/api/v1/routes.rs | 5 ++- tests/e2e/contexts/mod.rs | 1 + tests/e2e/contexts/proxy/contract.rs | 6 +++ tests/e2e/contexts/proxy/mod.rs | 1 + 8 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/web/api/v1/contexts/proxy/handlers.rs create mode 100644 src/web/api/v1/contexts/proxy/responses.rs create mode 100644 src/web/api/v1/contexts/proxy/routes.rs create mode 100644 tests/e2e/contexts/proxy/contract.rs create mode 100644 tests/e2e/contexts/proxy/mod.rs diff --git a/src/web/api/v1/contexts/proxy/handlers.rs b/src/web/api/v1/contexts/proxy/handlers.rs new file mode 100644 index 00000000..d31e112a --- /dev/null +++ b/src/web/api/v1/contexts/proxy/handlers.rs @@ -0,0 +1,47 @@ +//! API handlers for the the [`proxy`](crate::web::api::v1::contexts::proxy) API +//! context. +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::response::Response; + +use super::responses::png_image; +use crate::cache::image::manager::Error; +use crate::common::AppData; +use crate::ui::proxy::map_error_to_image; +use crate::web::api::v1::extractors::bearer_token::Extract; + +/// Get the remote image. It uses the cached image if available. +#[allow(clippy::unused_async)] +pub async fn get_proxy_image_handler( + State(app_data): State>, + Extract(maybe_bearer_token): Extract, + Path(url): Path, +) -> Response { + if maybe_bearer_token.is_none() { + return png_image(map_error_to_image(&Error::Unauthenticated)); + } + + let Ok(user_id) = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await else { return png_image(map_error_to_image(&Error::Unauthenticated)) }; + + // code-review: Handling status codes in the frontend other tan OK is quite a pain. + // Return OK for now. + + // todo: it also work for other image types but we are always returning the + // same content type: `image/png`. If we only support PNG images we should + // change the documentation and return an error for other image types. + + // Get image URL from URL path parameter. + let image_url = urlencoding::decode(&url).unwrap_or_default().into_owned(); + + match app_data.proxy_service.get_image_by_url(&image_url, &user_id).await { + Ok(image_bytes) => { + // Returns the cached image. + png_image(image_bytes) + } + Err(e) => { + // Returns an error image. + png_image(map_error_to_image(&e)) + } + } +} diff --git a/src/web/api/v1/contexts/proxy/mod.rs b/src/web/api/v1/contexts/proxy/mod.rs index 29eb0879..c63397a4 100644 --- a/src/web/api/v1/contexts/proxy/mod.rs +++ b/src/web/api/v1/contexts/proxy/mod.rs @@ -41,3 +41,6 @@ //! --output mandelbrotset.jpg \ //! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2Fthumb%2F2%2F21%2FMandel_zoom_00_mandelbrot_set.jpg%2F1280px-Mandel_zoom_00_mandelbrot_set.jpg //! ``` +pub mod handlers; +pub mod responses; +pub mod routes; diff --git a/src/web/api/v1/contexts/proxy/responses.rs b/src/web/api/v1/contexts/proxy/responses.rs new file mode 100644 index 00000000..1ce9730c --- /dev/null +++ b/src/web/api/v1/contexts/proxy/responses.rs @@ -0,0 +1,8 @@ +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use hyper::{header, StatusCode}; + +#[must_use] +pub fn png_image(bytes: Bytes) -> Response { + (StatusCode::OK, [(header::CONTENT_TYPE, "image/png")], bytes).into_response() +} diff --git a/src/web/api/v1/contexts/proxy/routes.rs b/src/web/api/v1/contexts/proxy/routes.rs new file mode 100644 index 00000000..e6bd7bef --- /dev/null +++ b/src/web/api/v1/contexts/proxy/routes.rs @@ -0,0 +1,15 @@ +//! API routes for the [`proxy`](crate::web::api::v1::contexts::proxy) API context. +//! +//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::proxy). +use std::sync::Arc; + +use axum::routing::get; +use axum::Router; + +use super::handlers::get_proxy_image_handler; +use crate::common::AppData; + +/// Routes for the [`about`](crate::web::api::v1::contexts::about) API context. +pub fn router(app_data: Arc) -> Router { + Router::new().route("/image/:url", get(get_proxy_image_handler).with_state(app_data)) +} diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 2667bf1b..f012d68c 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -6,7 +6,7 @@ use axum::Router; use super::contexts::about::handlers::about_page_handler; //use tower_http::cors::CorsLayer; -use super::contexts::{about, settings, tag, torrent}; +use super::contexts::{about, proxy, settings, tag, torrent}; use super::contexts::{category, user}; use crate::common::AppData; @@ -25,7 +25,8 @@ pub fn router(app_data: Arc) -> Router { .nest("/tags", tag::routes::router_for_multiple_resources(app_data.clone())) .nest("/settings", settings::routes::router(app_data.clone())) .nest("/torrent", torrent::routes::router_for_single_resources(app_data.clone())) - .nest("/torrents", torrent::routes::router_for_multiple_resources(app_data.clone())); + .nest("/torrents", torrent::routes::router_for_multiple_resources(app_data.clone())) + .nest("/proxy", proxy::routes::router(app_data.clone())); Router::new() .route("/", get(about_page_handler).with_state(app_data)) diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs index fa791e5f..797781be 100644 --- a/tests/e2e/contexts/mod.rs +++ b/tests/e2e/contexts/mod.rs @@ -1,5 +1,6 @@ pub mod about; pub mod category; +pub mod proxy; pub mod root; pub mod settings; pub mod tag; diff --git a/tests/e2e/contexts/proxy/contract.rs b/tests/e2e/contexts/proxy/contract.rs new file mode 100644 index 00000000..46c8b8a9 --- /dev/null +++ b/tests/e2e/contexts/proxy/contract.rs @@ -0,0 +1,6 @@ +//! API contract for `proxy` context. + +mod with_axum_implementation { + + // todo +} diff --git a/tests/e2e/contexts/proxy/mod.rs b/tests/e2e/contexts/proxy/mod.rs new file mode 100644 index 00000000..2943dbb5 --- /dev/null +++ b/tests/e2e/contexts/proxy/mod.rs @@ -0,0 +1 @@ +pub mod contract; From 34db87963fc3337f24ed59edbe4ebc5d4bded2d5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jun 2023 12:14:37 +0100 Subject: [PATCH 230/357] fix(api): Axum API, fix delete tag response Response after deleting a tag should be: ``` { data: 1 } ``` not: ``` { data: "1" } ``` where 1 (`i64`) is the tag ID. --- src/web/api/v1/contexts/tag/handlers.rs | 4 ++-- src/web/api/v1/contexts/tag/responses.rs | 6 ++---- tests/e2e/contexts/tag/contract.rs | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/web/api/v1/contexts/tag/handlers.rs b/src/web/api/v1/contexts/tag/handlers.rs index 7944c4a4..83f8a212 100644 --- a/src/web/api/v1/contexts/tag/handlers.rs +++ b/src/web/api/v1/contexts/tag/handlers.rs @@ -10,7 +10,7 @@ use super::responses::{added_tag, deleted_tag}; use crate::common::AppData; use crate::databases::database; use crate::errors::ServiceError; -use crate::models::torrent_tag::TorrentTag; +use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::web::api::v1::extractors::bearer_token::Extract; use crate::web::api::v1::responses::{self, OkResponseData}; @@ -72,7 +72,7 @@ pub async fn delete_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(delete_tag_form): extract::Json, -) -> Result>, ServiceError> { +) -> Result>, ServiceError> { let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; match app_data.tag_service.delete_tag(&delete_tag_form.tag_id, &user_id).await { diff --git a/src/web/api/v1/contexts/tag/responses.rs b/src/web/api/v1/contexts/tag/responses.rs index 3b44d51d..a1645994 100644 --- a/src/web/api/v1/contexts/tag/responses.rs +++ b/src/web/api/v1/contexts/tag/responses.rs @@ -13,8 +13,6 @@ pub fn added_tag(tag_name: &str) -> Json> { } /// Response after successfully deleting a tag. -pub fn deleted_tag(tag_id: TagId) -> Json> { - Json(OkResponseData { - data: tag_id.to_string(), - }) +pub fn deleted_tag(tag_id: TagId) -> Json> { + Json(OkResponseData { data: tag_id }) } diff --git a/tests/e2e/contexts/tag/contract.rs b/tests/e2e/contexts/tag/contract.rs index a64d7359..9dd8ff15 100644 --- a/tests/e2e/contexts/tag/contract.rs +++ b/tests/e2e/contexts/tag/contract.rs @@ -356,7 +356,7 @@ mod with_axum_implementation { #[tokio::test] async fn it_should_allow_admins_to_delete_tags() { let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; + env.start(api::Implementation::Axum).await; if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { println!("Skipped"); From b4744e723e3bf851394b35e010d60cb6184b1574 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jun 2023 12:21:32 +0100 Subject: [PATCH 231/357] test(api): fix test for empty categories The application allows adding empty categories. The test cannot be executed in a shared env because it would fail the second tie is executed becuase the category already exists. --- tests/e2e/contexts/category/contract.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index de6b57e6..8200cf22 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -111,6 +111,13 @@ async fn it_should_allow_adding_empty_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env.is_shared() { + // This test cannot be run in a shared test env because it will fail + // when the empty category already exits + println!("Skipped"); + return; + } + let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -342,6 +349,13 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; + if env.is_shared() { + // This test cannot be run in a shared test env because it will fail + // when the empty category already exits + println!("Skipped"); + return; + } + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { println!("Skipped"); return; From b73d864ad4a4a3903794254846fce2ff15dccf26 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jun 2023 12:52:17 +0100 Subject: [PATCH 232/357] fix(api): Axum API, error should return a 500 status code If the handlers return an error Axum does not automatically return an error HTTP code. See https://docs.rs/axum/latest/axum/error_handling/index.html. You have to convert the error into an error response. --- src/web/api/v1/contexts/category/handlers.rs | 36 +++++++-------- src/web/api/v1/contexts/settings/handlers.rs | 46 ++++++++++++-------- src/web/api/v1/contexts/tag/handlers.rs | 37 ++++++++-------- src/web/api/v1/contexts/torrent/handlers.rs | 24 +++++----- src/web/api/v1/contexts/user/handlers.rs | 45 ++++++++++--------- 5 files changed, 102 insertions(+), 86 deletions(-) diff --git a/src/web/api/v1/contexts/category/handlers.rs b/src/web/api/v1/contexts/category/handlers.rs index 981c4aad..bd66f53a 100644 --- a/src/web/api/v1/contexts/category/handlers.rs +++ b/src/web/api/v1/contexts/category/handlers.rs @@ -3,15 +3,13 @@ use std::sync::Arc; use axum::extract::{self, State}; -use axum::response::Json; +use axum::response::{IntoResponse, Json, Response}; use super::forms::{AddCategoryForm, DeleteCategoryForm}; use super::responses::{added_category, deleted_category}; use crate::common::AppData; -use crate::databases::database::{self, Category}; -use crate::errors::ServiceError; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self, OkResponseData}; +use crate::web::api::v1::responses::{self}; /// It handles the request to get all the categories. /// @@ -27,12 +25,10 @@ use crate::web::api::v1::responses::{self, OkResponseData}; /// /// It returns an error if there is a database error. #[allow(clippy::unused_async)] -pub async fn get_all_handler( - State(app_data): State>, -) -> Result>>, database::Error> { +pub async fn get_all_handler(State(app_data): State>) -> Response { match app_data.category_repository.get_all().await { - Ok(categories) => Ok(Json(responses::OkResponseData { data: categories })), - Err(error) => Err(error), + Ok(categories) => Json(responses::OkResponseData { data: categories }).into_response(), + Err(error) => error.into_response(), } } @@ -49,12 +45,15 @@ pub async fn add_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(category_form): extract::Json, -) -> Result>, ServiceError> { - let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; +) -> Response { + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(error) => return error.into_response(), + }; match app_data.category_service.add_category(&category_form.name, &user_id).await { - Ok(_) => Ok(added_category(&category_form.name)), - Err(error) => Err(error), + Ok(_) => added_category(&category_form.name).into_response(), + Err(error) => error.into_response(), } } @@ -71,15 +70,18 @@ pub async fn delete_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(category_form): extract::Json, -) -> Result>, ServiceError> { +) -> Response { // code-review: why do we need to send the whole category object to delete it? // And we should use the ID instead of the name, because the name could change // or we could add support for multiple languages. - let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(error) => return error.into_response(), + }; match app_data.category_service.delete_category(&category_form.name, &user_id).await { - Ok(_) => Ok(deleted_category(&category_form.name)), - Err(error) => Err(error), + Ok(_) => deleted_category(&category_form.name).into_response(), + Err(error) => error.into_response(), } } diff --git a/src/web/api/v1/contexts/settings/handlers.rs b/src/web/api/v1/contexts/settings/handlers.rs index feca203a..fbd5f871 100644 --- a/src/web/api/v1/contexts/settings/handlers.rs +++ b/src/web/api/v1/contexts/settings/handlers.rs @@ -3,13 +3,12 @@ use std::sync::Arc; use axum::extract::{self, State}; -use axum::response::Json; +use axum::response::{IntoResponse, Json, Response}; use crate::common::AppData; -use crate::config::{ConfigurationPublic, TorrustBackend}; -use crate::errors::ServiceError; +use crate::config::TorrustBackend; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self, OkResponseData}; +use crate::web::api::v1::responses::{self}; /// Get all settings. /// @@ -18,31 +17,34 @@ use crate::web::api::v1::responses::{self, OkResponseData}; /// This function will return an error if the user does not have permission to /// view all the settings. #[allow(clippy::unused_async)] -pub async fn get_all_handler( - State(app_data): State>, - Extract(maybe_bearer_token): Extract, -) -> Result>, ServiceError> { - let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; +pub async fn get_all_handler(State(app_data): State>, Extract(maybe_bearer_token): Extract) -> Response { + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(error) => return error.into_response(), + }; - let all_settings = app_data.settings_service.get_all(&user_id).await?; + let all_settings = match app_data.settings_service.get_all(&user_id).await { + Ok(all_settings) => all_settings, + Err(error) => return error.into_response(), + }; - Ok(Json(responses::OkResponseData { data: all_settings })) + Json(responses::OkResponseData { data: all_settings }).into_response() } /// Get public Settings. #[allow(clippy::unused_async)] -pub async fn get_public_handler(State(app_data): State>) -> Json> { +pub async fn get_public_handler(State(app_data): State>) -> Response { let public_settings = app_data.settings_service.get_public().await; - Json(responses::OkResponseData { data: public_settings }) + Json(responses::OkResponseData { data: public_settings }).into_response() } /// Get website name. #[allow(clippy::unused_async)] -pub async fn get_site_name_handler(State(app_data): State>) -> Json> { +pub async fn get_site_name_handler(State(app_data): State>) -> Response { let site_name = app_data.settings_service.get_site_name().await; - Json(responses::OkResponseData { data: site_name }) + Json(responses::OkResponseData { data: site_name }).into_response() } /// Update all the settings. @@ -59,10 +61,16 @@ pub async fn update_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(torrust_backend): extract::Json, -) -> Result>, ServiceError> { - let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; +) -> Response { + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(error) => return error.into_response(), + }; - let new_settings = app_data.settings_service.update_all(torrust_backend, &user_id).await?; + let new_settings = match app_data.settings_service.update_all(torrust_backend, &user_id).await { + Ok(new_settings) => new_settings, + Err(error) => return error.into_response(), + }; - Ok(Json(responses::OkResponseData { data: new_settings })) + Json(responses::OkResponseData { data: new_settings }).into_response() } diff --git a/src/web/api/v1/contexts/tag/handlers.rs b/src/web/api/v1/contexts/tag/handlers.rs index 83f8a212..feb0a745 100644 --- a/src/web/api/v1/contexts/tag/handlers.rs +++ b/src/web/api/v1/contexts/tag/handlers.rs @@ -3,16 +3,13 @@ use std::sync::Arc; use axum::extract::{self, State}; -use axum::response::Json; +use axum::response::{IntoResponse, Json, Response}; use super::forms::{AddTagForm, DeleteTagForm}; use super::responses::{added_tag, deleted_tag}; use crate::common::AppData; -use crate::databases::database; -use crate::errors::ServiceError; -use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self, OkResponseData}; +use crate::web::api::v1::responses::{self}; /// It handles the request to get all the tags. /// @@ -28,12 +25,10 @@ use crate::web::api::v1::responses::{self, OkResponseData}; /// /// It returns an error if there is a database error. #[allow(clippy::unused_async)] -pub async fn get_all_handler( - State(app_data): State>, -) -> Result>>, database::Error> { +pub async fn get_all_handler(State(app_data): State>) -> Response { match app_data.tag_repository.get_all().await { - Ok(tags) => Ok(Json(responses::OkResponseData { data: tags })), - Err(error) => Err(error), + Ok(tags) => Json(responses::OkResponseData { data: tags }).into_response(), + Err(error) => error.into_response(), } } @@ -50,12 +45,15 @@ pub async fn add_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(add_tag_form): extract::Json, -) -> Result>, ServiceError> { - let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; +) -> Response { + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(error) => return error.into_response(), + }; match app_data.tag_service.add_tag(&add_tag_form.name, &user_id).await { - Ok(_) => Ok(added_tag(&add_tag_form.name)), - Err(error) => Err(error), + Ok(_) => added_tag(&add_tag_form.name).into_response(), + Err(error) => error.into_response(), } } @@ -72,11 +70,14 @@ pub async fn delete_handler( State(app_data): State>, Extract(maybe_bearer_token): Extract, extract::Json(delete_tag_form): extract::Json, -) -> Result>, ServiceError> { - let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; +) -> Response { + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(error) => return error.into_response(), + }; match app_data.tag_service.delete_tag(&delete_tag_form.tag_id, &user_id).await { - Ok(_) => Ok(deleted_tag(delete_tag_form.tag_id)), - Err(error) => Err(error), + Ok(_) => deleted_tag(delete_tag_form.tag_id).into_response(), + Err(error) => error.into_response(), } } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index d9821d06..6133f9bf 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -39,12 +39,12 @@ pub async fn upload_torrent_handler( ) -> Response { let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { Ok(user_id) => user_id, - Err(err) => return err.into_response(), + Err(error) => return error.into_response(), }; let torrent_request = match get_torrent_request_from_payload(multipart).await { Ok(torrent_request) => torrent_request, - Err(err) => return err.into_response(), + Err(error) => return error.into_response(), }; let info_hash = torrent_request.torrent.info_hash().clone(); @@ -73,12 +73,12 @@ pub async fn download_torrent_handler( let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { Ok(opt_user_id) => opt_user_id, - Err(err) => return err.into_response(), + Err(error) => return error.into_response(), }; let torrent = match app_data.torrent_service.get_torrent(&info_hash, opt_user_id).await { Ok(torrent) => torrent, - Err(err) => return err.into_response(), + Err(error) => return error.into_response(), }; let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { return ServiceError::InternalServerError.into_response() }; @@ -97,7 +97,7 @@ pub async fn download_torrent_handler( pub async fn get_torrents_handler(State(app_data): State>, Query(criteria): Query) -> Response { match app_data.torrent_service.generate_torrent_info_listing(&criteria).await { Ok(torrents_response) => Json(OkResponseData { data: torrents_response }).into_response(), - Err(err) => err.into_response(), + Err(error) => error.into_response(), } } @@ -119,12 +119,12 @@ pub async fn get_torrent_info_handler( let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { Ok(opt_user_id) => opt_user_id, - Err(err) => return err.into_response(), + Err(error) => return error.into_response(), }; match app_data.torrent_service.get_torrent_info(&info_hash, opt_user_id).await { Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(), - Err(err) => err.into_response(), + Err(error) => error.into_response(), } } @@ -148,7 +148,7 @@ pub async fn update_torrent_info_handler( let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { Ok(user_id) => user_id, - Err(err) => return err.into_response(), + Err(error) => return error.into_response(), }; match app_data @@ -163,7 +163,7 @@ pub async fn update_torrent_info_handler( .await { Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(), - Err(err) => err.into_response(), + Err(error) => error.into_response(), } } @@ -186,7 +186,7 @@ pub async fn delete_torrent_handler( let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { Ok(user_id) => user_id, - Err(err) => return err.into_response(), + Err(error) => return error.into_response(), }; match app_data.torrent_service.delete_torrent(&info_hash, &user_id).await { @@ -194,7 +194,7 @@ pub async fn delete_torrent_handler( data: deleted_torrent_response, }) .into_response(), - Err(err) => err.into_response(), + Err(error) => error.into_response(), } } @@ -210,7 +210,7 @@ async fn get_optional_logged_in_user( match maybe_bearer_token { Some(bearer_token) => match app_data.auth.get_user_id_from_bearer_token(&Some(bearer_token)).await { Ok(user_id) => Ok(Some(user_id)), - Err(err) => Err(err), + Err(error) => Err(error), }, None => Ok(None), } diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index 0ab4e995..51fb041f 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -3,13 +3,13 @@ use std::sync::Arc; use axum::extract::{self, Host, Path, State}; +use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; use super::forms::{JsonWebToken, LoginForm, RegistrationForm}; -use super::responses::{self, NewUser, TokenResponse}; +use super::responses::{self}; use crate::common::AppData; -use crate::errors::ServiceError; use crate::web::api::v1::extractors::bearer_token::Extract; use crate::web::api::v1::responses::OkResponseData; @@ -25,7 +25,7 @@ pub async fn registration_handler( State(app_data): State>, Host(host_from_header): Host, extract::Json(registration_form): extract::Json, -) -> Result>, ServiceError> { +) -> Response { let api_base_url = app_data .cfg .get_api_base_url() @@ -37,8 +37,8 @@ pub async fn registration_handler( .register_user(®istration_form, &api_base_url) .await { - Ok(user_id) => Ok(responses::added_user(user_id)), - Err(error) => Err(error), + Ok(user_id) => responses::added_user(user_id).into_response(), + Err(error) => error.into_response(), } } @@ -68,14 +68,14 @@ pub async fn email_verification_handler(State(app_data): State>, Pa pub async fn login_handler( State(app_data): State>, extract::Json(login_form): extract::Json, -) -> Result>, ServiceError> { +) -> Response { match app_data .authentication_service .login(&login_form.login, &login_form.password) .await { - Ok((token, user_compact)) => Ok(responses::logged_in_user(token, user_compact)), - Err(error) => Err(error), + Ok((token, user_compact)) => responses::logged_in_user(token, user_compact).into_response(), + Err(error) => error.into_response(), } } @@ -91,12 +91,13 @@ pub async fn login_handler( pub async fn verify_token_handler( State(app_data): State>, extract::Json(token): extract::Json, -) -> Result>, ServiceError> { +) -> Response { match app_data.json_web_token.verify(&token.token).await { - Ok(_) => Ok(axum::Json(OkResponseData { + Ok(_) => axum::Json(OkResponseData { data: "Token is valid.".to_string(), - })), - Err(error) => Err(error), + }) + .into_response(), + Err(error) => error.into_response(), } } @@ -115,10 +116,10 @@ pub struct UsernameParam(pub String); pub async fn renew_token_handler( State(app_data): State>, extract::Json(token): extract::Json, -) -> Result>, ServiceError> { +) -> Response { match app_data.authentication_service.renew_token(&token.token).await { - Ok((token, user_compact)) => Ok(responses::renewed_token(token, user_compact)), - Err(error) => Err(error), + Ok((token, user_compact)) => responses::renewed_token(token, user_compact).into_response(), + Err(error) => error.into_response(), } } @@ -135,16 +136,20 @@ pub async fn ban_handler( State(app_data): State>, Path(to_be_banned_username): Path, Extract(maybe_bearer_token): Extract, -) -> Result>, ServiceError> { +) -> Response { // todo: add reason and `date_expiry` parameters to request - let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { + Ok(user_id) => user_id, + Err(error) => return error.into_response(), + }; match app_data.ban_service.ban_user(&to_be_banned_username.0, &user_id).await { - Ok(_) => Ok(axum::Json(OkResponseData { + Ok(_) => Json(OkResponseData { data: format!("Banned user: {}", to_be_banned_username.0), - })), - Err(error) => Err(error), + }) + .into_response(), + Err(error) => error.into_response(), } } From a6881e3591b656d25516e7535badc1185484c5ca Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jun 2023 12:57:45 +0100 Subject: [PATCH 233/357] refactor: move funtion get_optional_logged_in_user This function could be used by many other handlers. --- src/web/api/v1/auth.rs | 24 +++++++++++++++++++++ src/web/api/v1/contexts/torrent/handlers.rs | 22 ++----------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs index 545e3054..e93f642c 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/v1/auth.rs @@ -78,9 +78,15 @@ //! "data": "new category" //! } //! ``` +use std::sync::Arc; use hyper::http::HeaderValue; +use crate::common::AppData; +use crate::errors::ServiceError; +use crate::models::user::UserId; +use crate::web::api::v1::extractors::bearer_token::BearerToken; + /// Parses the token from the `Authorization` header. pub fn parse_token(authorization: &HeaderValue) -> String { let split: Vec<&str> = authorization @@ -91,3 +97,21 @@ pub fn parse_token(authorization: &HeaderValue) -> String { let token = split[1].trim(); token.to_string() } + +/// If the user is logged in, returns the user's ID. Otherwise, returns `None`. +/// +/// # Errors +/// +/// It returns an error if we cannot get the user from the bearer token. +pub async fn get_optional_logged_in_user( + maybe_bearer_token: Option, + app_data: Arc, +) -> Result, ServiceError> { + match maybe_bearer_token { + Some(bearer_token) => match app_data.auth.get_user_id_from_bearer_token(&Some(bearer_token)).await { + Ok(user_id) => Ok(Some(user_id)), + Err(error) => Err(error), + }, + None => Ok(None), + } +} diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 6133f9bf..df518170 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -16,11 +16,11 @@ use crate::errors::ServiceError; use crate::models::info_hash::InfoHash; use crate::models::torrent::TorrentRequest; use crate::models::torrent_tag::TagId; -use crate::models::user::UserId; use crate::routes::torrent::Create; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; -use crate::web::api::v1::extractors::bearer_token::{BearerToken, Extract}; +use crate::web::api::v1::auth::get_optional_logged_in_user; +use crate::web::api::v1::extractors::bearer_token::Extract; use crate::web::api::v1::responses::OkResponseData; /// Upload a new torrent file to the Index @@ -198,24 +198,6 @@ pub async fn delete_torrent_handler( } } -/// If the user is logged in, returns the user's ID. Otherwise, returns `None`. -/// -/// # Errors -/// -/// It returns an error if we cannot get the user from the bearer token. -async fn get_optional_logged_in_user( - maybe_bearer_token: Option, - app_data: Arc, -) -> Result, ServiceError> { - match maybe_bearer_token { - Some(bearer_token) => match app_data.auth.get_user_id_from_bearer_token(&Some(bearer_token)).await { - Ok(user_id) => Ok(Some(user_id)), - Err(error) => Err(error), - }, - None => Ok(None), - } -} - /// Extracts the [`TorrentRequest`] from the multipart form payload. /// /// # Errors From 9591239a04c195bbf91aa6776be1f35cd14d6a6f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jun 2023 13:02:12 +0100 Subject: [PATCH 234/357] refector(api): move API_VERSION const out of ActixWeb implementation We are going to remove the ActixWeb implementation after enabling the new Axum implementaion. WE are moving generic things in ActixWeb implementations that are not going to be removed. --- src/mailer.rs | 2 +- src/routes/about.rs | 2 +- src/routes/category.rs | 2 +- src/routes/mod.rs | 2 -- src/routes/proxy.rs | 2 +- src/routes/root.rs | 3 ++- src/routes/settings.rs | 2 +- src/routes/tag.rs | 2 +- src/routes/torrent.rs | 2 +- src/routes/user.rs | 2 +- src/services/about.rs | 2 +- src/web/api/mod.rs | 2 ++ 12 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/mailer.rs b/src/mailer.rs index 35d2ec3e..bf4c7a30 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use crate::config::Configuration; use crate::errors::ServiceError; -use crate::routes::API_VERSION; use crate::utils::clock; +use crate::web::api::API_VERSION; pub struct Service { cfg: Arc, diff --git a/src/routes/about.rs b/src/routes/about.rs index 2a32a74e..a88e3865 100644 --- a/src/routes/about.rs +++ b/src/routes/about.rs @@ -2,8 +2,8 @@ use actix_web::http::StatusCode; use actix_web::{web, HttpResponse, Responder}; use crate::errors::ServiceResult; -use crate::routes::API_VERSION; use crate::services::about::{index_page, license_page}; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/routes/category.rs b/src/routes/category.rs index 30d3643a..bd285867 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::common::WebAppData; use crate::errors::ServiceResult; use crate::models::response::OkResponse; -use crate::routes::API_VERSION; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/routes/mod.rs b/src/routes/mod.rs index bbb439a4..25ac1551 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -9,8 +9,6 @@ pub mod tag; pub mod torrent; pub mod user; -pub const API_VERSION: &str = "v1"; - pub fn init(cfg: &mut web::ServiceConfig) { user::init(cfg); torrent::init(cfg); diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs index 0ff99d08..ac6e2967 100644 --- a/src/routes/proxy.rs +++ b/src/routes/proxy.rs @@ -4,8 +4,8 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use crate::cache::image::manager::Error; use crate::common::WebAppData; use crate::errors::ServiceResult; -use crate::routes::API_VERSION; use crate::ui::proxy::{load_error_images, map_error_to_image}; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/routes/root.rs b/src/routes/root.rs index 29004dd8..7c82ecbd 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -1,6 +1,7 @@ use actix_web::web; -use crate::routes::{about, API_VERSION}; +use crate::routes::about; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(web::scope("/").service(web::resource("").route(web::get().to(about::get)))); diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 378a05dd..53d336c1 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -4,7 +4,7 @@ use crate::common::WebAppData; use crate::config; use crate::errors::ServiceResult; use crate::models::response::OkResponse; -use crate::routes::API_VERSION; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/routes/tag.rs b/src/routes/tag.rs index fb8f51bf..025031c6 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -5,7 +5,7 @@ use crate::common::WebAppData; use crate::errors::ServiceResult; use crate::models::response::OkResponse; use crate::models::torrent_tag::TagId; -use crate::routes::API_VERSION; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 40edc129..299a89e5 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -14,9 +14,9 @@ use crate::models::info_hash::InfoHash; use crate::models::response::{NewTorrentResponse, OkResponse}; use crate::models::torrent::TorrentRequest; use crate::models::torrent_tag::TagId; -use crate::routes::API_VERSION; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/routes/user.rs b/src/routes/user.rs index 020726b3..6ff78170 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; use crate::models::response::{OkResponse, TokenResponse}; -use crate::routes::API_VERSION; use crate::web::api::v1::contexts::user::forms::RegistrationForm; +use crate::web::api::API_VERSION; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/services/about.rs b/src/services/about.rs index b0b18c4a..fed3d973 100644 --- a/src/services/about.rs +++ b/src/services/about.rs @@ -1,5 +1,5 @@ //! Templates for "about" static pages. -use crate::routes::API_VERSION; +use crate::web::api::API_VERSION; #[must_use] pub fn index_page() -> String { diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs index 9321f433..8159eae3 100644 --- a/src/web/api/mod.rs +++ b/src/web/api/mod.rs @@ -15,6 +15,8 @@ use tokio::task::JoinHandle; use crate::common::AppData; use crate::web::api; +pub const API_VERSION: &str = "v1"; + /// API implementations. pub enum Implementation { /// API implementation with Actix Web. From d8b2104ff4eb4d38301087a1bef69c2b8eea9b60 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jun 2023 13:11:07 +0100 Subject: [PATCH 235/357] refactor(api): move Create struct It's been used also in Axum implementation and ActixWeb implementation will be removed. --- src/models/torrent.rs | 26 ++++++++++++++++++++- src/routes/torrent.rs | 25 +------------------- src/web/api/v1/contexts/torrent/handlers.rs | 3 +-- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 41325b9a..cc31b02e 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; +use super::torrent_tag::TagId; +use crate::errors::ServiceError; use crate::models::torrent_file::Torrent; -use crate::routes::torrent::Create; #[allow(clippy::module_name_repetitions)] pub type TorrentId = i64; @@ -28,3 +29,26 @@ pub struct TorrentRequest { pub fields: Create, pub torrent: Torrent, } + +#[derive(Debug, Deserialize)] +pub struct Create { + pub title: String, + pub description: String, + pub category: String, + pub tags: Vec, +} + +impl Create { + /// Returns the verify of this [`Create`]. + /// + /// # Errors + /// + /// This function will return an `BadRequest` error if the `title` or the `category` is empty. + pub fn verify(&self) -> Result<(), ServiceError> { + if self.title.is_empty() || self.category.is_empty() { + Err(ServiceError::BadRequest) + } else { + Ok(()) + } + } +} diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 299a89e5..e9ed757c 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -12,7 +12,7 @@ use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; use crate::models::info_hash::InfoHash; use crate::models::response::{NewTorrentResponse, OkResponse}; -use crate::models::torrent::TorrentRequest; +use crate::models::torrent::{Create, TorrentRequest}; use crate::models::torrent_tag::TagId; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; @@ -40,29 +40,6 @@ pub struct Count { pub count: i32, } -#[derive(Debug, Deserialize)] -pub struct Create { - pub title: String, - pub description: String, - pub category: String, - pub tags: Vec, -} - -impl Create { - /// Returns the verify of this [`Create`]. - /// - /// # Errors - /// - /// This function will return an `BadRequest` error if the `title` or the `category` is empty. - pub fn verify(&self) -> Result<(), ServiceError> { - if self.title.is_empty() || self.category.is_empty() { - Err(ServiceError::BadRequest) - } else { - Ok(()) - } - } -} - #[derive(Debug, Deserialize)] pub struct Update { title: Option, diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index df518170..6e928953 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -14,9 +14,8 @@ use super::responses::{new_torrent_response, torrent_file_response}; use crate::common::AppData; use crate::errors::ServiceError; use crate::models::info_hash::InfoHash; -use crate::models::torrent::TorrentRequest; +use crate::models::torrent::{Create, TorrentRequest}; use crate::models::torrent_tag::TagId; -use crate::routes::torrent::Create; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; use crate::web::api::v1::auth::get_optional_logged_in_user; From ff8816f82ca1cba5cbcc75b1add0015a5127cbed Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jun 2023 13:15:06 +0100 Subject: [PATCH 236/357] refactor(api): rename structs --- src/models/torrent.rs | 8 ++++---- src/routes/torrent.rs | 11 +++++++---- src/services/torrent.rs | 19 ++++++++++++------- src/web/api/v1/contexts/torrent/handlers.rs | 11 +++++++---- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/models/torrent.rs b/src/models/torrent.rs index cc31b02e..f9d4dfa5 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -25,20 +25,20 @@ pub struct TorrentListing { #[allow(clippy::module_name_repetitions)] #[derive(Debug)] -pub struct TorrentRequest { - pub fields: Create, +pub struct AddTorrentRequest { + pub metadata: Metadata, pub torrent: Torrent, } #[derive(Debug, Deserialize)] -pub struct Create { +pub struct Metadata { pub title: String, pub description: String, pub category: String, pub tags: Vec, } -impl Create { +impl Metadata { /// Returns the verify of this [`Create`]. /// /// # Errors diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index e9ed757c..d422e3b7 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -12,7 +12,7 @@ use crate::common::WebAppData; use crate::errors::{ServiceError, ServiceResult}; use crate::models::info_hash::InfoHash; use crate::models::response::{NewTorrentResponse, OkResponse}; -use crate::models::torrent::{Create, TorrentRequest}; +use crate::models::torrent::{AddTorrentRequest, Metadata}; use crate::models::torrent_tag::TagId; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; @@ -166,7 +166,7 @@ fn get_torrent_info_hash_from_request(req: &HttpRequest) -> Result Result { +async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let torrent_buffer = vec![0u8]; let mut torrent_cursor = Cursor::new(torrent_buffer); @@ -209,7 +209,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result Result Result { + pub async fn add_torrent(&self, mut torrent_request: AddTorrentRequest, user_id: UserId) -> Result { torrent_request.torrent.set_announce_urls(&self.configuration).await; let category = self .category_repository - .get_by_name(&torrent_request.fields.category) + .get_by_name(&torrent_request.metadata.category) .await .map_err(|_| ServiceError::InvalidCategory)?; @@ -126,7 +126,7 @@ impl Index { } self.torrent_tag_repository - .link_torrent_to_tags(&torrent_id, &torrent_request.fields.tags) + .link_torrent_to_tags(&torrent_id, &torrent_request.metadata.tags) .await?; Ok(torrent_id) @@ -417,14 +417,19 @@ impl DbTorrentRepository { /// # Errors /// /// This function will return an error there is a database error. - pub async fn add(&self, torrent_request: &TorrentRequest, user_id: UserId, category: Category) -> Result { + pub async fn add( + &self, + torrent_request: &AddTorrentRequest, + user_id: UserId, + category: Category, + ) -> Result { self.database .insert_torrent_and_get_id( &torrent_request.torrent, user_id, category.category_id, - &torrent_request.fields.title, - &torrent_request.fields.description, + &torrent_request.metadata.title, + &torrent_request.metadata.description, ) .await } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 6e928953..a3381d71 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -14,7 +14,7 @@ use super::responses::{new_torrent_response, torrent_file_response}; use crate::common::AppData; use crate::errors::ServiceError; use crate::models::info_hash::InfoHash; -use crate::models::torrent::{Create, TorrentRequest}; +use crate::models::torrent::{AddTorrentRequest, Metadata}; use crate::models::torrent_tag::TagId; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; @@ -209,7 +209,7 @@ pub async fn delete_torrent_handler( /// - The multipart content is invalid. /// - The torrent file pieces key has a length that is not a multiple of 20. /// - The binary data cannot be decoded as a torrent file. -async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { +async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let torrent_buffer = vec![0u8]; let mut torrent_cursor = Cursor::new(torrent_buffer); @@ -266,7 +266,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result Result Date: Mon, 19 Jun 2023 13:27:41 +0100 Subject: [PATCH 237/357] refactor(api): move auth logic to web api It depends on the web framework (request). --- src/app.rs | 2 +- src/auth.rs | 109 ----------------------------------------- src/common.rs | 2 +- src/lib.rs | 1 - src/web/api/v1/auth.rs | 100 ++++++++++++++++++++++++++++++++++++- 5 files changed, 101 insertions(+), 113 deletions(-) delete mode 100644 src/auth.rs diff --git a/src/app.rs b/src/app.rs index b36098a9..1c270bc4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use tokio::task::JoinHandle; -use crate::auth::Authentication; use crate::bootstrap::logging; use crate::cache::image::manager::ImageCacheService; use crate::common::AppData; @@ -19,6 +18,7 @@ use crate::services::torrent::{ use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; +use crate::web::api::v1::auth::Authentication; use crate::web::api::{start, Implementation}; use crate::{mailer, tracker}; diff --git a/src/auth.rs b/src/auth.rs deleted file mode 100644 index 4550a5ae..00000000 --- a/src/auth.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::sync::Arc; - -use actix_web::HttpRequest; - -use crate::errors::ServiceError; -use crate::models::user::{UserClaims, UserCompact, UserId}; -use crate::services::authentication::JsonWebToken; -use crate::web::api::v1::extractors::bearer_token::BearerToken; - -// todo: refactor this after finishing migration to Axum. -// - Extract service to handle Json Web Tokens: `new`, `sign_jwt`, `verify_jwt`. -// - Move the rest to `src/web/api/v1/auth.rs`. It's a helper for Axum handlers -// to get user id from request. - -pub struct Authentication { - json_web_token: Arc, -} - -impl Authentication { - #[must_use] - pub fn new(json_web_token: Arc) -> Self { - Self { json_web_token } - } - - /// Create Json Web Token - pub async fn sign_jwt(&self, user: UserCompact) -> String { - self.json_web_token.sign(user).await - } - - /// Verify Json Web Token - /// - /// # Errors - /// - /// This function will return an error if the JWT is not good or expired. - pub async fn verify_jwt(&self, token: &str) -> Result { - self.json_web_token.verify(token).await - } - - // Begin ActixWeb - - /// Get User id from `ActixWeb` Request - /// - /// # Errors - /// - /// This function will return an error if it can get claims from the request - pub async fn get_user_id_from_actix_web_request(&self, req: &HttpRequest) -> Result { - let claims = self.get_claims_from_actix_web_request(req).await?; - Ok(claims.user.user_id) - } - - /// Get Claims from `ActixWeb` Request - /// - /// # Errors - /// - /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. - /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. - async fn get_claims_from_actix_web_request(&self, req: &HttpRequest) -> Result { - match req.headers().get("Authorization") { - Some(auth) => { - let split: Vec<&str> = auth - .to_str() - .expect("variable `auth` contains data that is not visible ASCII chars.") - .split("Bearer") - .collect(); - let token = split[1].trim(); - - match self.verify_jwt(token).await { - Ok(claims) => Ok(claims), - Err(e) => Err(e), - } - } - None => Err(ServiceError::TokenNotFound), - } - } - - // End ActixWeb - - // Begin Axum - - /// Get logged-in user ID from bearer token - /// - /// # Errors - /// - /// This function will return an error if it can get claims from the request - pub async fn get_user_id_from_bearer_token(&self, maybe_token: &Option) -> Result { - let claims = self.get_claims_from_bearer_token(maybe_token).await?; - Ok(claims.user.user_id) - } - - /// Get Claims from bearer token - /// - /// # Errors - /// - /// This function will: - /// - /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. - /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. - async fn get_claims_from_bearer_token(&self, maybe_token: &Option) -> Result { - match maybe_token { - Some(token) => match self.verify_jwt(&token.value()).await { - Ok(claims) => Ok(claims), - Err(e) => Err(e), - }, - None => Err(ServiceError::TokenNotFound), - } - } - - // End Axum -} diff --git a/src/common.rs b/src/common.rs index 94e28828..90815ca8 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use crate::auth::Authentication; use crate::cache::image::manager::ImageCacheService; use crate::config::Configuration; use crate::databases::database::Database; @@ -14,6 +13,7 @@ use crate::services::torrent::{ use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; +use crate::web::api::v1::auth::Authentication; use crate::{mailer, tracker}; pub type Username = String; diff --git a/src/lib.rs b/src/lib.rs index adb5e5f1..36a7b879 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -257,7 +257,6 @@ //! In addition to the production code documentation you can find a lot of //! examples in the [tests](https://github.com/torrust/torrust-index-backend/tree/develop/tests/e2e/contexts) directory. pub mod app; -pub mod auth; pub mod bootstrap; pub mod cache; pub mod common; diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs index e93f642c..268efc14 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/v1/auth.rs @@ -80,13 +80,111 @@ //! ``` use std::sync::Arc; +use actix_web::HttpRequest; use hyper::http::HeaderValue; use crate::common::AppData; use crate::errors::ServiceError; -use crate::models::user::UserId; +use crate::models::user::{UserClaims, UserCompact, UserId}; +use crate::services::authentication::JsonWebToken; use crate::web::api::v1::extractors::bearer_token::BearerToken; +pub struct Authentication { + json_web_token: Arc, +} + +impl Authentication { + #[must_use] + pub fn new(json_web_token: Arc) -> Self { + Self { json_web_token } + } + + /// Create Json Web Token + pub async fn sign_jwt(&self, user: UserCompact) -> String { + self.json_web_token.sign(user).await + } + + /// Verify Json Web Token + /// + /// # Errors + /// + /// This function will return an error if the JWT is not good or expired. + pub async fn verify_jwt(&self, token: &str) -> Result { + self.json_web_token.verify(token).await + } + + // Begin ActixWeb + + /// Get User id from `ActixWeb` Request + /// + /// # Errors + /// + /// This function will return an error if it can get claims from the request + pub async fn get_user_id_from_actix_web_request(&self, req: &HttpRequest) -> Result { + let claims = self.get_claims_from_actix_web_request(req).await?; + Ok(claims.user.user_id) + } + + /// Get Claims from `ActixWeb` Request + /// + /// # Errors + /// + /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. + /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. + async fn get_claims_from_actix_web_request(&self, req: &HttpRequest) -> Result { + match req.headers().get("Authorization") { + Some(auth) => { + let split: Vec<&str> = auth + .to_str() + .expect("variable `auth` contains data that is not visible ASCII chars.") + .split("Bearer") + .collect(); + let token = split[1].trim(); + + match self.verify_jwt(token).await { + Ok(claims) => Ok(claims), + Err(e) => Err(e), + } + } + None => Err(ServiceError::TokenNotFound), + } + } + + // End ActixWeb + + // Begin Axum + + /// Get logged-in user ID from bearer token + /// + /// # Errors + /// + /// This function will return an error if it can get claims from the request + pub async fn get_user_id_from_bearer_token(&self, maybe_token: &Option) -> Result { + let claims = self.get_claims_from_bearer_token(maybe_token).await?; + Ok(claims.user.user_id) + } + + /// Get Claims from bearer token + /// + /// # Errors + /// + /// This function will: + /// + /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. + /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. + async fn get_claims_from_bearer_token(&self, maybe_token: &Option) -> Result { + match maybe_token { + Some(token) => match self.verify_jwt(&token.value()).await { + Ok(claims) => Ok(claims), + Err(e) => Err(e), + }, + None => Err(ServiceError::TokenNotFound), + } + } + + // End Axum +} + /// Parses the token from the `Authorization` header. pub fn parse_token(authorization: &HeaderValue) -> String { let split: Vec<&str> = authorization From 6fc6872e5de4b62b5b2f34a4398fb3bc0b61f20d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jun 2023 12:05:40 +0100 Subject: [PATCH 238/357] refactor(api): [#197] make Axum implementation the default one --- .github/workflows/develop.yml | 2 +- src/bin/main.rs | 6 +- tests/e2e/config.rs | 4 +- tests/e2e/contexts/about/contract.rs | 15 ++ tests/e2e/contexts/category/contract.rs | 104 ++++++------ tests/e2e/contexts/root/contract.rs | 9 + tests/e2e/contexts/settings/contract.rs | 47 +++--- tests/e2e/contexts/tag/contract.rs | 109 ++++++------ tests/e2e/contexts/torrent/contract.rs | 210 ++++++++++++------------ tests/e2e/contexts/user/contract.rs | 86 +++++----- 10 files changed, 317 insertions(+), 275 deletions(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index b0a958d3..c613b039 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -32,4 +32,4 @@ jobs: - name: E2E Tests run: ./docker/bin/run-e2e-tests.sh env: - TORRUST_IDX_BACK_E2E_EXCLUDE_AXUM_IMPL: "true" + TORRUST_IDX_BACK_E2E_EXCLUDE_ACTIX_WEB_IMPL: "true" diff --git a/src/bin/main.rs b/src/bin/main.rs index 332c352d..46922a13 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -6,11 +6,7 @@ use torrust_index_backend::web::api::Implementation; async fn main() -> Result<(), std::io::Error> { let configuration = init_configuration().await; - // todo: we are migrating from actix-web to axum, so we need to keep both - // implementations for a while. For production we only use ActixWeb. - // Once the Axum implementation is finished and stable, we can switch to it - // and remove the ActixWeb implementation. - let api_implementation = Implementation::ActixWeb; + let api_implementation = Implementation::Axum; let app = app::run(configuration, &api_implementation).await; diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index abf056fd..ce168333 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -14,8 +14,8 @@ pub const ENV_VAR_E2E_SHARED: &str = "TORRUST_IDX_BACK_E2E_SHARED"; /// The whole `config.toml` file content. It has priority over the config file. pub const ENV_VAR_E2E_CONFIG: &str = "TORRUST_IDX_BACK_E2E_CONFIG"; -/// If present, E2E tests for new Axum implementation will not be executed -pub const ENV_VAR_E2E_EXCLUDE_AXUM_IMPL: &str = "TORRUST_IDX_BACK_E2E_EXCLUDE_AXUM_IMPL"; +/// If present, E2E tests for new `ActixWeb` implementation will not be executed +pub const ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL: &str = "TORRUST_IDX_BACK_E2E_EXCLUDE_ACTIX_WEB_IMPL"; // Default values diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs index efe30227..7c3e84b2 100644 --- a/tests/e2e/contexts/about/contract.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -1,14 +1,23 @@ //! API contract for `about` context. +use std::env; + use torrust_index_backend::web::api; use crate::common::asserts::{assert_response_title, assert_text_ok}; use crate::common::client::Client; +use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::environment::TestEnv; #[tokio::test] async fn it_should_load_the_about_page_with_information_about_the_api() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.about().await; @@ -21,6 +30,12 @@ async fn it_should_load_the_about_page_with_information_about_the_api() { async fn it_should_load_the_license_page_at_the_api_entrypoint() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.license().await; diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index 8200cf22..3187d89d 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -1,4 +1,6 @@ //! API contract for `category` context. +use std::env; + use torrust_index_backend::web::api; use crate::common::asserts::assert_json_ok_response; @@ -6,6 +8,7 @@ use crate::common::client::Client; use crate::common::contexts::category::fixtures::random_category_name; use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use crate::common::contexts::category::responses::{AddedCategoryResponse, ListResponse}; +use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::category::steps::{add_category, add_random_category}; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; @@ -14,6 +17,12 @@ use crate::e2e::environment::TestEnv; async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_categories().await; @@ -25,6 +34,12 @@ async fn it_should_return_an_empty_category_list_when_there_are_no_categories() async fn it_should_return_a_category_list() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); add_random_category(&env).await; @@ -47,6 +62,12 @@ async fn it_should_return_a_category_list() { async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client @@ -64,6 +85,11 @@ async fn it_should_not_allow_adding_a_new_category_to_non_admins() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); @@ -83,6 +109,11 @@ async fn it_should_allow_admins_to_add_new_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -111,6 +142,11 @@ async fn it_should_allow_adding_empty_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if env.is_shared() { // This test cannot be run in a shared test env because it will fail // when the empty category already exits @@ -144,6 +180,11 @@ async fn it_should_not_allow_adding_duplicated_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let added_category_name = add_random_category(&env).await; // Try to add the same category again @@ -156,6 +197,11 @@ async fn it_should_allow_admins_to_delete_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -182,6 +228,11 @@ async fn it_should_not_allow_non_admins_to_delete_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let added_category_name = add_random_category(&env).await; let logged_in_non_admin = new_logged_in_user(&env).await; @@ -201,6 +252,12 @@ async fn it_should_not_allow_non_admins_to_delete_categories() { async fn it_should_not_allow_guests_to_delete_categories() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let added_category_name = add_random_category(&env).await; @@ -216,7 +273,6 @@ async fn it_should_not_allow_guests_to_delete_categories() { } mod with_axum_implementation { - use std::env; use torrust_index_backend::web::api; @@ -226,7 +282,6 @@ mod with_axum_implementation { use crate::common::contexts::category::fixtures::random_category_name; use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use crate::common::contexts::category::responses::ListResponse; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::category::steps::{add_category, add_random_category}; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; @@ -248,11 +303,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); add_random_category(&env).await; @@ -276,11 +326,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client @@ -298,11 +343,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); @@ -322,11 +362,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -356,11 +391,6 @@ mod with_axum_implementation { return; } - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -381,11 +411,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let added_category_name = add_random_category(&env).await; // Try to add the same category again @@ -399,11 +424,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -424,11 +444,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let added_category_name = add_random_category(&env).await; let logged_in_non_admin = new_logged_in_user(&env).await; @@ -449,11 +464,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let added_category_name = add_random_category(&env).await; diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs index ab5269b4..bf7bd754 100644 --- a/tests/e2e/contexts/root/contract.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -1,14 +1,23 @@ //! API contract for `root` context. +use std::env; + use torrust_index_backend::web::api; use crate::common::asserts::{assert_response_title, assert_text_ok}; use crate::common::client::Client; +use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::environment::TestEnv; #[tokio::test] async fn it_should_load_the_about_page_at_the_api_entrypoint() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.root().await; diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index 97e6082e..d82d45a3 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -1,7 +1,10 @@ +use std::env; + use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; +use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::user::steps::new_logged_in_admin; use crate::e2e::environment::TestEnv; @@ -9,6 +12,12 @@ use crate::e2e::environment::TestEnv; async fn it_should_allow_guests_to_get_the_public_settings() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_public_settings().await; @@ -34,6 +43,12 @@ async fn it_should_allow_guests_to_get_the_public_settings() { async fn it_should_allow_guests_to_get_the_site_name() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_site_name().await; @@ -52,6 +67,11 @@ async fn it_should_allow_admins_to_get_all_the_settings() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -71,6 +91,11 @@ async fn it_should_allow_admins_to_update_all_the_settings() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.is_isolated() { // This test can't be executed in a non-isolated environment because // it will change the settings for all the other tests. @@ -96,14 +121,12 @@ async fn it_should_allow_admins_to_update_all_the_settings() { } mod with_axum_implementation { - use std::env; use torrust_index_backend::web::api; use crate::common::asserts::assert_json_ok_response; use crate::common::client::Client; use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::user::steps::new_logged_in_admin; use crate::e2e::environment::TestEnv; @@ -112,11 +135,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_public_settings().await; @@ -142,11 +160,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_site_name().await; @@ -163,11 +176,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -185,11 +193,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.is_isolated() { // This test can't be executed in a non-isolated environment because // it will change the settings for all the other tests. diff --git a/tests/e2e/contexts/tag/contract.rs b/tests/e2e/contexts/tag/contract.rs index 9dd8ff15..711b1c0a 100644 --- a/tests/e2e/contexts/tag/contract.rs +++ b/tests/e2e/contexts/tag/contract.rs @@ -1,4 +1,6 @@ //! API contract for `tag` context. +use std::env; + use torrust_index_backend::web::api; use crate::common::asserts::assert_json_ok_response; @@ -6,6 +8,7 @@ use crate::common::client::Client; use crate::common::contexts::tag::fixtures::random_tag_name; use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; use crate::common::contexts::tag::responses::{AddedTagResponse, DeletedTagResponse, ListResponse}; +use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::tag::steps::{add_random_tag, add_tag}; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; @@ -14,6 +17,12 @@ use crate::e2e::environment::TestEnv; async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_tags().await; @@ -25,6 +34,12 @@ async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { async fn it_should_return_a_tag_list() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); // Add a tag @@ -50,6 +65,12 @@ async fn it_should_return_a_tag_list() { async fn it_should_not_allow_adding_a_new_tag_to_unauthenticated_users() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client @@ -66,6 +87,11 @@ async fn it_should_not_allow_adding_a_new_tag_to_non_admins() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); @@ -84,6 +110,11 @@ async fn it_should_allow_admins_to_add_new_tags() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -111,6 +142,11 @@ async fn it_should_allow_adding_duplicated_tags() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + // Add a tag let random_tag_name = random_tag_name(); let response = add_tag(&random_tag_name, &env).await; @@ -128,6 +164,11 @@ async fn it_should_allow_adding_a_tag_with_an_empty_name() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let empty_tag_name = String::new(); let response = add_tag(&empty_tag_name, &env).await; assert_eq!(response.status, 200); @@ -138,6 +179,11 @@ async fn it_should_allow_admins_to_delete_tags() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -159,6 +205,11 @@ async fn it_should_not_allow_non_admins_to_delete_tags() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); @@ -173,6 +224,12 @@ async fn it_should_not_allow_non_admins_to_delete_tags() { async fn it_should_not_allow_guests_to_delete_tags() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let (tag_id, _tag_name) = add_random_tag(&env).await; @@ -183,7 +240,6 @@ async fn it_should_not_allow_guests_to_delete_tags() { } mod with_axum_implementation { - use std::env; use torrust_index_backend::web::api; @@ -193,7 +249,6 @@ mod with_axum_implementation { use crate::common::contexts::tag::fixtures::random_tag_name; use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; use crate::common::contexts::tag::responses::ListResponse; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::tag::steps::{add_random_tag, add_tag}; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; @@ -203,11 +258,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client.get_tags().await; @@ -220,11 +270,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); // Add a tag @@ -251,11 +296,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let response = client @@ -272,11 +312,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); @@ -295,11 +330,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -321,11 +351,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - // Add a tag let random_tag_name = random_tag_name(); let response = add_tag(&random_tag_name, &env).await; @@ -343,11 +368,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let empty_tag_name = String::new(); let response = add_tag(&empty_tag_name, &env).await; assert_eq!(response.status, 200); @@ -358,11 +378,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -378,11 +393,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); @@ -398,11 +408,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let (tag_id, _tag_name) = add_random_tag(&env).await; diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index c14acf67..52c084fe 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -15,6 +15,8 @@ Get torrent info: */ mod for_guests { + use std::env; + use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; @@ -26,6 +28,7 @@ mod for_guests { Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, }; use crate::common::http::{Query, QueryParam}; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::torrent::asserts::expected_torrent; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; @@ -36,6 +39,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -59,6 +67,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -89,6 +102,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -124,6 +142,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -155,6 +178,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -218,6 +246,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -243,6 +276,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -263,6 +301,11 @@ mod for_guests { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -281,6 +324,8 @@ mod for_guests { mod for_authenticated_users { + use std::env; + use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; @@ -288,6 +333,7 @@ mod for_authenticated_users { use crate::common::contexts::torrent::fixtures::random_torrent; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; @@ -298,6 +344,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -327,6 +378,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let uploader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); @@ -346,6 +402,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -375,6 +436,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -405,6 +471,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -437,10 +508,13 @@ mod for_authenticated_users { } mod and_non_admins { + use std::env; + use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -450,6 +524,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -470,6 +549,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -501,11 +585,14 @@ mod for_authenticated_users { } mod and_torrent_owners { + use std::env; + use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -515,6 +602,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -549,11 +641,14 @@ mod for_authenticated_users { } mod and_admins { + use std::env; + use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; @@ -563,6 +658,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -587,6 +687,11 @@ mod for_authenticated_users { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -626,8 +731,6 @@ mod with_axum_implementation { mod for_guests { - use std::env; - use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; @@ -639,7 +742,6 @@ mod with_axum_implementation { Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, }; use crate::common::http::{Query, QueryParam}; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::asserts::expected_torrent; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; @@ -650,11 +752,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -678,11 +775,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -713,11 +805,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -753,11 +840,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -789,11 +871,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -857,11 +934,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -887,11 +959,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -912,11 +979,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -935,8 +997,6 @@ mod with_axum_implementation { mod for_authenticated_users { - use std::env; - use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; @@ -945,7 +1005,6 @@ mod with_axum_implementation { use crate::common::contexts::torrent::fixtures::random_torrent; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; @@ -956,11 +1015,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -990,11 +1044,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let uploader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); @@ -1014,11 +1063,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -1047,11 +1091,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -1082,11 +1121,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -1120,13 +1154,10 @@ mod with_axum_implementation { mod and_non_admins { - use std::env; - use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -1136,11 +1167,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -1161,11 +1187,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -1198,14 +1219,11 @@ mod with_axum_implementation { mod and_torrent_owners { - use std::env; - use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::new_logged_in_user; use crate::e2e::environment::TestEnv; @@ -1215,11 +1233,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -1255,14 +1268,11 @@ mod with_axum_implementation { mod and_admins { - use std::env; - use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::torrent::forms::UpdateTorrentFrom; use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; use crate::e2e::environment::TestEnv; @@ -1272,11 +1282,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; @@ -1301,11 +1306,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - if !env.provides_a_tracker() { println!("test skipped. It requires a tracker to be running."); return; diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 73eff810..17732da9 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -1,4 +1,6 @@ //! API contract for `user` context. +use std::env; + use torrust_index_backend::web::api; use crate::common::client::Client; @@ -7,6 +9,7 @@ use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVer use crate::common::contexts::user::responses::{ SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, }; +use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::user::steps::{new_logged_in_user, new_registered_user}; use crate::e2e::environment::TestEnv; @@ -42,6 +45,12 @@ the mailcatcher API. async fn it_should_allow_a_guest_user_to_register() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let form = random_user_registration_form(); @@ -59,6 +68,12 @@ async fn it_should_allow_a_guest_user_to_register() { async fn it_should_allow_a_registered_user_to_login() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let registered_user = new_registered_user(&env).await; @@ -84,6 +99,12 @@ async fn it_should_allow_a_registered_user_to_login() { async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let logged_in_user = new_logged_in_user(&env).await; @@ -108,6 +129,11 @@ async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_w let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_user = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); @@ -134,11 +160,14 @@ async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_w } mod banned_user_list { + use std::env; + use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::user::forms::Username; use crate::common::contexts::user::responses::BannedUserResponse; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; use crate::e2e::environment::TestEnv; @@ -147,6 +176,11 @@ mod banned_user_list { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -168,6 +202,11 @@ mod banned_user_list { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let logged_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); @@ -182,6 +221,12 @@ mod banned_user_list { async fn it_should_not_allow_a_guest_to_ban_a_user() { let mut env = TestEnv::new(); env.start(api::Implementation::ActixWeb).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { + println!("Skipped"); + return; + } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let registered_user = new_registered_user(&env).await; @@ -195,14 +240,12 @@ mod banned_user_list { mod with_axum_implementation { mod registration { - use std::env; use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::user::asserts::assert_added_user_response; use crate::common::contexts::user::fixtures::random_user_registration_form; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::environment::TestEnv; #[tokio::test] @@ -210,11 +253,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let form = random_user_registration_form(); @@ -226,7 +264,6 @@ mod with_axum_implementation { } mod authentication { - use std::env; use torrust_index_backend::web::api; @@ -235,7 +272,6 @@ mod with_axum_implementation { assert_successful_login_response, assert_token_renewal_response, assert_token_verified_response, }; use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::user::steps::{new_logged_in_user, new_registered_user}; use crate::e2e::environment::TestEnv; @@ -244,11 +280,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let registered_user = new_registered_user(&env).await; @@ -268,11 +299,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let logged_in_user = new_logged_in_user(&env).await; @@ -292,11 +318,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_user = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); @@ -312,14 +333,12 @@ mod with_axum_implementation { } mod banned_user_list { - use std::env; use torrust_index_backend::web::api; use crate::common::client::Client; use crate::common::contexts::user::asserts::assert_banned_user_response; use crate::common::contexts::user::forms::Username; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; use crate::e2e::environment::TestEnv; @@ -328,11 +347,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); @@ -349,11 +363,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let logged_non_admin = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); @@ -370,11 +379,6 @@ mod with_axum_implementation { let mut env = TestEnv::new(); env.start(api::Implementation::Axum).await; - if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { - println!("Skipped"); - return; - } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let registered_user = new_registered_user(&env).await; From 44c799e29edb66f1d505d74a685ecc05a6518b1b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jun 2023 12:48:46 +0100 Subject: [PATCH 239/357] refator(api): [#208] remove ActixWeb implementation --- .github/workflows/develop.yml | 2 - src/app.rs | 2 - src/bin/import_tracker_statistics.rs | 2 +- src/bin/main.rs | 1 - src/bin/upgrade.rs | 2 +- src/common.rs | 2 - src/errors.rs | 26 +- src/lib.rs | 3 +- src/models/torrent.rs | 2 +- src/routes/about.rs | 38 -- src/routes/category.rs | 71 --- src/routes/mod.rs | 21 - src/routes/proxy.rs | 60 -- src/routes/root.rs | 9 - src/routes/settings.rs | 76 --- src/routes/tag.rs | 76 --- src/routes/torrent.rs | 237 -------- src/routes/user.rs | 145 ----- src/web/api/actix.rs | 75 --- src/web/api/axum.rs | 1 - src/web/api/mod.rs | 10 - src/web/api/v1/auth.rs | 44 -- src/web/api/v1/contexts/tag/mod.rs | 2 +- src/web/api/v1/contexts/torrent/mod.rs | 2 +- src/web/api/v1/contexts/user/mod.rs | 14 +- tests/e2e/config.rs | 3 - tests/e2e/contexts/about/contract.rs | 45 -- tests/e2e/contexts/category/contract.rs | 273 --------- tests/e2e/contexts/root/contract.rs | 27 - tests/e2e/contexts/settings/contract.rs | 123 +--- tests/e2e/contexts/tag/contract.rs | 240 -------- tests/e2e/contexts/torrent/contract.rs | 713 ------------------------ tests/e2e/contexts/user/contract.rs | 211 ------- tests/environments/app_starter.rs | 1 - 34 files changed, 15 insertions(+), 2544 deletions(-) delete mode 100644 src/routes/about.rs delete mode 100644 src/routes/category.rs delete mode 100644 src/routes/mod.rs delete mode 100644 src/routes/proxy.rs delete mode 100644 src/routes/root.rs delete mode 100644 src/routes/settings.rs delete mode 100644 src/routes/tag.rs delete mode 100644 src/routes/torrent.rs delete mode 100644 src/routes/user.rs delete mode 100644 src/web/api/actix.rs diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index c613b039..2ee931b2 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -31,5 +31,3 @@ jobs: run: cargo llvm-cov nextest - name: E2E Tests run: ./docker/bin/run-e2e-tests.sh - env: - TORRUST_IDX_BACK_E2E_EXCLUDE_ACTIX_WEB_IMPL: "true" diff --git a/src/app.rs b/src/app.rs index 1c270bc4..c1d89e3c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,7 +24,6 @@ use crate::{mailer, tracker}; pub struct Running { pub api_socket_addr: SocketAddr, - pub actix_web_api_server: Option>>, pub axum_api_server: Option>>, pub tracker_data_importer_handle: tokio::task::JoinHandle<()>, } @@ -171,7 +170,6 @@ pub async fn run(configuration: Configuration, api_implementation: &Implementati Running { api_socket_addr: running_api.socket_addr, - actix_web_api_server: running_api.actix_web_api_server, axum_api_server: running_api.axum_api_server, tracker_data_importer_handle: tracker_statistics_importer_handle, } diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index 0b7f7288..894863fa 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -5,7 +5,7 @@ //! You can execute it with: `cargo run --bin import_tracker_statistics` use torrust_index_backend::console::commands::import_tracker_statistics::run_importer; -#[actix_web::main] +#[tokio::main] async fn main() { run_importer().await; } diff --git a/src/bin/main.rs b/src/bin/main.rs index 46922a13..3772d321 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -11,7 +11,6 @@ async fn main() -> Result<(), std::io::Error> { let app = app::run(configuration, &api_implementation).await; match api_implementation { - Implementation::ActixWeb => app.actix_web_api_server.unwrap().await.expect("the API server was dropped"), Implementation::Axum => app.axum_api_server.unwrap().await.expect("the Axum API server was dropped"), } } diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index 8fb1ee0c..486bde93 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -4,7 +4,7 @@ use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run; -#[actix_web::main] +#[tokio::main] async fn main() { run().await; } diff --git a/src/common.rs b/src/common.rs index 90815ca8..0af991a2 100644 --- a/src/common.rs +++ b/src/common.rs @@ -17,8 +17,6 @@ use crate::web::api::v1::auth::Authentication; use crate::{mailer, tracker}; pub type Username = String; -pub type WebAppData = actix_web::web::Data>; - pub struct AppData { pub cfg: Arc, pub database: Arc>, diff --git a/src/errors.rs b/src/errors.rs index 02404896..16c89353 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,10 +1,8 @@ use std::borrow::Cow; use std::error; -use actix_web::http::{header, StatusCode}; -use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError}; use derive_more::{Display, Error}; -use serde::{Deserialize, Serialize}; +use hyper::StatusCode; use crate::databases::database; @@ -139,28 +137,6 @@ pub enum ServiceError { DatabaseError, } -// Begin ActixWeb error handling -// todo: remove after migration to Axum - -#[derive(Serialize, Deserialize)] -pub struct ErrorToResponse { - pub error: String, -} - -impl ResponseError for ServiceError { - fn status_code(&self) -> StatusCode { - http_status_code_for_service_error(self) - } - - fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()) - .append_header((header::CONTENT_TYPE, "application/json; charset=UTF-8")) - .body(serde_json::to_string(&ErrorToResponse { error: self.to_string() }).unwrap()) - } -} - -// End ActixWeb error handling - impl From for ServiceError { fn from(e: sqlx::Error) -> Self { eprintln!("{e:?}"); diff --git a/src/lib.rs b/src/lib.rs index 36a7b879..ae01a9a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ //! //! This is the backend API for [Torrust Tracker Index](https://github.com/torrust/torrust-index). //! -//! It is written in Rust and uses the [actix-web](https://actix.rs/) framework. It is designed to be +//! It is written in Rust and uses the [Axum](https://github.com/tokio-rs/axum) framework. It is designed to be //! used with by the [Torrust Tracker Index Frontend](https://github.com/torrust/torrust-index-frontend). //! //! If you are looking for information on how to use the API, please see the @@ -266,7 +266,6 @@ pub mod databases; pub mod errors; pub mod mailer; pub mod models; -pub mod routes; pub mod services; pub mod tracker; pub mod ui; diff --git a/src/models/torrent.rs b/src/models/torrent.rs index f9d4dfa5..c06800ca 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -39,7 +39,7 @@ pub struct Metadata { } impl Metadata { - /// Returns the verify of this [`Create`]. + /// Returns the verify of this [`Metadata`]. /// /// # Errors /// diff --git a/src/routes/about.rs b/src/routes/about.rs deleted file mode 100644 index a88e3865..00000000 --- a/src/routes/about.rs +++ /dev/null @@ -1,38 +0,0 @@ -use actix_web::http::StatusCode; -use actix_web::{web, HttpResponse, Responder}; - -use crate::errors::ServiceResult; -use crate::services::about::{index_page, license_page}; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope(&format!("/{API_VERSION}/about")) - .service(web::resource("").route(web::get().to(get))) - .service(web::resource("/license").route(web::get().to(license))), - ); -} - -/// Get About Section HTML -/// -/// # Errors -/// -/// This function will not return an error. -#[allow(clippy::unused_async)] -pub async fn get() -> ServiceResult { - Ok(HttpResponse::build(StatusCode::OK) - .content_type("text/html; charset=utf-8") - .body(index_page())) -} - -/// Get the License in HTML -/// -/// # Errors -/// -/// This function will not return an error. -#[allow(clippy::unused_async)] -pub async fn license() -> ServiceResult { - Ok(HttpResponse::build(StatusCode::OK) - .content_type("text/html; charset=utf-8") - .body(license_page())) -} diff --git a/src/routes/category.rs b/src/routes/category.rs deleted file mode 100644 index bd285867..00000000 --- a/src/routes/category.rs +++ /dev/null @@ -1,71 +0,0 @@ -use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use serde::{Deserialize, Serialize}; - -use crate::common::WebAppData; -use crate::errors::ServiceResult; -use crate::models::response::OkResponse; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope(&format!("/{API_VERSION}/category")).service( - web::resource("") - .route(web::get().to(get)) - .route(web::post().to(add)) - .route(web::delete().to(delete)), - ), - ); -} - -/// Gets the Categories -/// -/// # Errors -/// -/// This function will return an error if there is a database error. -pub async fn get(app_data: WebAppData) -> ServiceResult { - let categories = app_data.category_repository.get_all().await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: categories })) -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Category { - pub name: String, - pub icon: Option, -} - -/// Adds a New Category -/// -/// # Errors -/// -/// This function will return an error if unable to get user. -/// This function will return an error if unable to insert into the database the new category. -pub async fn add(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - let _category_id = app_data.category_service.add_category(&payload.name, &user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: payload.name.clone(), - })) -} - -/// Deletes a Category -/// -/// # Errors -/// -/// This function will return an error if unable to get user. -/// This function will return an error if unable to delete the category from the database. -pub async fn delete(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - // code-review: why do we need to send the whole category object to delete it? - // And we should use the ID instead of the name, because the name could change - // or we could add support for multiple languages. - - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - app_data.category_service.delete_category(&payload.name, &user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: payload.name.clone(), - })) -} diff --git a/src/routes/mod.rs b/src/routes/mod.rs deleted file mode 100644 index 25ac1551..00000000 --- a/src/routes/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -use actix_web::web; - -pub mod about; -pub mod category; -pub mod proxy; -pub mod root; -pub mod settings; -pub mod tag; -pub mod torrent; -pub mod user; - -pub fn init(cfg: &mut web::ServiceConfig) { - user::init(cfg); - torrent::init(cfg); - category::init(cfg); - settings::init(cfg); - about::init(cfg); - proxy::init(cfg); - tag::init(cfg); - root::init(cfg); -} diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs deleted file mode 100644 index ac6e2967..00000000 --- a/src/routes/proxy.rs +++ /dev/null @@ -1,60 +0,0 @@ -use actix_web::http::StatusCode; -use actix_web::{web, HttpRequest, HttpResponse, Responder}; - -use crate::cache::image::manager::Error; -use crate::common::WebAppData; -use crate::errors::ServiceResult; -use crate::ui::proxy::{load_error_images, map_error_to_image}; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope(&format!("/{API_VERSION}/proxy")).service(web::resource("/image/{url}").route(web::get().to(get_proxy_image))), - ); - - load_error_images(); -} - -/// Get the proxy image. -/// -/// # Errors -/// -/// This function will return `Ok` only for now. -pub async fn get_proxy_image(req: HttpRequest, app_data: WebAppData, path: web::Path) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); - - match user_id { - Some(user_id) => { - // Get image URL from URL path - let encoded_image_url = path.into_inner(); - let image_url = urlencoding::decode(&encoded_image_url).unwrap_or_default(); - - match app_data.proxy_service.get_image_by_url(&image_url, &user_id).await { - Ok(image_bytes) => { - // Returns the cached image. - Ok(HttpResponse::build(StatusCode::OK) - .content_type("image/png") - .append_header(("Cache-Control", "max-age=15552000")) - .body(image_bytes)) - } - Err(e) => - // Returns an error image. - // Handling status codes in the frontend other tan OK is quite a pain. - // Return OK for now. - { - Ok(HttpResponse::build(StatusCode::OK) - .content_type("image/png") - .append_header(("Cache-Control", "no-cache")) - .body(map_error_to_image(&e))) - } - } - } - None => { - // Unauthenticated users can't see images. - Ok(HttpResponse::build(StatusCode::OK) - .content_type("image/png") - .append_header(("Cache-Control", "no-cache")) - .body(map_error_to_image(&Error::Unauthenticated))) - } - } -} diff --git a/src/routes/root.rs b/src/routes/root.rs deleted file mode 100644 index 7c82ecbd..00000000 --- a/src/routes/root.rs +++ /dev/null @@ -1,9 +0,0 @@ -use actix_web::web; - -use crate::routes::about; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("/").service(web::resource("").route(web::get().to(about::get)))); - cfg.service(web::scope(&format!("/{API_VERSION}")).service(web::resource("").route(web::get().to(about::get)))); -} diff --git a/src/routes/settings.rs b/src/routes/settings.rs deleted file mode 100644 index 53d336c1..00000000 --- a/src/routes/settings.rs +++ /dev/null @@ -1,76 +0,0 @@ -use actix_web::{web, HttpRequest, HttpResponse, Responder}; - -use crate::common::WebAppData; -use crate::config; -use crate::errors::ServiceResult; -use crate::models::response::OkResponse; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope(&format!("/{API_VERSION}/settings")) - .service( - web::resource("") - .route(web::get().to(get_all_handler)) - .route(web::post().to(update_handler)), - ) - .service(web::resource("/name").route(web::get().to(get_site_name_handler))) - .service(web::resource("/public").route(web::get().to(get_public_handler))), - ); -} - -/// Get Settings -/// -/// # Errors -/// -/// This function will return an error if unable to get user from database. -pub async fn get_all_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - let all_settings = app_data.settings_service.get_all(&user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: all_settings })) -} - -/// Update the settings -/// -/// # Errors -/// -/// Will return an error if: -/// -/// - There is no logged-in user. -/// - The user is not an administrator. -/// - The settings could not be updated because they were loaded from env vars. -pub async fn update_handler( - req: HttpRequest, - payload: web::Json, - app_data: WebAppData, -) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - let new_settings = app_data.settings_service.update_all(payload.into_inner(), &user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: new_settings })) -} - -/// Get Public Settings -/// -/// # Errors -/// -/// This function should not return an error. -pub async fn get_public_handler(app_data: WebAppData) -> ServiceResult { - let public_settings = app_data.settings_service.get_public().await; - - Ok(HttpResponse::Ok().json(OkResponse { data: public_settings })) -} - -/// Get Name of Website -/// -/// # Errors -/// -/// This function should not return an error. -pub async fn get_site_name_handler(app_data: WebAppData) -> ServiceResult { - let site_name = app_data.settings_service.get_site_name().await; - - Ok(HttpResponse::Ok().json(OkResponse { data: site_name })) -} diff --git a/src/routes/tag.rs b/src/routes/tag.rs deleted file mode 100644 index 025031c6..00000000 --- a/src/routes/tag.rs +++ /dev/null @@ -1,76 +0,0 @@ -use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use serde::{Deserialize, Serialize}; - -use crate::common::WebAppData; -use crate::errors::ServiceResult; -use crate::models::response::OkResponse; -use crate::models::torrent_tag::TagId; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope(&format!("/{API_VERSION}/tag")).service( - web::resource("") - .route(web::post().to(create)) - .route(web::delete().to(delete)), - ), - ); - cfg.service(web::scope(&format!("/{API_VERSION}/tags")).service(web::resource("").route(web::get().to(get_all)))); -} - -/// Get Tags -/// -/// # Errors -/// -/// This function will return an error if unable to get tags from database. -pub async fn get_all(app_data: WebAppData) -> ServiceResult { - let tags = app_data.tag_repository.get_all().await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: tags })) -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Create { - pub name: String, -} - -/// Create Tag -/// -/// # Errors -/// -/// This function will return an error if unable to: -/// -/// * Get the requesting user id from the request. -/// * Get the compact user from the user id. -/// * Add the new tag to the database. -pub async fn create(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - app_data.tag_service.add_tag(&payload.name, &user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: payload.name.to_string(), - })) -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Delete { - pub tag_id: TagId, -} - -/// Delete Tag -/// -/// # Errors -/// -/// This function will return an error if unable to: -/// -/// * Get the requesting user id from the request. -/// * Get the compact user from the user id. -/// * Delete the tag from the database. -pub async fn delete(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - app_data.tag_service.delete_tag(&payload.tag_id, &user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: payload.tag_id })) -} diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs deleted file mode 100644 index d422e3b7..00000000 --- a/src/routes/torrent.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::io::{Cursor, Write}; -use std::str::FromStr; - -use actix_multipart::Multipart; -use actix_web::web::Query; -use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use futures::{StreamExt, TryStreamExt}; -use serde::Deserialize; -use sqlx::FromRow; - -use crate::common::WebAppData; -use crate::errors::{ServiceError, ServiceResult}; -use crate::models::info_hash::InfoHash; -use crate::models::response::{NewTorrentResponse, OkResponse}; -use crate::models::torrent::{AddTorrentRequest, Metadata}; -use crate::models::torrent_tag::TagId; -use crate::services::torrent::ListingRequest; -use crate::utils::parse_torrent; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope(&format!("/{API_VERSION}/torrent")) - .service(web::resource("/upload").route(web::post().to(upload_torrent_handler))) - .service(web::resource("/download/{info_hash}").route(web::get().to(download_torrent_handler))) - .service( - web::resource("/{info_hash}") - .route(web::get().to(get_torrent_info_handler)) - .route(web::put().to(update_torrent_info_handler)) - .route(web::delete().to(delete_torrent_handler)), - ), - ); - cfg.service( - web::scope(&format!("/{API_VERSION}/torrents")).service(web::resource("").route(web::get().to(get_torrents_handler))), - ); -} - -#[derive(FromRow)] -pub struct Count { - pub count: i32, -} - -#[derive(Debug, Deserialize)] -pub struct Update { - title: Option, - description: Option, - tags: Option>, -} - -/// Upload a Torrent to the Index -/// -/// # Errors -/// -/// This function will return an error if there was a problem uploading the -/// torrent. -pub async fn upload_torrent_handler(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - let torrent_request = get_torrent_request_from_payload(payload).await?; - - let info_hash = torrent_request.torrent.info_hash().clone(); - - let torrent_service = app_data.torrent_service.clone(); - - let torrent_id = torrent_service.add_torrent(torrent_request, user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: NewTorrentResponse { torrent_id, info_hash }, - })) -} - -/// Returns the torrent as a byte stream `application/x-bittorrent`. -/// -/// # Errors -/// -/// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. -pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); - - let torrent = app_data.torrent_service.get_torrent(&info_hash, user_id).await?; - - let buffer = parse_torrent::encode_torrent(&torrent).map_err(|_| ServiceError::InternalServerError)?; - - Ok(HttpResponse::Ok().content_type("application/x-bittorrent").body(buffer)) -} - -/// Get Torrent from the Index -/// -/// # Errors -/// -/// This function will return an error if unable to get torrent info. -pub async fn get_torrent_info_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); - - let torrent_response = app_data.torrent_service.get_torrent_info(&info_hash, user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) -} - -/// Update a Torrent in the Index -/// -/// # Errors -/// -/// This function will return an error if unable to: -/// -/// * Get the user id from the request. -/// * Get the torrent info-hash from the request. -/// * Update the torrent info. -pub async fn update_torrent_info_handler( - req: HttpRequest, - payload: web::Json, - app_data: WebAppData, -) -> ServiceResult { - let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - let torrent_response = app_data - .torrent_service - .update_torrent_info(&info_hash, &payload.title, &payload.description, &payload.tags, &user_id) - .await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: torrent_response })) -} - -/// Delete a Torrent from the Index -/// -/// # Errors -/// -/// This function will return an error if unable to: -/// -/// * Get the user id from the request. -/// * Get the torrent info-hash from the request. -/// * Delete the torrent. -pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - - let deleted_torrent_response = app_data.torrent_service.delete_torrent(&info_hash, &user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: deleted_torrent_response, - })) -} - -/// It returns a list of torrents matching the search criteria. -/// Eg: `/torrents?categories=music,other,movie&search=bunny&sort=size_DESC` -/// -/// # Errors -/// -/// Returns a `ServiceError::DatabaseError` if the database query fails. -pub async fn get_torrents_handler(criteria: Query, app_data: WebAppData) -> ServiceResult { - let torrents_response = app_data.torrent_service.generate_torrent_info_listing(&criteria).await?; - - Ok(HttpResponse::Ok().json(OkResponse { data: torrents_response })) -} - -fn get_torrent_info_hash_from_request(req: &HttpRequest) -> Result { - match req.match_info().get("info_hash") { - None => Err(ServiceError::BadRequest), - Some(info_hash) => match InfoHash::from_str(info_hash) { - Err(_) => Err(ServiceError::BadRequest), - Ok(v) => Ok(v), - }, - } -} - -async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { - let torrent_buffer = vec![0u8]; - let mut torrent_cursor = Cursor::new(torrent_buffer); - - let mut title = String::new(); - let mut description = String::new(); - let mut category = String::new(); - let mut tags: Vec = vec![]; - - while let Ok(Some(mut field)) = payload.try_next().await { - match field.content_disposition().get_name().unwrap() { - "title" | "description" | "category" | "tags" => { - let data = field.next().await; - - if data.is_none() { - continue; - } - - let wrapped_data = &data.unwrap().map_err(|_| ServiceError::BadRequest)?; - let parsed_data = std::str::from_utf8(wrapped_data).map_err(|_| ServiceError::BadRequest)?; - - match field.content_disposition().get_name().unwrap() { - "title" => title = parsed_data.to_string(), - "description" => description = parsed_data.to_string(), - "category" => category = parsed_data.to_string(), - "tags" => tags = serde_json::from_str(parsed_data).map_err(|_| ServiceError::BadRequest)?, - _ => {} - } - } - "torrent" => { - if *field.content_type().unwrap() != "application/x-bittorrent" { - return Err(ServiceError::InvalidFileType); - } - - while let Some(chunk) = field.next().await { - let data = chunk.unwrap(); - torrent_cursor.write_all(&data)?; - } - } - _ => {} - } - } - - let fields = Metadata { - title, - description, - category, - tags, - }; - - fields.verify()?; - - let position = usize::try_from(torrent_cursor.position()).map_err(|_| ServiceError::InvalidTorrentFile)?; - let inner = torrent_cursor.get_ref(); - - let torrent = parse_torrent::decode_torrent(&inner[..position]).map_err(|_| ServiceError::InvalidTorrentFile)?; - - // make sure that the pieces key has a length that is a multiple of 20 - if let Some(pieces) = torrent.info.pieces.as_ref() { - if pieces.as_ref().len() % 20 != 0 { - return Err(ServiceError::InvalidTorrentPiecesLength); - } - } - - Ok(AddTorrentRequest { - metadata: fields, - torrent, - }) -} diff --git a/src/routes/user.rs b/src/routes/user.rs deleted file mode 100644 index 6ff78170..00000000 --- a/src/routes/user.rs +++ /dev/null @@ -1,145 +0,0 @@ -use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use serde::{Deserialize, Serialize}; - -use crate::common::WebAppData; -use crate::errors::{ServiceError, ServiceResult}; -use crate::models::response::{OkResponse, TokenResponse}; -use crate::web::api::v1::contexts::user::forms::RegistrationForm; -use crate::web::api::API_VERSION; - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope(&format!("/{API_VERSION}/user")) - // Registration - .service(web::resource("/register").route(web::post().to(registration_handler))) - // code-review: should this be part of the REST API? - // - This endpoint should only verify the email. - // - There should be an independent service (web app) serving the email verification page. - // The wep app can user this endpoint to verify the email and render the page accordingly. - .service(web::resource("/email/verify/{token}").route(web::get().to(email_verification_handler))) - // Authentication - .service(web::resource("/login").route(web::post().to(login_handler))) - .service(web::resource("/token/verify").route(web::post().to(verify_token_handler))) - .service(web::resource("/token/renew").route(web::post().to(renew_token_handler))) - // User Access Ban - // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. - .service(web::resource("/ban/{user}").route(web::delete().to(ban_handler))), - ); -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Login { - pub login: String, - pub password: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Token { - pub token: String, -} - -/// Register a User in the Index -/// -/// # Errors -/// -/// This function will return an error if the user could not be registered. -pub async fn registration_handler( - req: HttpRequest, - registration_form: web::Json, - app_data: WebAppData, -) -> ServiceResult { - let conn_info = req.connection_info().clone(); - // todo: check if `base_url` option was define in settings `net->base_url`. - // It should have priority over request he - let api_base_url = format!("{}://{}", conn_info.scheme(), conn_info.host()); - - let _user_id = app_data - .registration_service - .register_user(®istration_form, &api_base_url) - .await?; - - Ok(HttpResponse::Ok()) -} - -/// Login user to Index -/// -/// # Errors -/// -/// This function will return an error if the user could not be logged in. -pub async fn login_handler(payload: web::Json, app_data: WebAppData) -> ServiceResult { - let (token, user_compact) = app_data - .authentication_service - .login(&payload.login, &payload.password) - .await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: TokenResponse { - token, - username: user_compact.username, - admin: user_compact.administrator, - }, - })) -} - -/// Verify a supplied JWT. -/// -/// # Errors -/// -/// This function will return an error if unable to verify the supplied payload as a valid jwt. -pub async fn verify_token_handler(payload: web::Json, app_data: WebAppData) -> ServiceResult { - // Verify if JWT is valid - let _claims = app_data.json_web_token.verify(&payload.token).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: "Token is valid.".to_string(), - })) -} - -/// Renew a supplied JWT. -/// -/// # Errors -/// -/// This function will return an error if unable to verify the supplied -/// payload as a valid JWT. -pub async fn renew_token_handler(payload: web::Json, app_data: WebAppData) -> ServiceResult { - let (token, user_compact) = app_data.authentication_service.renew_token(&payload.token).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: TokenResponse { - token, - username: user_compact.username, - admin: user_compact.administrator, - }, - })) -} - -pub async fn email_verification_handler(req: HttpRequest, app_data: WebAppData) -> String { - // Get token from URL path - let token = match req.match_info().get("token").ok_or(ServiceError::InternalServerError) { - Ok(token) => token, - Err(err) => return err.to_string(), - }; - - match app_data.registration_service.verify_email(token).await { - Ok(_) => String::from("Email verified, you can close this page."), - Err(error) => error.to_string(), - } -} - -/// Ban a user from the Index -/// -/// TODO: add reason and `date_expiry` parameters to request -/// -/// # Errors -/// -/// This function will return if the user could not be banned. -pub async fn ban_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; - let to_be_banned_username = req.match_info().get("user").ok_or(ServiceError::InternalServerError)?; - - app_data.ban_service.ban_user(to_be_banned_username, &user_id).await?; - - Ok(HttpResponse::Ok().json(OkResponse { - data: format!("Banned user: {to_be_banned_username}"), - })) -} diff --git a/src/web/api/actix.rs b/src/web/api/actix.rs deleted file mode 100644 index 47b5f3d6..00000000 --- a/src/web/api/actix.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::net::SocketAddr; -use std::sync::Arc; - -use actix_cors::Cors; -use actix_web::{middleware, web, App, HttpServer}; -use log::info; -use tokio::sync::oneshot::{self, Sender}; - -use super::Running; -use crate::common::AppData; -use crate::routes; -use crate::web::api::ServerStartedMessage; - -/// Starts the API server with `ActixWeb`. -/// -/// # Panics -/// -/// Panics if the API server can't be started. -pub async fn start(app_data: Arc, net_ip: &str, net_port: u16) -> Running { - let config_socket_addr: SocketAddr = format!("{net_ip}:{net_port}") - .parse() - .expect("API server socket address to be valid."); - - let (tx, rx) = oneshot::channel::(); - - // Run the API server - let join_handle = tokio::spawn(async move { - info!("Starting API server with net config: {} ...", config_socket_addr); - - let server_future = start_server(config_socket_addr, app_data.clone(), tx); - - let _ = server_future.await; - - Ok(()) - }); - - // Wait until the API server is running - let bound_addr = match rx.await { - Ok(msg) => msg.socket_addr, - Err(e) => panic!("API server start. The API server was dropped: {e}"), - }; - - info!("API server started"); - - Running { - socket_addr: bound_addr, - actix_web_api_server: Some(join_handle), - axum_api_server: None, - } -} - -fn start_server( - config_socket_addr: SocketAddr, - app_data: Arc, - tx: Sender, -) -> actix_web::dev::Server { - let server = HttpServer::new(move || { - App::new() - .wrap(Cors::permissive()) - .app_data(web::Data::new(app_data.clone())) - .wrap(middleware::Logger::default()) - .configure(routes::init) - }) - .bind(config_socket_addr) - .expect("can't bind server to socket address"); - - let bound_addr = server.addrs()[0]; - - info!("API server listening on http://{}", bound_addr); - - tx.send(ServerStartedMessage { socket_addr: bound_addr }) - .expect("the API server should not be dropped"); - - server.run() -} diff --git a/src/web/api/axum.rs b/src/web/api/axum.rs index 5371dbc9..c281381c 100644 --- a/src/web/api/axum.rs +++ b/src/web/api/axum.rs @@ -42,7 +42,6 @@ pub async fn start(app_data: Arc, net_ip: &str, net_port: u16) -> Runni Running { socket_addr: bound_addr, - actix_web_api_server: None, axum_api_server: Some(join_handle), } } diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs index 8159eae3..31c38487 100644 --- a/src/web/api/mod.rs +++ b/src/web/api/mod.rs @@ -3,7 +3,6 @@ //! Currently, the API has only one version: `v1`. //! //! Refer to the [`v1`](crate::web::api::v1) module for more information. -pub mod actix; pub mod axum; pub mod v1; @@ -19,8 +18,6 @@ pub const API_VERSION: &str = "v1"; /// API implementations. pub enum Implementation { - /// API implementation with Actix Web. - ActixWeb, /// API implementation with Axum. Axum, } @@ -29,8 +26,6 @@ pub enum Implementation { pub struct Running { /// The socket address the API server is listening on. pub socket_addr: SocketAddr, - /// The API server when using Actix Web. - pub actix_web_api_server: Option>>, /// The handle for the running API server task when using Axum. pub axum_api_server: Option>>, } @@ -42,14 +37,9 @@ pub struct ServerStartedMessage { } /// Starts the API server. -/// -/// We are migrating the API server from Actix Web to Axum. While the migration -/// is in progress, we will keep both implementations, running the Axum one only -/// for testing purposes. #[must_use] pub async fn start(app_data: Arc, net_ip: &str, net_port: u16, implementation: &Implementation) -> api::Running { match implementation { - Implementation::ActixWeb => actix::start(app_data, net_ip, net_port).await, Implementation::Axum => axum::start(app_data, net_ip, net_port).await, } } diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs index 268efc14..3967aa28 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/v1/auth.rs @@ -80,7 +80,6 @@ //! ``` use std::sync::Arc; -use actix_web::HttpRequest; use hyper::http::HeaderValue; use crate::common::AppData; @@ -113,47 +112,6 @@ impl Authentication { self.json_web_token.verify(token).await } - // Begin ActixWeb - - /// Get User id from `ActixWeb` Request - /// - /// # Errors - /// - /// This function will return an error if it can get claims from the request - pub async fn get_user_id_from_actix_web_request(&self, req: &HttpRequest) -> Result { - let claims = self.get_claims_from_actix_web_request(req).await?; - Ok(claims.user.user_id) - } - - /// Get Claims from `ActixWeb` Request - /// - /// # Errors - /// - /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. - /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. - async fn get_claims_from_actix_web_request(&self, req: &HttpRequest) -> Result { - match req.headers().get("Authorization") { - Some(auth) => { - let split: Vec<&str> = auth - .to_str() - .expect("variable `auth` contains data that is not visible ASCII chars.") - .split("Bearer") - .collect(); - let token = split[1].trim(); - - match self.verify_jwt(token).await { - Ok(claims) => Ok(claims), - Err(e) => Err(e), - } - } - None => Err(ServiceError::TokenNotFound), - } - } - - // End ActixWeb - - // Begin Axum - /// Get logged-in user ID from bearer token /// /// # Errors @@ -181,8 +139,6 @@ impl Authentication { None => Err(ServiceError::TokenNotFound), } } - - // End Axum } /// Parses the token from the `Authorization` header. diff --git a/src/web/api/v1/contexts/tag/mod.rs b/src/web/api/v1/contexts/tag/mod.rs index 3e969590..1d4d77de 100644 --- a/src/web/api/v1/contexts/tag/mod.rs +++ b/src/web/api/v1/contexts/tag/mod.rs @@ -41,7 +41,7 @@ //! ``` //! **Resource** //! -//! Refer to the [`Tag`](crate::databases::database::Tag) +//! Refer to the [`Tag`](crate::models::torrent_tag::TorrentTag) //! struct for more information about the response attributes. //! //! # Add a tag diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs index 78351f78..81c1651d 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -243,7 +243,7 @@ //! `description` | `Option` | The torrent description | No | `MandelbrotSet image` //! //! -//! Refer to the [`Update`](crate::routes::torrent::Update) +//! Refer to the [`UpdateTorrentInfoForm`](crate::web::api::v1::contexts::torrent::forms::UpdateTorrentInfoForm) //! struct for more information about the request attributes. //! //! **Example request** diff --git a/src/web/api/v1/contexts/user/mod.rs b/src/web/api/v1/contexts/user/mod.rs index 3a4267c0..0b0b0eb5 100644 --- a/src/web/api/v1/contexts/user/mod.rs +++ b/src/web/api/v1/contexts/user/mod.rs @@ -50,7 +50,7 @@ //! max_password_length = 64 //! ``` //! -//! Refer to the [`RegistrationForm`](crate::routes::user::RegistrationForm) +//! Refer to the [`RegistrationForm`](crate::web::api::v1::contexts::user::forms::RegistrationForm) //! struct for more information about the registration form. //! //! **Example request** @@ -97,8 +97,8 @@ //! `login` | `String` | The password | Yes | `indexadmin` //! `password` | `String` | The password | Yes | `BenoitMandelbrot1924` //! -//! Refer to the [`RegistrationForm`](crate::routes::user::Login) -//! struct for more information about the registration form. +//! Refer to the [`LoginForm`](crate::web::api::v1::contexts::user::forms::LoginForm) +//! struct for more information about the login form. //! //! **Example request** //! @@ -125,8 +125,8 @@ //! ---|---|---|---|--- //! `token` | `String` | The token you want to verify | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` //! -//! Refer to the [`Token`](crate::routes::user::Token) -//! struct for more information about the registration form. +//! Refer to the [`JsonWebToken`](crate::web::api::v1::contexts::user::forms::JsonWebToken) +//! struct for more information about the token. //! //! **Example request** //! @@ -171,8 +171,8 @@ //! ---|---|---|---|--- //! `token` | `String` | The current valid token | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` //! -//! Refer to the [`Token`](crate::routes::user::Token) -//! struct for more information about the registration form. +//! Refer to the [`JsonWebToken`](crate::web::api::v1::contexts::user::forms::JsonWebToken) +//! struct for more information about the token. //! //! **Example request** //! diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index ce168333..f3179f43 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -14,9 +14,6 @@ pub const ENV_VAR_E2E_SHARED: &str = "TORRUST_IDX_BACK_E2E_SHARED"; /// The whole `config.toml` file content. It has priority over the config file. pub const ENV_VAR_E2E_CONFIG: &str = "TORRUST_IDX_BACK_E2E_CONFIG"; -/// If present, E2E tests for new `ActixWeb` implementation will not be executed -pub const ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL: &str = "TORRUST_IDX_BACK_E2E_EXCLUDE_ACTIX_WEB_IMPL"; - // Default values pub const ENV_VAR_E2E_DEFAULT_CONFIG_PATH: &str = "./config-idx-back.local.toml"; diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs index 7c3e84b2..52d7efed 100644 --- a/tests/e2e/contexts/about/contract.rs +++ b/tests/e2e/contexts/about/contract.rs @@ -1,49 +1,4 @@ //! API contract for `about` context. -use std::env; - -use torrust_index_backend::web::api; - -use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::common::client::Client; -use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; -use crate::e2e::environment::TestEnv; - -#[tokio::test] -async fn it_should_load_the_about_page_with_information_about_the_api() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.about().await; - - assert_text_ok(&response); - assert_response_title(&response, "About"); -} - -#[tokio::test] -async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.license().await; - - assert_text_ok(&response); - assert_response_title(&response, "Licensing"); -} - mod with_axum_implementation { use torrust_index_backend::web::api; diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs index 3187d89d..eb5f2d94 100644 --- a/tests/e2e/contexts/category/contract.rs +++ b/tests/e2e/contexts/category/contract.rs @@ -1,277 +1,4 @@ //! API contract for `category` context. -use std::env; - -use torrust_index_backend::web::api; - -use crate::common::asserts::assert_json_ok_response; -use crate::common::client::Client; -use crate::common::contexts::category::fixtures::random_category_name; -use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; -use crate::common::contexts::category::responses::{AddedCategoryResponse, ListResponse}; -use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; -use crate::e2e::contexts::category::steps::{add_category, add_random_category}; -use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; -use crate::e2e::environment::TestEnv; - -#[tokio::test] -async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_categories().await; - - assert_json_ok_response(&response); -} - -#[tokio::test] -async fn it_should_return_a_category_list() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - add_random_category(&env).await; - - let response = client.get_categories().await; - - let res: ListResponse = serde_json::from_str(&response.body).unwrap(); - - // There should be at least the category we added. - // Since this is an E2E test and it could be run in a shared test env, - // there might be more categories. - assert!(res.data.len() > 1); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client - .add_category(AddCategoryForm { - name: "CATEGORY NAME".to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 401); -} - -#[tokio::test] -async fn it_should_not_allow_adding_a_new_category_to_non_admins() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_non_admin = new_logged_in_user(&env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); - - let response = client - .add_category(AddCategoryForm { - name: "CATEGORY NAME".to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 403); -} - -#[tokio::test] -async fn it_should_allow_admins_to_add_new_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let category_name = random_category_name(); - - let response = client - .add_category(AddCategoryForm { - name: category_name.to_string(), - icon: None, - }) - .await; - - let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, category_name); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_adding_empty_categories() { - // code-review: this is a bit weird, is it a intended behavior? - - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if env.is_shared() { - // This test cannot be run in a shared test env because it will fail - // when the empty category already exits - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let category_name = String::new(); - - let response = client - .add_category(AddCategoryForm { - name: category_name.to_string(), - icon: None, - }) - .await; - - let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, category_name); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_not_allow_adding_duplicated_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let added_category_name = add_random_category(&env).await; - - // Try to add the same category again - let response = add_category(&added_category_name, &env).await; - assert_eq!(response.status, 400); -} - -#[tokio::test] -async fn it_should_allow_admins_to_delete_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let added_category_name = add_random_category(&env).await; - - let response = client - .delete_category(DeleteCategoryForm { - name: added_category_name.to_string(), - icon: None, - }) - .await; - - let res: AddedCategoryResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, added_category_name); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_not_allow_non_admins_to_delete_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let added_category_name = add_random_category(&env).await; - - let logged_in_non_admin = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); - - let response = client - .delete_category(DeleteCategoryForm { - name: added_category_name.to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 403); -} - -#[tokio::test] -async fn it_should_not_allow_guests_to_delete_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let added_category_name = add_random_category(&env).await; - - let response = client - .delete_category(DeleteCategoryForm { - name: added_category_name.to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 401); -} - mod with_axum_implementation { use torrust_index_backend::web::api; diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs index bf7bd754..e66661d2 100644 --- a/tests/e2e/contexts/root/contract.rs +++ b/tests/e2e/contexts/root/contract.rs @@ -1,31 +1,4 @@ //! API contract for `root` context. -use std::env; - -use torrust_index_backend::web::api; - -use crate::common::asserts::{assert_response_title, assert_text_ok}; -use crate::common::client::Client; -use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; -use crate::e2e::environment::TestEnv; - -#[tokio::test] -async fn it_should_load_the_about_page_at_the_api_entrypoint() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.root().await; - - assert_text_ok(&response); - assert_response_title(&response, "About"); -} - mod with_axum_implementation { use torrust_index_backend::web::api; diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs index d82d45a3..ac4cbd49 100644 --- a/tests/e2e/contexts/settings/contract.rs +++ b/tests/e2e/contexts/settings/contract.rs @@ -1,125 +1,4 @@ -use std::env; - -use torrust_index_backend::web::api; - -use crate::common::client::Client; -use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; -use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; -use crate::e2e::contexts::user::steps::new_logged_in_admin; -use crate::e2e::environment::TestEnv; - -#[tokio::test] -async fn it_should_allow_guests_to_get_the_public_settings() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_public_settings().await; - - let res: PublicSettingsResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!( - res.data, - Public { - website_name: env.server_settings().unwrap().website.name, - tracker_url: env.server_settings().unwrap().tracker.url, - tracker_mode: env.server_settings().unwrap().tracker.mode, - email_on_signup: env.server_settings().unwrap().auth.email_on_signup, - } - ); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_guests_to_get_the_site_name() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_site_name().await; - - let res: SiteNameResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, "Torrust"); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_admins_to_get_all_the_settings() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let response = client.get_settings().await; - - let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, env.server_settings().unwrap()); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_admins_to_update_all_the_settings() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.is_isolated() { - // This test can't be executed in a non-isolated environment because - // it will change the settings for all the other tests. - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let mut new_settings = env.server_settings().unwrap(); - - new_settings.website.name = "UPDATED NAME".to_string(); - - let response = client.update_settings(&new_settings).await; - - let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, new_settings); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - +//! API contract for `settings` context. mod with_axum_implementation { use torrust_index_backend::web::api; diff --git a/tests/e2e/contexts/tag/contract.rs b/tests/e2e/contexts/tag/contract.rs index 711b1c0a..775b53dc 100644 --- a/tests/e2e/contexts/tag/contract.rs +++ b/tests/e2e/contexts/tag/contract.rs @@ -1,244 +1,4 @@ //! API contract for `tag` context. -use std::env; - -use torrust_index_backend::web::api; - -use crate::common::asserts::assert_json_ok_response; -use crate::common::client::Client; -use crate::common::contexts::tag::fixtures::random_tag_name; -use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; -use crate::common::contexts::tag::responses::{AddedTagResponse, DeletedTagResponse, ListResponse}; -use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; -use crate::e2e::contexts::tag::steps::{add_random_tag, add_tag}; -use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; -use crate::e2e::environment::TestEnv; - -#[tokio::test] -async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_tags().await; - - assert_json_ok_response(&response); -} - -#[tokio::test] -async fn it_should_return_a_tag_list() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // Add a tag - let tag_name = random_tag_name(); - let response = add_tag(&tag_name, &env).await; - assert_eq!(response.status, 200); - - let response = client.get_tags().await; - - let res: ListResponse = serde_json::from_str(&response.body).unwrap(); - - // There should be at least the tag we added. - // Since this is an E2E test that could be executed in a shred env, - // there might be more tags. - assert!(!res.data.is_empty()); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_not_allow_adding_a_new_tag_to_unauthenticated_users() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client - .add_tag(AddTagForm { - name: "TAG NAME".to_string(), - }) - .await; - - assert_eq!(response.status, 401); -} - -#[tokio::test] -async fn it_should_not_allow_adding_a_new_tag_to_non_admins() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_non_admin = new_logged_in_user(&env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); - - let response = client - .add_tag(AddTagForm { - name: "TAG NAME".to_string(), - }) - .await; - - assert_eq!(response.status, 403); -} - -#[tokio::test] -async fn it_should_allow_admins_to_add_new_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let tag_name = random_tag_name(); - - let response = client - .add_tag(AddTagForm { - name: tag_name.to_string(), - }) - .await; - - let res: AddedTagResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, tag_name); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_adding_duplicated_tags() { - // code-review: is this an intended behavior? - - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - // Add a tag - let random_tag_name = random_tag_name(); - let response = add_tag(&random_tag_name, &env).await; - assert_eq!(response.status, 200); - - // Try to add the same tag again - let response = add_tag(&random_tag_name, &env).await; - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_adding_a_tag_with_an_empty_name() { - // code-review: is this an intended behavior? - - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let empty_tag_name = String::new(); - let response = add_tag(&empty_tag_name, &env).await; - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_admins_to_delete_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let (tag_id, _tag_name) = add_random_tag(&env).await; - - let response = client.delete_tag(DeleteTagForm { tag_id }).await; - - let res: DeletedTagResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, tag_id); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_not_allow_non_admins_to_delete_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_non_admin = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); - - let (tag_id, _tag_name) = add_random_tag(&env).await; - - let response = client.delete_tag(DeleteTagForm { tag_id }).await; - - assert_eq!(response.status, 403); -} - -#[tokio::test] -async fn it_should_not_allow_guests_to_delete_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let (tag_id, _tag_name) = add_random_tag(&env).await; - - let response = client.delete_tag(DeleteTagForm { tag_id }).await; - - assert_eq!(response.status, 401); -} - mod with_axum_implementation { use torrust_index_backend::web::api; diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 52c084fe..a01cf5d3 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -14,719 +14,6 @@ Get torrent info: - should contain realtime seeders and leechers from the tracker */ -mod for_guests { - use std::env; - - use torrust_index_backend::utils::parse_torrent::decode_torrent; - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::category::fixtures::software_predefined_category_id; - use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; - use crate::common::contexts::torrent::requests::InfoHash; - use crate::common::contexts::torrent::responses::{ - Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, - }; - use crate::common::http::{Query, QueryParam}; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; - use crate::e2e::contexts::torrent::asserts::expected_torrent; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_guests_to_get_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.get_torrents(Query::empty()).await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - assert!(torrent_list_response.data.total > 0); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_to_get_torrents_with_pagination() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - - // Given we insert two torrents - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // When we request only one torrent per page - let response = client - .get_torrents(Query::with_params([QueryParam::new("page_size", "1")].to_vec())) - .await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - // Then we should have only one torrent per page - assert_eq!(torrent_list_response.data.results.len(), 1); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_to_limit_the_number_of_torrents_per_request() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - - let max_torrent_page_size = 30; - - // Given we insert one torrent more than the page size limit - for _ in 0..max_torrent_page_size { - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // When we request more torrents than the page size limit - let response = client - .get_torrents(Query::with_params( - [QueryParam::new("page_size", &format!("{}", (max_torrent_page_size + 1)))].to_vec(), - )) - .await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - // Then we should get only the page size limit - assert_eq!(torrent_list_response.data.results.len(), max_torrent_page_size); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_return_a_default_amount_of_torrents_per_request_if_no_page_size_is_provided() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - - let default_torrent_page_size = 10; - - // Given we insert one torrent more than the default page size - for _ in 0..default_torrent_page_size { - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // When we request more torrents than the default page size limit - let response = client.get_torrents(Query::empty()).await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - // Then we should get only the default number of torrents per page - assert_eq!(torrent_list_response.data.results.len(), default_torrent_page_size); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.get_torrent(&test_torrent.info_hash()).await; - - let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); - - let tracker_url = env.server_settings().unwrap().tracker.url; - let encoded_tracker_url = urlencoding::encode(&tracker_url); - - let expected_torrent = TorrentDetails { - torrent_id: uploaded_torrent.torrent_id, - uploader: uploader.username, - info_hash: test_torrent.file_info.info_hash.to_uppercase(), - title: test_torrent.index_info.title.clone(), - description: test_torrent.index_info.description, - category: Category { - category_id: software_predefined_category_id(), - name: test_torrent.index_info.category, - num_torrents: 19, // Ignored in assertion - }, - upload_date: "2023-04-27 07:56:08".to_string(), // Ignored in assertion - file_size: test_torrent.file_info.content_size, - seeders: 0, - leechers: 0, - files: vec![File { - path: vec![test_torrent.file_info.files[0].clone()], - // Using one file torrent for testing: content_size = first file size - length: test_torrent.file_info.content_size, - md5sum: None, - }], - // code-review: why is this duplicated? It seems that is adding the - // same tracker twice because first ti adds all trackers and then - // it adds the tracker with the personal announce url, if the user - // is logged in. If the user is not logged in, it adds the default - // tracker again, and it ends up with two trackers. - trackers: vec![tracker_url.clone(), tracker_url.clone()], - magnet_link: format!( - // cspell:disable-next-line - "magnet:?xt=urn:btih:{}&dn={}&tr={}&tr={}", - test_torrent.file_info.info_hash.to_uppercase(), - urlencoding::encode(&test_torrent.index_info.title), - encoded_tracker_url, - encoded_tracker_url - ), - }; - - assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.download_torrent(&test_torrent.info_hash()).await; - - let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); - let uploaded_torrent = - decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); - let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; - assert_eq!(torrent, expected_torrent); - assert!(response.is_bittorrent_and_ok()); - } - - #[tokio::test] - async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let non_existing_info_hash: InfoHash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); - - let response = client.download_torrent(&non_existing_info_hash).await; - - // code-review: should this be 404? - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_not_allow_guests_to_delete_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.delete_torrent(&test_torrent.info_hash()).await; - - assert_eq!(response.status, 401); - } -} - -mod for_authenticated_users { - - use std::env; - - use torrust_index_backend::utils::parse_torrent::decode_torrent; - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::torrent::fixtures::random_torrent; - use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; - use crate::common::contexts::torrent::responses::UploadedTorrentResponse; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; - use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_authenticated_users_to_upload_new_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let test_torrent = random_torrent(); - let info_hash = test_torrent.info_hash().clone(); - - let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); - - let response = client.upload_torrent(form.into()).await; - - let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!( - uploaded_torrent_response.data.info_hash.to_lowercase(), - info_hash.to_lowercase() - ); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let mut test_torrent = random_torrent(); - - test_torrent.index_info.category = "non-existing-category".to_string(); - - let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); - - let response = client.upload_torrent(form.into()).await; - - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - // Upload the first torrent - let first_torrent = random_torrent(); - let first_torrent_title = first_torrent.index_info.title.clone(); - let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; - - // Upload the second torrent with the same title as the first one - let mut second_torrent = random_torrent(); - second_torrent.index_info.title = first_torrent_title; - let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); - let response = client.upload_torrent(form.into()).await; - - assert_eq!(response.body, "{\"error\":\"This torrent title has already been used.\"}"); - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - // Upload the first torrent - let first_torrent = random_torrent(); - let mut first_torrent_clone = first_torrent.clone(); - let first_torrent_title = first_torrent.index_info.title.clone(); - let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; - - // Upload the second torrent with the same info-hash as the first one. - // We need to change the title otherwise the torrent will be rejected - // because of the duplicate title. - first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); - let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); - let response = client.upload_torrent(form.into()).await; - - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - // Given a previously uploaded torrent - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - - // And a logged in user who is going to download the torrent - let downloader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); - - // When the user downloads the torrent - let response = client.download_torrent(&test_torrent.info_hash()).await; - - let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); - - // Then the torrent should have the personal announce URL - let tracker_key = get_user_tracker_key(&downloader, &env) - .await - .expect("uploader should have a valid tracker key"); - - let tracker_url = env.server_settings().unwrap().tracker.url; - - assert_eq!( - torrent.announce.unwrap(), - build_announce_url(&tracker_url, &Some(tracker_key)) - ); - } - - mod and_non_admins { - use std::env; - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_not_allow_non_admins_to_delete_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let response = client.delete_torrent(&test_torrent.info_hash()).await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - // Given a users uploads a torrent - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - // Then another non admin user should not be able to update the torrent - let not_the_uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), ¬_the_uploader.token); - - let new_title = format!("{}-new-title", test_torrent.index_info.title); - let new_description = format!("{}-new-description", test_torrent.index_info.description); - - let response = client - .update_torrent( - &test_torrent.info_hash(), - UpdateTorrentFrom { - title: Some(new_title.clone()), - description: Some(new_description.clone()), - }, - ) - .await; - - assert_eq!(response.status, 403); - } - } - - mod and_torrent_owners { - use std::env; - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_torrent_owners_to_update_their_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let new_title = format!("{}-new-title", test_torrent.index_info.title); - let new_description = format!("{}-new-description", test_torrent.index_info.description); - - let response = client - .update_torrent( - &test_torrent.info_hash(), - UpdateTorrentFrom { - title: Some(new_title.clone()), - description: Some(new_description.clone()), - }, - ) - .await; - - let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - let torrent = updated_torrent_response.data; - - assert_eq!(torrent.title, new_title); - assert_eq!(torrent.description, new_description); - assert!(response.is_json_and_ok()); - } - } - - mod and_admins { - use std::env; - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); - - let response = client.delete_torrent(&test_torrent.info_hash()).await; - - let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(deleted_torrent_response.data.torrent_id, uploaded_torrent.torrent_id); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_admins_to_update_someone_elses_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let new_title = format!("{}-new-title", test_torrent.index_info.title); - let new_description = format!("{}-new-description", test_torrent.index_info.description); - - let response = client - .update_torrent( - &test_torrent.info_hash(), - UpdateTorrentFrom { - title: Some(new_title.clone()), - description: Some(new_description.clone()), - }, - ) - .await; - - let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - let torrent = updated_torrent_response.data; - - assert_eq!(torrent.title, new_title); - assert_eq!(torrent.description, new_description); - assert!(response.is_json_and_ok()); - } - } -} - mod with_axum_implementation { mod for_guests { diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index 17732da9..e1e66d56 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -1,17 +1,4 @@ //! API contract for `user` context. -use std::env; - -use torrust_index_backend::web::api; - -use crate::common::client::Client; -use crate::common::contexts::user::fixtures::random_user_registration_form; -use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; -use crate::common::contexts::user::responses::{ - SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, -}; -use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; -use crate::e2e::contexts::user::steps::{new_logged_in_user, new_registered_user}; -use crate::e2e::environment::TestEnv; /* @@ -39,204 +26,6 @@ the mailcatcher API. */ -// Responses data - -#[tokio::test] -async fn it_should_allow_a_guest_user_to_register() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let form = random_user_registration_form(); - - let response = client.register_user(form).await; - - assert_eq!(response.body, "", "wrong response body, it should be an empty string"); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "text/plain; charset=utf-8"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_a_registered_user_to_login() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let registered_user = new_registered_user(&env).await; - - let response = client - .login_user(LoginForm { - login: registered_user.username.clone(), - password: registered_user.password.clone(), - }) - .await; - - let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); - let logged_in_user = res.data; - - assert_eq!(logged_in_user.username, registered_user.username); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let logged_in_user = new_logged_in_user(&env).await; - - let response = client - .verify_token(TokenVerificationForm { - token: logged_in_user.token.clone(), - }) - .await; - - let res: TokenVerifiedResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, "Token is valid."); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -#[tokio::test] -async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_user = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); - - let response = client - .renew_token(TokenRenewalForm { - token: logged_in_user.token.clone(), - }) - .await; - - let res: TokenRenewalResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!( - res.data, - TokenRenewalData { - token: logged_in_user.token.clone(), // The same token is returned - username: logged_in_user.username.clone(), - admin: logged_in_user.admin, - } - ); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); -} - -mod banned_user_list { - use std::env; - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::user::forms::Username; - use crate::common::contexts::user::responses::BannedUserResponse; - use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL; - use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_an_admin_to_ban_a_user() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let registered_user = new_registered_user(&env).await; - - let response = client.ban_user(Username::new(registered_user.username.clone())).await; - - let res: BannedUserResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, format!("Banned user: {}", registered_user.username)); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); - } - - #[tokio::test] - async fn it_should_not_allow_a_non_admin_to_ban_a_user() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let logged_non_admin = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); - - let registered_user = new_registered_user(&env).await; - - let response = client.ban_user(Username::new(registered_user.username.clone())).await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_not_allow_a_guest_to_ban_a_user() { - let mut env = TestEnv::new(); - env.start(api::Implementation::ActixWeb).await; - - if env::var(ENV_VAR_E2E_EXCLUDE_ACTIX_WEB_IMPL).is_ok() { - println!("Skipped"); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let registered_user = new_registered_user(&env).await; - - let response = client.ban_user(Username::new(registered_user.username.clone())).await; - - assert_eq!(response.status, 401); - } -} - mod with_axum_implementation { mod registration { diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index a08f3592..47aef6e2 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -54,7 +54,6 @@ impl AppStarter { .expect("the app starter should not be dropped"); match api_implementation { - Implementation::ActixWeb => app.actix_web_api_server.unwrap().await, Implementation::Axum => app.axum_api_server.unwrap().await, } }); From 717cdaae4c7e01ff2db88654fe7e07826fbd19dd Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jun 2023 14:49:07 +0100 Subject: [PATCH 240/357] refactor(api): [#208] use API impementation enum for API versioning We've removed the ActixWeb implementation for the API. We can use the enum for implementations for versioning the API. Currently there is only one version `v1` but i'ts ready to add the verion `v2`. --- src/app.rs | 10 +- src/bin/main.rs | 10 +- src/mailer.rs | 4 +- src/services/about.rs | 6 +- src/web/api/mod.rs | 19 +- src/web/api/{axum.rs => server.rs} | 4 +- src/web/api/v1/routes.rs | 4 +- tests/e2e/contexts/about/contract.rs | 32 - tests/e2e/contexts/category/contract.rs | 207 ------ tests/e2e/contexts/proxy/contract.rs | 6 - tests/e2e/contexts/root/contract.rs | 21 - tests/e2e/contexts/settings/contract.rs | 96 --- tests/e2e/contexts/tag/contract.rs | 179 ----- tests/e2e/contexts/torrent/contract.rs | 630 ------------------ tests/e2e/contexts/user/contract.rs | 180 ----- tests/e2e/environment.rs | 6 +- tests/e2e/mod.rs | 2 +- tests/e2e/web/api/mod.rs | 1 + .../e2e/web/api/v1/contexts/about/contract.rs | 31 + .../{ => web/api/v1}/contexts/about/mod.rs | 0 .../web/api/v1/contexts/category/contract.rs | 205 ++++++ .../{ => web/api/v1}/contexts/category/mod.rs | 0 .../api/v1}/contexts/category/steps.rs | 2 +- tests/e2e/{ => web/api/v1}/contexts/mod.rs | 0 .../e2e/web/api/v1/contexts/proxy/contract.rs | 3 + .../{ => web/api/v1}/contexts/proxy/mod.rs | 0 .../e2e/web/api/v1/contexts/root/contract.rs | 20 + .../e2e/{ => web/api/v1}/contexts/root/mod.rs | 0 .../web/api/v1/contexts/settings/contract.rs | 94 +++ .../{ => web/api/v1}/contexts/settings/mod.rs | 0 tests/e2e/web/api/v1/contexts/tag/contract.rs | 177 +++++ .../e2e/{ => web/api/v1}/contexts/tag/mod.rs | 0 .../{ => web/api/v1}/contexts/tag/steps.rs | 2 +- .../api/v1}/contexts/torrent/asserts.rs | 0 .../web/api/v1/contexts/torrent/contract.rs | 627 +++++++++++++++++ .../{ => web/api/v1}/contexts/torrent/mod.rs | 0 .../api/v1}/contexts/torrent/steps.rs | 0 .../e2e/web/api/v1/contexts/user/contract.rs | 176 +++++ .../e2e/{ => web/api/v1}/contexts/user/mod.rs | 0 .../{ => web/api/v1}/contexts/user/steps.rs | 0 tests/e2e/web/api/v1/mod.rs | 1 + tests/e2e/web/mod.rs | 1 + tests/environments/app_starter.rs | 10 +- tests/environments/isolated.rs | 10 +- 44 files changed, 1380 insertions(+), 1396 deletions(-) rename src/web/api/{axum.rs => server.rs} (96%) delete mode 100644 tests/e2e/contexts/about/contract.rs delete mode 100644 tests/e2e/contexts/category/contract.rs delete mode 100644 tests/e2e/contexts/proxy/contract.rs delete mode 100644 tests/e2e/contexts/root/contract.rs delete mode 100644 tests/e2e/contexts/settings/contract.rs delete mode 100644 tests/e2e/contexts/tag/contract.rs delete mode 100644 tests/e2e/contexts/torrent/contract.rs delete mode 100644 tests/e2e/contexts/user/contract.rs create mode 100644 tests/e2e/web/api/mod.rs create mode 100644 tests/e2e/web/api/v1/contexts/about/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/about/mod.rs (100%) create mode 100644 tests/e2e/web/api/v1/contexts/category/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/category/mod.rs (100%) rename tests/e2e/{ => web/api/v1}/contexts/category/steps.rs (93%) rename tests/e2e/{ => web/api/v1}/contexts/mod.rs (100%) create mode 100644 tests/e2e/web/api/v1/contexts/proxy/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/proxy/mod.rs (100%) create mode 100644 tests/e2e/web/api/v1/contexts/root/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/root/mod.rs (100%) create mode 100644 tests/e2e/web/api/v1/contexts/settings/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/settings/mod.rs (100%) create mode 100644 tests/e2e/web/api/v1/contexts/tag/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/tag/mod.rs (100%) rename tests/e2e/{ => web/api/v1}/contexts/tag/steps.rs (94%) rename tests/e2e/{ => web/api/v1}/contexts/torrent/asserts.rs (100%) create mode 100644 tests/e2e/web/api/v1/contexts/torrent/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/torrent/mod.rs (100%) rename tests/e2e/{ => web/api/v1}/contexts/torrent/steps.rs (100%) create mode 100644 tests/e2e/web/api/v1/contexts/user/contract.rs rename tests/e2e/{ => web/api/v1}/contexts/user/mod.rs (100%) rename tests/e2e/{ => web/api/v1}/contexts/user/steps.rs (100%) create mode 100644 tests/e2e/web/api/v1/mod.rs create mode 100644 tests/e2e/web/mod.rs diff --git a/src/app.rs b/src/app.rs index c1d89e3c..e0e263ef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,17 +19,17 @@ use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbU use crate::services::{proxy, settings, torrent}; use crate::tracker::statistics_importer::StatisticsImporter; use crate::web::api::v1::auth::Authentication; -use crate::web::api::{start, Implementation}; +use crate::web::api::{start, Version}; use crate::{mailer, tracker}; pub struct Running { pub api_socket_addr: SocketAddr, - pub axum_api_server: Option>>, + pub api_server: Option>>, pub tracker_data_importer_handle: tokio::task::JoinHandle<()>, } #[allow(clippy::too_many_lines)] -pub async fn run(configuration: Configuration, api_implementation: &Implementation) -> Running { +pub async fn run(configuration: Configuration, api_version: &Version) -> Running { let log_level = configuration.settings.read().await.log_level.clone(); logging::setup(&log_level); @@ -166,11 +166,11 @@ pub async fn run(configuration: Configuration, api_implementation: &Implementati // Start API server - let running_api = start(app_data, &net_ip, net_port, api_implementation).await; + let running_api = start(app_data, &net_ip, net_port, api_version).await; Running { api_socket_addr: running_api.socket_addr, - axum_api_server: running_api.axum_api_server, + api_server: running_api.api_server, tracker_data_importer_handle: tracker_statistics_importer_handle, } } diff --git a/src/bin/main.rs b/src/bin/main.rs index 3772d321..5660be68 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,16 +1,16 @@ use torrust_index_backend::app; use torrust_index_backend::bootstrap::config::init_configuration; -use torrust_index_backend::web::api::Implementation; +use torrust_index_backend::web::api::Version; #[tokio::main] async fn main() -> Result<(), std::io::Error> { let configuration = init_configuration().await; - let api_implementation = Implementation::Axum; + let api_version = Version::V1; - let app = app::run(configuration, &api_implementation).await; + let app = app::run(configuration, &api_version).await; - match api_implementation { - Implementation::Axum => app.axum_api_server.unwrap().await.expect("the Axum API server was dropped"), + match api_version { + Version::V1 => app.api_server.unwrap().await.expect("the API server was dropped"), } } diff --git a/src/mailer.rs b/src/mailer.rs index bf4c7a30..e55f26f9 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::config::Configuration; use crate::errors::ServiceError; use crate::utils::clock; -use crate::web::api::API_VERSION; +use crate::web::api::v1::routes::API_VERSION_URL_PREFIX; pub struct Service { cfg: Arc, @@ -147,7 +147,7 @@ impl Service { base_url = cfg_base_url; } - format!("{base_url}/{API_VERSION}/user/email/verify/{token}") + format!("{base_url}/{API_VERSION_URL_PREFIX}/user/email/verify/{token}") } } diff --git a/src/services/about.rs b/src/services/about.rs index fed3d973..100822d8 100644 --- a/src/services/about.rs +++ b/src/services/about.rs @@ -1,5 +1,5 @@ //! Templates for "about" static pages. -use crate::web::api::API_VERSION; +use crate::web::api::v1::routes::API_VERSION_URL_PREFIX; #[must_use] pub fn index_page() -> String { @@ -22,7 +22,7 @@ pub fn page() -> String {

Hi! This is a running torrust-index-backend.

"# @@ -55,7 +55,7 @@ pub fn license_page() -> String {

If you want to read more about all the licenses and how they apply please refer to the contributor agreement.

"# diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs index 31c38487..46ffe2b7 100644 --- a/src/web/api/mod.rs +++ b/src/web/api/mod.rs @@ -3,7 +3,7 @@ //! Currently, the API has only one version: `v1`. //! //! Refer to the [`v1`](crate::web::api::v1) module for more information. -pub mod axum; +pub mod server; pub mod v1; use std::net::SocketAddr; @@ -14,20 +14,17 @@ use tokio::task::JoinHandle; use crate::common::AppData; use crate::web::api; -pub const API_VERSION: &str = "v1"; - -/// API implementations. -pub enum Implementation { - /// API implementation with Axum. - Axum, +/// API versions. +pub enum Version { + V1, } /// The running API server. pub struct Running { /// The socket address the API server is listening on. pub socket_addr: SocketAddr, - /// The handle for the running API server task when using Axum. - pub axum_api_server: Option>>, + /// The handle for the running API server. + pub api_server: Option>>, } #[must_use] @@ -38,8 +35,8 @@ pub struct ServerStartedMessage { /// Starts the API server. #[must_use] -pub async fn start(app_data: Arc, net_ip: &str, net_port: u16, implementation: &Implementation) -> api::Running { +pub async fn start(app_data: Arc, net_ip: &str, net_port: u16, implementation: &Version) -> api::Running { match implementation { - Implementation::Axum => axum::start(app_data, net_ip, net_port).await, + Version::V1 => server::start(app_data, net_ip, net_port).await, } } diff --git a/src/web/api/axum.rs b/src/web/api/server.rs similarity index 96% rename from src/web/api/axum.rs rename to src/web/api/server.rs index c281381c..8fa1e704 100644 --- a/src/web/api/axum.rs +++ b/src/web/api/server.rs @@ -9,7 +9,7 @@ use super::v1::routes::router; use super::{Running, ServerStartedMessage}; use crate::common::AppData; -/// Starts the API server with `Axum`. +/// Starts the API server. /// /// # Panics /// @@ -42,7 +42,7 @@ pub async fn start(app_data: Arc, net_ip: &str, net_port: u16) -> Runni Running { socket_addr: bound_addr, - axum_api_server: Some(join_handle), + api_server: Some(join_handle), } } diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index f012d68c..586ec2d7 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -10,6 +10,8 @@ use super::contexts::{about, proxy, settings, tag, torrent}; use super::contexts::{category, user}; use crate::common::AppData; +pub const API_VERSION_URL_PREFIX: &str = "v1"; + /// Add all API routes to the router. #[allow(clippy::needless_pass_by_value)] pub fn router(app_data: Arc) -> Router { @@ -30,7 +32,7 @@ pub fn router(app_data: Arc) -> Router { Router::new() .route("/", get(about_page_handler).with_state(app_data)) - .nest("/v1", v1_api_routes) + .nest(&format!("/{API_VERSION_URL_PREFIX}"), v1_api_routes) // For development purposes only. // //.layer(CorsLayer::permissive()) // Uncomment this line and the `use` import. diff --git a/tests/e2e/contexts/about/contract.rs b/tests/e2e/contexts/about/contract.rs deleted file mode 100644 index 52d7efed..00000000 --- a/tests/e2e/contexts/about/contract.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! API contract for `about` context. -mod with_axum_implementation { - use torrust_index_backend::web::api; - - use crate::common::asserts::{assert_response_title, assert_text_ok}; - use crate::common::client::Client; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_load_the_about_page_with_information_about_the_api() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.about().await; - - assert_text_ok(&response); - assert_response_title(&response, "About"); - } - - #[tokio::test] - async fn it_should_load_the_license_page_at_the_api_entrypoint() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.license().await; - - assert_text_ok(&response); - assert_response_title(&response, "Licensing"); - } -} diff --git a/tests/e2e/contexts/category/contract.rs b/tests/e2e/contexts/category/contract.rs deleted file mode 100644 index eb5f2d94..00000000 --- a/tests/e2e/contexts/category/contract.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! API contract for `category` context. -mod with_axum_implementation { - - use torrust_index_backend::web::api; - - use crate::common::asserts::assert_json_ok_response; - use crate::common::client::Client; - use crate::common::contexts::category::asserts::{assert_added_category_response, assert_deleted_category_response}; - use crate::common::contexts::category::fixtures::random_category_name; - use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; - use crate::common::contexts::category::responses::ListResponse; - use crate::e2e::contexts::category::steps::{add_category, add_random_category}; - use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_categories().await; - - assert_json_ok_response(&response); - } - - #[tokio::test] - async fn it_should_return_a_category_list() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - add_random_category(&env).await; - - let response = client.get_categories().await; - - let res: ListResponse = serde_json::from_str(&response.body).unwrap(); - - // There should be at least the category we added. - // Since this is an E2E test and it could be run in a shared test env, - // there might be more categories. - assert!(res.data.len() > 1); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); - } - - #[tokio::test] - async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client - .add_category(AddCategoryForm { - name: "CATEGORY NAME".to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 401); - } - - #[tokio::test] - async fn it_should_not_allow_adding_a_new_category_to_non_admins() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_non_admin = new_logged_in_user(&env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); - - let response = client - .add_category(AddCategoryForm { - name: "CATEGORY NAME".to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_allow_admins_to_add_new_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let category_name = random_category_name(); - - let response = client - .add_category(AddCategoryForm { - name: category_name.to_string(), - icon: None, - }) - .await; - - assert_added_category_response(&response, &category_name); - } - - #[tokio::test] - async fn it_should_allow_adding_empty_categories() { - // code-review: this is a bit weird, is it a intended behavior? - - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if env.is_shared() { - // This test cannot be run in a shared test env because it will fail - // when the empty category already exits - println!("Skipped"); - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let category_name = String::new(); - - let response = client - .add_category(AddCategoryForm { - name: category_name.to_string(), - icon: None, - }) - .await; - - assert_added_category_response(&response, &category_name); - } - - #[tokio::test] - async fn it_should_not_allow_adding_duplicated_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let added_category_name = add_random_category(&env).await; - - // Try to add the same category again - let response = add_category(&added_category_name, &env).await; - - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_allow_admins_to_delete_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let added_category_name = add_random_category(&env).await; - - let response = client - .delete_category(DeleteCategoryForm { - name: added_category_name.to_string(), - icon: None, - }) - .await; - - assert_deleted_category_response(&response, &added_category_name); - } - - #[tokio::test] - async fn it_should_not_allow_non_admins_to_delete_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let added_category_name = add_random_category(&env).await; - - let logged_in_non_admin = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); - - let response = client - .delete_category(DeleteCategoryForm { - name: added_category_name.to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_not_allow_guests_to_delete_categories() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let added_category_name = add_random_category(&env).await; - - let response = client - .delete_category(DeleteCategoryForm { - name: added_category_name.to_string(), - icon: None, - }) - .await; - - assert_eq!(response.status, 401); - } -} diff --git a/tests/e2e/contexts/proxy/contract.rs b/tests/e2e/contexts/proxy/contract.rs deleted file mode 100644 index 46c8b8a9..00000000 --- a/tests/e2e/contexts/proxy/contract.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! API contract for `proxy` context. - -mod with_axum_implementation { - - // todo -} diff --git a/tests/e2e/contexts/root/contract.rs b/tests/e2e/contexts/root/contract.rs deleted file mode 100644 index e66661d2..00000000 --- a/tests/e2e/contexts/root/contract.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! API contract for `root` context. -mod with_axum_implementation { - use torrust_index_backend::web::api; - - use crate::common::asserts::{assert_response_title, assert_text_ok}; - use crate::common::client::Client; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_load_the_about_page_at_the_api_entrypoint() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.root().await; - - assert_text_ok(&response); - assert_response_title(&response, "About"); - } -} diff --git a/tests/e2e/contexts/settings/contract.rs b/tests/e2e/contexts/settings/contract.rs deleted file mode 100644 index ac4cbd49..00000000 --- a/tests/e2e/contexts/settings/contract.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! API contract for `settings` context. -mod with_axum_implementation { - - use torrust_index_backend::web::api; - - use crate::common::asserts::assert_json_ok_response; - use crate::common::client::Client; - use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; - use crate::e2e::contexts::user::steps::new_logged_in_admin; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_guests_to_get_the_public_settings() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_public_settings().await; - - let res: PublicSettingsResponse = serde_json::from_str(&response.body) - .unwrap_or_else(|_| panic!("response {:#?} should be a PublicSettingsResponse", response.body)); - - assert_eq!( - res.data, - Public { - website_name: env.server_settings().unwrap().website.name, - tracker_url: env.server_settings().unwrap().tracker.url, - tracker_mode: env.server_settings().unwrap().tracker.mode, - email_on_signup: env.server_settings().unwrap().auth.email_on_signup, - } - ); - - assert_json_ok_response(&response); - } - - #[tokio::test] - async fn it_should_allow_guests_to_get_the_site_name() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_site_name().await; - - let res: SiteNameResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, "Torrust"); - - assert_json_ok_response(&response); - } - - #[tokio::test] - async fn it_should_allow_admins_to_get_all_the_settings() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let response = client.get_settings().await; - - let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, env.server_settings().unwrap()); - - assert_json_ok_response(&response); - } - - #[tokio::test] - async fn it_should_allow_admins_to_update_all_the_settings() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.is_isolated() { - // This test can't be executed in a non-isolated environment because - // it will change the settings for all the other tests. - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let mut new_settings = env.server_settings().unwrap(); - - new_settings.website.name = "UPDATED NAME".to_string(); - - let response = client.update_settings(&new_settings).await; - - let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, new_settings); - - assert_json_ok_response(&response); - } -} diff --git a/tests/e2e/contexts/tag/contract.rs b/tests/e2e/contexts/tag/contract.rs deleted file mode 100644 index 775b53dc..00000000 --- a/tests/e2e/contexts/tag/contract.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! API contract for `tag` context. -mod with_axum_implementation { - - use torrust_index_backend::web::api; - - use crate::common::asserts::assert_json_ok_response; - use crate::common::client::Client; - use crate::common::contexts::tag::asserts::{assert_added_tag_response, assert_deleted_tag_response}; - use crate::common::contexts::tag::fixtures::random_tag_name; - use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; - use crate::common::contexts::tag::responses::ListResponse; - use crate::e2e::contexts::tag::steps::{add_random_tag, add_tag}; - use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client.get_tags().await; - - assert_json_ok_response(&response); - } - - #[tokio::test] - async fn it_should_return_a_tag_list() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // Add a tag - let tag_name = random_tag_name(); - let response = add_tag(&tag_name, &env).await; - assert_eq!(response.status, 200); - - let response = client.get_tags().await; - - let res: ListResponse = serde_json::from_str(&response.body).unwrap(); - - // There should be at least the tag we added. - // Since this is an E2E test that could be executed in a shred env, - // there might be more tags. - assert!(!res.data.is_empty()); - if let Some(content_type) = &response.content_type { - assert_eq!(content_type, "application/json"); - } - assert_eq!(response.status, 200); - } - - #[tokio::test] - async fn it_should_not_allow_adding_a_new_tag_to_unauthenticated_users() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let response = client - .add_tag(AddTagForm { - name: "TAG NAME".to_string(), - }) - .await; - - assert_eq!(response.status, 401); - } - - #[tokio::test] - async fn it_should_not_allow_adding_a_new_tag_to_non_admins() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_non_admin = new_logged_in_user(&env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); - - let response = client - .add_tag(AddTagForm { - name: "TAG NAME".to_string(), - }) - .await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_allow_admins_to_add_new_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let tag_name = random_tag_name(); - - let response = client - .add_tag(AddTagForm { - name: tag_name.to_string(), - }) - .await; - - assert_added_tag_response(&response, &tag_name); - } - - #[tokio::test] - async fn it_should_allow_adding_duplicated_tags() { - // code-review: is this an intended behavior? - - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - // Add a tag - let random_tag_name = random_tag_name(); - let response = add_tag(&random_tag_name, &env).await; - assert_eq!(response.status, 200); - - // Try to add the same tag again - let response = add_tag(&random_tag_name, &env).await; - assert_eq!(response.status, 200); - } - - #[tokio::test] - async fn it_should_allow_adding_a_tag_with_an_empty_name() { - // code-review: is this an intended behavior? - - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let empty_tag_name = String::new(); - let response = add_tag(&empty_tag_name, &env).await; - assert_eq!(response.status, 200); - } - - #[tokio::test] - async fn it_should_allow_admins_to_delete_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let (tag_id, _tag_name) = add_random_tag(&env).await; - - let response = client.delete_tag(DeleteTagForm { tag_id }).await; - - assert_deleted_tag_response(&response, tag_id); - } - - #[tokio::test] - async fn it_should_not_allow_non_admins_to_delete_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_non_admin = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); - - let (tag_id, _tag_name) = add_random_tag(&env).await; - - let response = client.delete_tag(DeleteTagForm { tag_id }).await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_not_allow_guests_to_delete_tags() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let (tag_id, _tag_name) = add_random_tag(&env).await; - - let response = client.delete_tag(DeleteTagForm { tag_id }).await; - - assert_eq!(response.status, 401); - } -} diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs deleted file mode 100644 index a01cf5d3..00000000 --- a/tests/e2e/contexts/torrent/contract.rs +++ /dev/null @@ -1,630 +0,0 @@ -//! API contract for `torrent` context. - -/* -todo: - -Delete torrent: - -- After deleting a torrent, it should be removed from the tracker whitelist - -Get torrent info: - -- The torrent info: - - should contain the magnet link with the trackers from the torrent file - - should contain realtime seeders and leechers from the tracker -*/ - -mod with_axum_implementation { - - mod for_guests { - - use torrust_index_backend::utils::parse_torrent::decode_torrent; - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::category::fixtures::software_predefined_category_id; - use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; - use crate::common::contexts::torrent::requests::InfoHash; - use crate::common::contexts::torrent::responses::{ - Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, - }; - use crate::common::http::{Query, QueryParam}; - use crate::e2e::contexts::torrent::asserts::expected_torrent; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_guests_to_get_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.get_torrents(Query::empty()).await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - assert!(torrent_list_response.data.total > 0); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_to_get_torrents_with_pagination() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - - // Given we insert two torrents - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // When we request only one torrent per page - let response = client - .get_torrents(Query::with_params([QueryParam::new("page_size", "1")].to_vec())) - .await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - // Then we should have only one torrent per page - assert_eq!(torrent_list_response.data.results.len(), 1); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_to_limit_the_number_of_torrents_per_request() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - - let max_torrent_page_size = 30; - - // Given we insert one torrent more than the page size limit - for _ in 0..max_torrent_page_size { - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // When we request more torrents than the page size limit - let response = client - .get_torrents(Query::with_params( - [QueryParam::new("page_size", &format!("{}", (max_torrent_page_size + 1)))].to_vec(), - )) - .await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - // Then we should get only the page size limit - assert_eq!(torrent_list_response.data.results.len(), max_torrent_page_size); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_return_a_default_amount_of_torrents_per_request_if_no_page_size_is_provided() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - - let default_torrent_page_size = 10; - - // Given we insert one torrent more than the default page size - for _ in 0..default_torrent_page_size { - let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - // When we request more torrents than the default page size limit - let response = client.get_torrents(Query::empty()).await; - - let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); - - // Then we should get only the default number of torrents per page - assert_eq!(torrent_list_response.data.results.len(), default_torrent_page_size); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.get_torrent(&test_torrent.info_hash()).await; - - let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); - - let tracker_url = env.server_settings().unwrap().tracker.url; - let encoded_tracker_url = urlencoding::encode(&tracker_url); - - let expected_torrent = TorrentDetails { - torrent_id: uploaded_torrent.torrent_id, - uploader: uploader.username, - info_hash: test_torrent.file_info.info_hash.to_uppercase(), - title: test_torrent.index_info.title.clone(), - description: test_torrent.index_info.description, - category: Category { - category_id: software_predefined_category_id(), - name: test_torrent.index_info.category, - num_torrents: 19, // Ignored in assertion - }, - upload_date: "2023-04-27 07:56:08".to_string(), // Ignored in assertion - file_size: test_torrent.file_info.content_size, - seeders: 0, - leechers: 0, - files: vec![File { - path: vec![test_torrent.file_info.files[0].clone()], - // Using one file torrent for testing: content_size = first file size - length: test_torrent.file_info.content_size, - md5sum: None, - }], - // code-review: why is this duplicated? It seems that is adding the - // same tracker twice because first ti adds all trackers and then - // it adds the tracker with the personal announce url, if the user - // is logged in. If the user is not logged in, it adds the default - // tracker again, and it ends up with two trackers. - trackers: vec![tracker_url.clone(), tracker_url.clone()], - magnet_link: format!( - // cspell:disable-next-line - "magnet:?xt=urn:btih:{}&dn={}&tr={}&tr={}", - test_torrent.file_info.info_hash.to_uppercase(), - urlencoding::encode(&test_torrent.index_info.title), - encoded_tracker_url, - encoded_tracker_url - ), - }; - - assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.download_torrent(&test_torrent.info_hash()).await; - - let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); - let uploaded_torrent = - decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); - let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; - assert_eq!(torrent, expected_torrent); - assert!(response.is_bittorrent_and_ok()); - } - - #[tokio::test] - async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let non_existing_info_hash: InfoHash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); - - let response = client.download_torrent(&non_existing_info_hash).await; - - // code-review: should this be 404? - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_not_allow_guests_to_delete_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let response = client.delete_torrent(&test_torrent.info_hash()).await; - - assert_eq!(response.status, 401); - } - } - - mod for_authenticated_users { - - use torrust_index_backend::utils::parse_torrent::decode_torrent; - use torrust_index_backend::web::api; - - use crate::common::asserts::assert_json_error_response; - use crate::common::client::Client; - use crate::common::contexts::torrent::fixtures::random_torrent; - use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; - use crate::common::contexts::torrent::responses::UploadedTorrentResponse; - use crate::e2e::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_authenticated_users_to_upload_new_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let test_torrent = random_torrent(); - let info_hash = test_torrent.info_hash().clone(); - - let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); - - let response = client.upload_torrent(form.into()).await; - - let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!( - uploaded_torrent_response.data.info_hash.to_lowercase(), - info_hash.to_lowercase() - ); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let mut test_torrent = random_torrent(); - - test_torrent.index_info.category = "non-existing-category".to_string(); - - let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); - - let response = client.upload_torrent(form.into()).await; - - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - // Upload the first torrent - let first_torrent = random_torrent(); - let first_torrent_title = first_torrent.index_info.title.clone(); - let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; - - // Upload the second torrent with the same title as the first one - let mut second_torrent = random_torrent(); - second_torrent.index_info.title = first_torrent_title; - let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); - let response = client.upload_torrent(form.into()).await; - - assert_json_error_response(&response, "This torrent title has already been used."); - } - - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - // Upload the first torrent - let first_torrent = random_torrent(); - let mut first_torrent_clone = first_torrent.clone(); - let first_torrent_title = first_torrent.index_info.title.clone(); - let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; - - // Upload the second torrent with the same info-hash as the first one. - // We need to change the title otherwise the torrent will be rejected - // because of the duplicate title. - first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); - let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); - let response = client.upload_torrent(form.into()).await; - - assert_eq!(response.status, 400); - } - - #[tokio::test] - async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - // Given a previously uploaded torrent - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - - // And a logged in user who is going to download the torrent - let downloader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); - - // When the user downloads the torrent - let response = client.download_torrent(&test_torrent.info_hash()).await; - - let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); - - // Then the torrent should have the personal announce URL - let tracker_key = get_user_tracker_key(&downloader, &env) - .await - .expect("uploader should have a valid tracker key"); - - let tracker_url = env.server_settings().unwrap().tracker.url; - - assert_eq!( - torrent.announce.unwrap(), - build_announce_url(&tracker_url, &Some(tracker_key)) - ); - } - - mod and_non_admins { - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_not_allow_non_admins_to_delete_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let response = client.delete_torrent(&test_torrent.info_hash()).await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - // Given a users uploads a torrent - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - // Then another non admin user should not be able to update the torrent - let not_the_uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), ¬_the_uploader.token); - - let new_title = format!("{}-new-title", test_torrent.index_info.title); - let new_description = format!("{}-new-description", test_torrent.index_info.description); - - let response = client - .update_torrent( - &test_torrent.info_hash(), - UpdateTorrentFrom { - title: Some(new_title.clone()), - description: Some(new_description.clone()), - }, - ) - .await; - - assert_eq!(response.status, 403); - } - } - - mod and_torrent_owners { - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::new_logged_in_user; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_torrent_owners_to_update_their_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - - let new_title = format!("{}-new-title", test_torrent.index_info.title); - let new_description = format!("{}-new-description", test_torrent.index_info.description); - - let response = client - .update_torrent( - &test_torrent.info_hash(), - UpdateTorrentFrom { - title: Some(new_title.clone()), - description: Some(new_description.clone()), - }, - ) - .await; - - let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - let torrent = updated_torrent_response.data; - - assert_eq!(torrent.title, new_title); - assert_eq!(torrent.description, new_description); - assert!(response.is_json_and_ok()); - } - } - - mod and_admins { - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::torrent::forms::UpdateTorrentFrom; - use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; - use crate::e2e::contexts::torrent::steps::upload_random_torrent_to_index; - use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); - - let response = client.delete_torrent(&test_torrent.info_hash()).await; - - let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(deleted_torrent_response.data.torrent_id, uploaded_torrent.torrent_id); - assert!(response.is_json_and_ok()); - } - - #[tokio::test] - async fn it_should_allow_admins_to_update_someone_elses_torrents() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } - - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let new_title = format!("{}-new-title", test_torrent.index_info.title); - let new_description = format!("{}-new-description", test_torrent.index_info.description); - - let response = client - .update_torrent( - &test_torrent.info_hash(), - UpdateTorrentFrom { - title: Some(new_title.clone()), - description: Some(new_description.clone()), - }, - ) - .await; - - let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - - let torrent = updated_torrent_response.data; - - assert_eq!(torrent.title, new_title); - assert_eq!(torrent.description, new_description); - assert!(response.is_json_and_ok()); - } - } - } -} diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs deleted file mode 100644 index e1e66d56..00000000 --- a/tests/e2e/contexts/user/contract.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! API contract for `user` context. - -/* - -This test suite is not complete. It's just a starting point to show how to -write E2E tests. Anyway, the goal is not to fully cover all the app features -with E2E tests. The goal is to cover the most important features and to -demonstrate how to write E2E tests. Some important pending tests could be: - -todo: - -- It should allow renewing a token one week before it expires. -- It should allow verifying user registration via email. - -The first one requires to mock the time. Consider extracting the mod - into -an independent crate. - -The second one requires: -- To call the mailcatcher API to get the verification URL. -- To enable email verification in the configuration. -- To fix current tests to verify the email for newly created users. -- To find out which email is the one that contains the verification URL for a -given test. That maybe done using the email recipient if that's possible with -the mailcatcher API. - -*/ - -mod with_axum_implementation { - - mod registration { - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::user::asserts::assert_added_user_response; - use crate::common::contexts::user::fixtures::random_user_registration_form; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_a_guest_user_to_register() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let form = random_user_registration_form(); - - let response = client.register_user(form).await; - - assert_added_user_response(&response); - } - } - - mod authentication { - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::user::asserts::{ - assert_successful_login_response, assert_token_renewal_response, assert_token_verified_response, - }; - use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; - use crate::e2e::contexts::user::steps::{new_logged_in_user, new_registered_user}; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_a_registered_user_to_login() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let registered_user = new_registered_user(&env).await; - - let response = client - .login_user(LoginForm { - login: registered_user.username.clone(), - password: registered_user.password.clone(), - }) - .await; - - assert_successful_login_response(&response, ®istered_user); - } - - #[tokio::test] - async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let logged_in_user = new_logged_in_user(&env).await; - - let response = client - .verify_token(TokenVerificationForm { - token: logged_in_user.token.clone(), - }) - .await; - - assert_token_verified_response(&response); - } - - #[tokio::test] - async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week( - ) { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_user = new_logged_in_user(&env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); - - let response = client - .renew_token(TokenRenewalForm { - token: logged_in_user.token.clone(), - }) - .await; - - assert_token_renewal_response(&response, &logged_in_user); - } - } - - mod banned_user_list { - - use torrust_index_backend::web::api; - - use crate::common::client::Client; - use crate::common::contexts::user::asserts::assert_banned_user_response; - use crate::common::contexts::user::forms::Username; - use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; - use crate::e2e::environment::TestEnv; - - #[tokio::test] - async fn it_should_allow_an_admin_to_ban_a_user() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_in_admin = new_logged_in_admin(&env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let registered_user = new_registered_user(&env).await; - - let response = client.ban_user(Username::new(registered_user.username.clone())).await; - - assert_banned_user_response(&response, ®istered_user); - } - - #[tokio::test] - async fn it_should_not_allow_a_non_admin_to_ban_a_user() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let logged_non_admin = new_logged_in_user(&env).await; - - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); - - let registered_user = new_registered_user(&env).await; - - let response = client.ban_user(Username::new(registered_user.username.clone())).await; - - assert_eq!(response.status, 403); - } - - #[tokio::test] - async fn it_should_not_allow_a_guest_to_ban_a_user() { - let mut env = TestEnv::new(); - env.start(api::Implementation::Axum).await; - - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - - let registered_user = new_registered_user(&env).await; - - let response = client.ban_user(Username::new(registered_user.username.clone())).await; - - assert_eq!(response.status, 401); - } - } -} diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 343e5512..4684fd82 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -1,6 +1,6 @@ use std::env; -use torrust_index_backend::web::api::Implementation; +use torrust_index_backend::web::api::Version; use super::config::{init_shared_env_configuration, ENV_VAR_E2E_SHARED}; use crate::common::contexts::settings::Settings; @@ -55,7 +55,7 @@ impl TestEnv { /// It starts the test environment. It can be a shared or isolated test /// environment depending on the value of the `ENV_VAR_E2E_SHARED` env var. - pub async fn start(&mut self, api_implementation: Implementation) { + pub async fn start(&mut self, api_version: Version) { let e2e_shared = ENV_VAR_E2E_SHARED; // bool if let Ok(_e2e_test_env_is_shared) = env::var(e2e_shared) { @@ -66,7 +66,7 @@ impl TestEnv { self.starting_settings = self.server_settings_for_shared_env().await; } else { // Using an isolated test env. - let isolated_env = isolated::TestEnv::running(api_implementation).await; + let isolated_env = isolated::TestEnv::running(api_version).await; self.isolated = Some(isolated_env); self.starting_settings = self.server_settings_for_isolated_env(); diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 71386b0f..2b909fd9 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -37,5 +37,5 @@ //! documentation. See for more //! information. pub mod config; -pub mod contexts; pub mod environment; +pub mod web; diff --git a/tests/e2e/web/api/mod.rs b/tests/e2e/web/api/mod.rs new file mode 100644 index 00000000..a3a6d96c --- /dev/null +++ b/tests/e2e/web/api/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/tests/e2e/web/api/v1/contexts/about/contract.rs b/tests/e2e/web/api/v1/contexts/about/contract.rs new file mode 100644 index 00000000..edbe2d2c --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/about/contract.rs @@ -0,0 +1,31 @@ +//! API contract for `about` context. + +use torrust_index_backend::web::api; + +use crate::common::asserts::{assert_response_title, assert_text_ok}; +use crate::common::client::Client; +use crate::e2e::environment::TestEnv; + +#[tokio::test] +async fn it_should_load_the_about_page_with_information_about_the_api() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.about().await; + + assert_text_ok(&response); + assert_response_title(&response, "About"); +} + +#[tokio::test] +async fn it_should_load_the_license_page_at_the_api_entrypoint() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.license().await; + + assert_text_ok(&response); + assert_response_title(&response, "Licensing"); +} diff --git a/tests/e2e/contexts/about/mod.rs b/tests/e2e/web/api/v1/contexts/about/mod.rs similarity index 100% rename from tests/e2e/contexts/about/mod.rs rename to tests/e2e/web/api/v1/contexts/about/mod.rs diff --git a/tests/e2e/web/api/v1/contexts/category/contract.rs b/tests/e2e/web/api/v1/contexts/category/contract.rs new file mode 100644 index 00000000..0ec559c9 --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/category/contract.rs @@ -0,0 +1,205 @@ +//! API contract for `category` context. + +use torrust_index_backend::web::api; + +use crate::common::asserts::assert_json_ok_response; +use crate::common::client::Client; +use crate::common::contexts::category::asserts::{assert_added_category_response, assert_deleted_category_response}; +use crate::common::contexts::category::fixtures::random_category_name; +use crate::common::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; +use crate::common::contexts::category::responses::ListResponse; +use crate::e2e::environment::TestEnv; +use crate::e2e::web::api::v1::contexts::category::steps::{add_category, add_random_category}; +use crate::e2e::web::api::v1::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; + +#[tokio::test] +async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_categories().await; + + assert_json_ok_response(&response); +} + +#[tokio::test] +async fn it_should_return_a_category_list() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + add_random_category(&env).await; + + let response = client.get_categories().await; + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + // There should be at least the category we added. + // Since this is an E2E test and it could be run in a shared test env, + // there might be more categories. + assert!(res.data.len() > 1); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_not_allow_adding_a_new_category_to_unauthenticated_users() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client + .add_category(AddCategoryForm { + name: "CATEGORY NAME".to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 401); +} + +#[tokio::test] +async fn it_should_not_allow_adding_a_new_category_to_non_admins() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let response = client + .add_category(AddCategoryForm { + name: "CATEGORY NAME".to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +async fn it_should_allow_admins_to_add_new_categories() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let category_name = random_category_name(); + + let response = client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + assert_added_category_response(&response, &category_name); +} + +#[tokio::test] +async fn it_should_allow_adding_empty_categories() { + // code-review: this is a bit weird, is it a intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if env.is_shared() { + // This test cannot be run in a shared test env because it will fail + // when the empty category already exits + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let category_name = String::new(); + + let response = client + .add_category(AddCategoryForm { + name: category_name.to_string(), + icon: None, + }) + .await; + + assert_added_category_response(&response, &category_name); +} + +#[tokio::test] +async fn it_should_not_allow_adding_duplicated_categories() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let added_category_name = add_random_category(&env).await; + + // Try to add the same category again + let response = add_category(&added_category_name, &env).await; + + assert_eq!(response.status, 400); +} + +#[tokio::test] +async fn it_should_allow_admins_to_delete_categories() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let added_category_name = add_random_category(&env).await; + + let response = client + .delete_category(DeleteCategoryForm { + name: added_category_name.to_string(), + icon: None, + }) + .await; + + assert_deleted_category_response(&response, &added_category_name); +} + +#[tokio::test] +async fn it_should_not_allow_non_admins_to_delete_categories() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let added_category_name = add_random_category(&env).await; + + let logged_in_non_admin = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); + + let response = client + .delete_category(DeleteCategoryForm { + name: added_category_name.to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +async fn it_should_not_allow_guests_to_delete_categories() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let added_category_name = add_random_category(&env).await; + + let response = client + .delete_category(DeleteCategoryForm { + name: added_category_name.to_string(), + icon: None, + }) + .await; + + assert_eq!(response.status, 401); +} diff --git a/tests/e2e/contexts/category/mod.rs b/tests/e2e/web/api/v1/contexts/category/mod.rs similarity index 100% rename from tests/e2e/contexts/category/mod.rs rename to tests/e2e/web/api/v1/contexts/category/mod.rs diff --git a/tests/e2e/contexts/category/steps.rs b/tests/e2e/web/api/v1/contexts/category/steps.rs similarity index 93% rename from tests/e2e/contexts/category/steps.rs rename to tests/e2e/web/api/v1/contexts/category/steps.rs index ab000dab..cca5b8ae 100644 --- a/tests/e2e/contexts/category/steps.rs +++ b/tests/e2e/web/api/v1/contexts/category/steps.rs @@ -3,8 +3,8 @@ use crate::common::contexts::category::fixtures::random_category_name; use crate::common::contexts::category::forms::AddCategoryForm; use crate::common::contexts::category::responses::AddedCategoryResponse; use crate::common::responses::TextResponse; -use crate::e2e::contexts::user::steps::new_logged_in_admin; use crate::e2e::environment::TestEnv; +use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_admin; /// Add a random category and return its name. pub async fn add_random_category(env: &TestEnv) -> String { diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/web/api/v1/contexts/mod.rs similarity index 100% rename from tests/e2e/contexts/mod.rs rename to tests/e2e/web/api/v1/contexts/mod.rs diff --git a/tests/e2e/web/api/v1/contexts/proxy/contract.rs b/tests/e2e/web/api/v1/contexts/proxy/contract.rs new file mode 100644 index 00000000..0b63dfc4 --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/proxy/contract.rs @@ -0,0 +1,3 @@ +//! API contract for `proxy` context. + +// todo diff --git a/tests/e2e/contexts/proxy/mod.rs b/tests/e2e/web/api/v1/contexts/proxy/mod.rs similarity index 100% rename from tests/e2e/contexts/proxy/mod.rs rename to tests/e2e/web/api/v1/contexts/proxy/mod.rs diff --git a/tests/e2e/web/api/v1/contexts/root/contract.rs b/tests/e2e/web/api/v1/contexts/root/contract.rs new file mode 100644 index 00000000..24d763df --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/root/contract.rs @@ -0,0 +1,20 @@ +//! API contract for `root` context. + +use torrust_index_backend::web::api; + +use crate::common::asserts::{assert_response_title, assert_text_ok}; +use crate::common::client::Client; +use crate::e2e::environment::TestEnv; + +#[tokio::test] +async fn it_should_load_the_about_page_at_the_api_entrypoint() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.root().await; + + assert_text_ok(&response); + assert_response_title(&response, "About"); +} diff --git a/tests/e2e/contexts/root/mod.rs b/tests/e2e/web/api/v1/contexts/root/mod.rs similarity index 100% rename from tests/e2e/contexts/root/mod.rs rename to tests/e2e/web/api/v1/contexts/root/mod.rs diff --git a/tests/e2e/web/api/v1/contexts/settings/contract.rs b/tests/e2e/web/api/v1/contexts/settings/contract.rs new file mode 100644 index 00000000..5bd1c420 --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/settings/contract.rs @@ -0,0 +1,94 @@ +//! API contract for `settings` context. + +use torrust_index_backend::web::api; + +use crate::common::asserts::assert_json_ok_response; +use crate::common::client::Client; +use crate::common::contexts::settings::responses::{AllSettingsResponse, Public, PublicSettingsResponse, SiteNameResponse}; +use crate::e2e::environment::TestEnv; +use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_admin; + +#[tokio::test] +async fn it_should_allow_guests_to_get_the_public_settings() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_public_settings().await; + + let res: PublicSettingsResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a PublicSettingsResponse", response.body)); + + assert_eq!( + res.data, + Public { + website_name: env.server_settings().unwrap().website.name, + tracker_url: env.server_settings().unwrap().tracker.url, + tracker_mode: env.server_settings().unwrap().tracker.mode, + email_on_signup: env.server_settings().unwrap().auth.email_on_signup, + } + ); + + assert_json_ok_response(&response); +} + +#[tokio::test] +async fn it_should_allow_guests_to_get_the_site_name() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_site_name().await; + + let res: SiteNameResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, "Torrust"); + + assert_json_ok_response(&response); +} + +#[tokio::test] +async fn it_should_allow_admins_to_get_all_the_settings() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let response = client.get_settings().await; + + let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, env.server_settings().unwrap()); + + assert_json_ok_response(&response); +} + +#[tokio::test] +async fn it_should_allow_admins_to_update_all_the_settings() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.is_isolated() { + // This test can't be executed in a non-isolated environment because + // it will change the settings for all the other tests. + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let mut new_settings = env.server_settings().unwrap(); + + new_settings.website.name = "UPDATED NAME".to_string(); + + let response = client.update_settings(&new_settings).await; + + let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, new_settings); + + assert_json_ok_response(&response); +} diff --git a/tests/e2e/contexts/settings/mod.rs b/tests/e2e/web/api/v1/contexts/settings/mod.rs similarity index 100% rename from tests/e2e/contexts/settings/mod.rs rename to tests/e2e/web/api/v1/contexts/settings/mod.rs diff --git a/tests/e2e/web/api/v1/contexts/tag/contract.rs b/tests/e2e/web/api/v1/contexts/tag/contract.rs new file mode 100644 index 00000000..bfb7b1b8 --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/tag/contract.rs @@ -0,0 +1,177 @@ +//! API contract for `tag` context. + +use torrust_index_backend::web::api; + +use crate::common::asserts::assert_json_ok_response; +use crate::common::client::Client; +use crate::common::contexts::tag::asserts::{assert_added_tag_response, assert_deleted_tag_response}; +use crate::common::contexts::tag::fixtures::random_tag_name; +use crate::common::contexts::tag::forms::{AddTagForm, DeleteTagForm}; +use crate::common::contexts::tag::responses::ListResponse; +use crate::e2e::environment::TestEnv; +use crate::e2e::web::api::v1::contexts::tag::steps::{add_random_tag, add_tag}; +use crate::e2e::web::api::v1::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; + +#[tokio::test] +async fn it_should_return_an_empty_tag_list_when_there_are_no_tags() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client.get_tags().await; + + assert_json_ok_response(&response); +} + +#[tokio::test] +async fn it_should_return_a_tag_list() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // Add a tag + let tag_name = random_tag_name(); + let response = add_tag(&tag_name, &env).await; + assert_eq!(response.status, 200); + + let response = client.get_tags().await; + + let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + + // There should be at least the tag we added. + // Since this is an E2E test that could be executed in a shred env, + // there might be more tags. + assert!(!res.data.is_empty()); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_not_allow_adding_a_new_tag_to_unauthenticated_users() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let response = client + .add_tag(AddTagForm { + name: "TAG NAME".to_string(), + }) + .await; + + assert_eq!(response.status, 401); +} + +#[tokio::test] +async fn it_should_not_allow_adding_a_new_tag_to_non_admins() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let response = client + .add_tag(AddTagForm { + name: "TAG NAME".to_string(), + }) + .await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +async fn it_should_allow_admins_to_add_new_tags() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let tag_name = random_tag_name(); + + let response = client + .add_tag(AddTagForm { + name: tag_name.to_string(), + }) + .await; + + assert_added_tag_response(&response, &tag_name); +} + +#[tokio::test] +async fn it_should_allow_adding_duplicated_tags() { + // code-review: is this an intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + // Add a tag + let random_tag_name = random_tag_name(); + let response = add_tag(&random_tag_name, &env).await; + assert_eq!(response.status, 200); + + // Try to add the same tag again + let response = add_tag(&random_tag_name, &env).await; + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_allow_adding_a_tag_with_an_empty_name() { + // code-review: is this an intended behavior? + + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let empty_tag_name = String::new(); + let response = add_tag(&empty_tag_name, &env).await; + assert_eq!(response.status, 200); +} + +#[tokio::test] +async fn it_should_allow_admins_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_deleted_tag_response(&response, tag_id); +} + +#[tokio::test] +async fn it_should_not_allow_non_admins_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_non_admin = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_non_admin.token); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_eq!(response.status, 403); +} + +#[tokio::test] +async fn it_should_not_allow_guests_to_delete_tags() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let (tag_id, _tag_name) = add_random_tag(&env).await; + + let response = client.delete_tag(DeleteTagForm { tag_id }).await; + + assert_eq!(response.status, 401); +} diff --git a/tests/e2e/contexts/tag/mod.rs b/tests/e2e/web/api/v1/contexts/tag/mod.rs similarity index 100% rename from tests/e2e/contexts/tag/mod.rs rename to tests/e2e/web/api/v1/contexts/tag/mod.rs diff --git a/tests/e2e/contexts/tag/steps.rs b/tests/e2e/web/api/v1/contexts/tag/steps.rs similarity index 94% rename from tests/e2e/contexts/tag/steps.rs rename to tests/e2e/web/api/v1/contexts/tag/steps.rs index 32bb767c..0e59d0ec 100644 --- a/tests/e2e/contexts/tag/steps.rs +++ b/tests/e2e/web/api/v1/contexts/tag/steps.rs @@ -3,8 +3,8 @@ use crate::common::contexts::tag::fixtures::random_tag_name; use crate::common::contexts::tag::forms::AddTagForm; use crate::common::contexts::tag::responses::ListResponse; use crate::common::responses::TextResponse; -use crate::e2e::contexts::user::steps::new_logged_in_admin; use crate::e2e::environment::TestEnv; +use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_admin; pub async fn add_random_tag(env: &TestEnv) -> (i64, String) { let tag_name = random_tag_name(); diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/web/api/v1/contexts/torrent/asserts.rs similarity index 100% rename from tests/e2e/contexts/torrent/asserts.rs rename to tests/e2e/web/api/v1/contexts/torrent/asserts.rs diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs new file mode 100644 index 00000000..69ad61a5 --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -0,0 +1,627 @@ +//! API contract for `torrent` context. + +/* +todo: + +Delete torrent: + +- After deleting a torrent, it should be removed from the tracker whitelist + +Get torrent info: + +- The torrent info: + - should contain the magnet link with the trackers from the torrent file + - should contain realtime seeders and leechers from the tracker +*/ + +mod for_guests { + + use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::category::fixtures::software_predefined_category_id; + use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::common::contexts::torrent::requests::InfoHash; + use crate::common::contexts::torrent::responses::{ + Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, + }; + use crate::common::http::{Query, QueryParam}; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::torrent::asserts::expected_torrent; + use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; + + #[tokio::test] + async fn it_should_allow_guests_to_get_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.get_torrents(Query::empty()).await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + assert!(torrent_list_response.data.total > 0); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_to_get_torrents_with_pagination() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + // Given we insert two torrents + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request only one torrent per page + let response = client + .get_torrents(Query::with_params([QueryParam::new("page_size", "1")].to_vec())) + .await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should have only one torrent per page + assert_eq!(torrent_list_response.data.results.len(), 1); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_to_limit_the_number_of_torrents_per_request() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + let max_torrent_page_size = 30; + + // Given we insert one torrent more than the page size limit + for _ in 0..max_torrent_page_size { + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request more torrents than the page size limit + let response = client + .get_torrents(Query::with_params( + [QueryParam::new("page_size", &format!("{}", (max_torrent_page_size + 1)))].to_vec(), + )) + .await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should get only the page size limit + assert_eq!(torrent_list_response.data.results.len(), max_torrent_page_size); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_return_a_default_amount_of_torrents_per_request_if_no_page_size_is_provided() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + + let default_torrent_page_size = 10; + + // Given we insert one torrent more than the default page size + for _ in 0..default_torrent_page_size { + let (_test_torrent, _indexed_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + // When we request more torrents than the default page size limit + let response = client.get_torrents(Query::empty()).await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + // Then we should get only the default number of torrents per page + assert_eq!(torrent_list_response.data.results.len(), default_torrent_page_size); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_guests_to_get_torrent_details_searching_by_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.get_torrent(&test_torrent.info_hash()).await; + + let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); + + let tracker_url = env.server_settings().unwrap().tracker.url; + let encoded_tracker_url = urlencoding::encode(&tracker_url); + + let expected_torrent = TorrentDetails { + torrent_id: uploaded_torrent.torrent_id, + uploader: uploader.username, + info_hash: test_torrent.file_info.info_hash.to_uppercase(), + title: test_torrent.index_info.title.clone(), + description: test_torrent.index_info.description, + category: Category { + category_id: software_predefined_category_id(), + name: test_torrent.index_info.category, + num_torrents: 19, // Ignored in assertion + }, + upload_date: "2023-04-27 07:56:08".to_string(), // Ignored in assertion + file_size: test_torrent.file_info.content_size, + seeders: 0, + leechers: 0, + files: vec![File { + path: vec![test_torrent.file_info.files[0].clone()], + // Using one file torrent for testing: content_size = first file size + length: test_torrent.file_info.content_size, + md5sum: None, + }], + // code-review: why is this duplicated? It seems that is adding the + // same tracker twice because first ti adds all trackers and then + // it adds the tracker with the personal announce url, if the user + // is logged in. If the user is not logged in, it adds the default + // tracker again, and it ends up with two trackers. + trackers: vec![tracker_url.clone(), tracker_url.clone()], + magnet_link: format!( + // cspell:disable-next-line + "magnet:?xt=urn:btih:{}&dn={}&tr={}&tr={}", + test_torrent.file_info.info_hash.to_uppercase(), + urlencoding::encode(&test_torrent.index_info.title), + encoded_tracker_url, + encoded_tracker_url + ), + }; + + assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.download_torrent(&test_torrent.info_hash()).await; + + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + let uploaded_torrent = + decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); + let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; + assert_eq!(torrent, expected_torrent); + assert!(response.is_bittorrent_and_ok()); + } + + #[tokio::test] + async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let non_existing_info_hash: InfoHash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); + + let response = client.download_torrent(&non_existing_info_hash).await; + + // code-review: should this be 404? + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn it_should_not_allow_guests_to_delete_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let response = client.delete_torrent(&test_torrent.info_hash()).await; + + assert_eq!(response.status, 401); + } +} + +mod for_authenticated_users { + + use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::web::api; + + use crate::common::asserts::assert_json_error_response; + use crate::common::client::Client; + use crate::common::contexts::torrent::fixtures::random_torrent; + use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; + use crate::common::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; + use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; + + #[tokio::test] + async fn it_should_allow_authenticated_users_to_upload_new_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let test_torrent = random_torrent(); + let info_hash = test_torrent.info_hash().clone(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!( + uploaded_torrent_response.data.info_hash.to_lowercase(), + info_hash.to_lowercase() + ); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + test_torrent.index_info.category = "non-existing-category".to_string(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same title as the first one + let mut second_torrent = random_torrent(); + second_torrent.index_info.title = first_torrent_title; + let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_json_error_response(&response, "This torrent title has already been used."); + } + + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let mut first_torrent_clone = first_torrent.clone(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same info-hash as the first one. + // We need to change the title otherwise the torrent will be rejected + // because of the duplicate title. + first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); + let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + // Given a previously uploaded torrent + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + + // And a logged in user who is going to download the torrent + let downloader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); + + // When the user downloads the torrent + let response = client.download_torrent(&test_torrent.info_hash()).await; + + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + + // Then the torrent should have the personal announce URL + let tracker_key = get_user_tracker_key(&downloader, &env) + .await + .expect("uploader should have a valid tracker key"); + + let tracker_url = env.server_settings().unwrap().tracker.url; + + assert_eq!( + torrent.announce.unwrap(), + build_announce_url(&tracker_url, &Some(tracker_key)) + ); + } + + mod and_non_admins { + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; + + #[tokio::test] + async fn it_should_not_allow_non_admins_to_delete_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let response = client.delete_torrent(&test_torrent.info_hash()).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + // Given a users uploads a torrent + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + // Then another non admin user should not be able to update the torrent + let not_the_uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), ¬_the_uploader.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + &test_torrent.info_hash(), + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + assert_eq!(response.status, 403); + } + } + + mod and_torrent_owners { + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::common::contexts::torrent::responses::UpdatedTorrentResponse; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; + + #[tokio::test] + async fn it_should_allow_torrent_owners_to_update_their_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + &test_torrent.info_hash(), + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + } + + mod and_admins { + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::forms::UpdateTorrentFrom; + use crate::common::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::user::steps::{new_logged_in_admin, new_logged_in_user}; + + #[tokio::test] + async fn it_should_allow_admins_to_delete_torrents_searching_by_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); + + let response = client.delete_torrent(&test_torrent.info_hash()).await; + + let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(deleted_torrent_response.data.torrent_id, uploaded_torrent.torrent_id); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_allow_admins_to_update_someone_elses_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + &test_torrent.info_hash(), + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + } +} diff --git a/tests/e2e/contexts/torrent/mod.rs b/tests/e2e/web/api/v1/contexts/torrent/mod.rs similarity index 100% rename from tests/e2e/contexts/torrent/mod.rs rename to tests/e2e/web/api/v1/contexts/torrent/mod.rs diff --git a/tests/e2e/contexts/torrent/steps.rs b/tests/e2e/web/api/v1/contexts/torrent/steps.rs similarity index 100% rename from tests/e2e/contexts/torrent/steps.rs rename to tests/e2e/web/api/v1/contexts/torrent/steps.rs diff --git a/tests/e2e/web/api/v1/contexts/user/contract.rs b/tests/e2e/web/api/v1/contexts/user/contract.rs new file mode 100644 index 00000000..12c4e146 --- /dev/null +++ b/tests/e2e/web/api/v1/contexts/user/contract.rs @@ -0,0 +1,176 @@ +//! API contract for `user` context. + +/* + +This test suite is not complete. It's just a starting point to show how to +write E2E tests. Anyway, the goal is not to fully cover all the app features +with E2E tests. The goal is to cover the most important features and to +demonstrate how to write E2E tests. Some important pending tests could be: + +todo: + +- It should allow renewing a token one week before it expires. +- It should allow verifying user registration via email. + +The first one requires to mock the time. Consider extracting the mod + into +an independent crate. + +The second one requires: +- To call the mailcatcher API to get the verification URL. +- To enable email verification in the configuration. +- To fix current tests to verify the email for newly created users. +- To find out which email is the one that contains the verification URL for a +given test. That maybe done using the email recipient if that's possible with +the mailcatcher API. + +*/ + +mod registration { + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::user::asserts::assert_added_user_response; + use crate::common::contexts::user::fixtures::random_user_registration_form; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_a_guest_user_to_register() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let form = random_user_registration_form(); + + let response = client.register_user(form).await; + + assert_added_user_response(&response); + } +} + +mod authentication { + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::user::asserts::{ + assert_successful_login_response, assert_token_renewal_response, assert_token_verified_response, + }; + use crate::common::contexts::user::forms::{LoginForm, TokenRenewalForm, TokenVerificationForm}; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::user::steps::{new_logged_in_user, new_registered_user}; + + #[tokio::test] + async fn it_should_allow_a_registered_user_to_login() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let registered_user = new_registered_user(&env).await; + + let response = client + .login_user(LoginForm { + login: registered_user.username.clone(), + password: registered_user.password.clone(), + }) + .await; + + assert_successful_login_response(&response, ®istered_user); + } + + #[tokio::test] + async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let logged_in_user = new_logged_in_user(&env).await; + + let response = client + .verify_token(TokenVerificationForm { + token: logged_in_user.token.clone(), + }) + .await; + + assert_token_verified_response(&response); + } + + #[tokio::test] + async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_user = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_user.token); + + let response = client + .renew_token(TokenRenewalForm { + token: logged_in_user.token.clone(), + }) + .await; + + assert_token_renewal_response(&response, &logged_in_user); + } +} + +mod banned_user_list { + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::user::asserts::assert_banned_user_response; + use crate::common::contexts::user::forms::Username; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; + + #[tokio::test] + async fn it_should_allow_an_admin_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_in_admin = new_logged_in_admin(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_banned_user_response(&response, ®istered_user); + } + + #[tokio::test] + async fn it_should_not_allow_a_non_admin_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_not_allow_a_guest_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 401); + } +} diff --git a/tests/e2e/contexts/user/mod.rs b/tests/e2e/web/api/v1/contexts/user/mod.rs similarity index 100% rename from tests/e2e/contexts/user/mod.rs rename to tests/e2e/web/api/v1/contexts/user/mod.rs diff --git a/tests/e2e/contexts/user/steps.rs b/tests/e2e/web/api/v1/contexts/user/steps.rs similarity index 100% rename from tests/e2e/contexts/user/steps.rs rename to tests/e2e/web/api/v1/contexts/user/steps.rs diff --git a/tests/e2e/web/api/v1/mod.rs b/tests/e2e/web/api/v1/mod.rs new file mode 100644 index 00000000..0f9779b8 --- /dev/null +++ b/tests/e2e/web/api/v1/mod.rs @@ -0,0 +1 @@ +pub mod contexts; diff --git a/tests/e2e/web/mod.rs b/tests/e2e/web/mod.rs new file mode 100644 index 00000000..e5fdf85e --- /dev/null +++ b/tests/e2e/web/mod.rs @@ -0,0 +1 @@ +pub mod api; diff --git a/tests/environments/app_starter.rs b/tests/environments/app_starter.rs index 47aef6e2..9999f4d3 100644 --- a/tests/environments/app_starter.rs +++ b/tests/environments/app_starter.rs @@ -4,7 +4,7 @@ use log::info; use tokio::sync::{oneshot, RwLock}; use tokio::task::JoinHandle; use torrust_index_backend::config::Configuration; -use torrust_index_backend::web::api::Implementation; +use torrust_index_backend::web::api::Version; use torrust_index_backend::{app, config}; /// It launches the app and provides a way to stop it. @@ -32,7 +32,7 @@ impl AppStarter { /// # Panics /// /// Will panic if the app was dropped after spawning it. - pub async fn start(&mut self, api_implementation: Implementation) { + pub async fn start(&mut self, api_version: Version) { let configuration = Configuration { settings: RwLock::new(self.configuration.clone()), config_path: self.config_path.clone(), @@ -43,7 +43,7 @@ impl AppStarter { // Launch the app in a separate task let app_handle = tokio::spawn(async move { - let app = app::run(configuration, &api_implementation).await; + let app = app::run(configuration, &api_version).await; info!("Application started. API server listening on {}", app.api_socket_addr); @@ -53,8 +53,8 @@ impl AppStarter { }) .expect("the app starter should not be dropped"); - match api_implementation { - Implementation::Axum => app.axum_api_server.unwrap().await, + match api_version { + Version::V1 => app.api_server.unwrap().await, } }); diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index a4b1ae45..96a7179b 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -1,7 +1,7 @@ use tempfile::TempDir; use torrust_index_backend::config; use torrust_index_backend::config::FREE_PORT; -use torrust_index_backend::web::api::Implementation; +use torrust_index_backend::web::api::Version; use super::app_starter::AppStarter; use crate::common::random; @@ -16,9 +16,9 @@ pub struct TestEnv { impl TestEnv { /// Provides a running app instance for integration tests. - pub async fn running(api_implementation: Implementation) -> Self { + pub async fn running(api_version: Version) -> Self { let mut env = Self::default(); - env.start(api_implementation).await; + env.start(api_version).await; env } @@ -40,8 +40,8 @@ impl TestEnv { } /// Starts the app. - pub async fn start(&mut self, api_implementation: Implementation) { - self.app_starter.start(api_implementation).await; + pub async fn start(&mut self, api_version: Version) { + self.app_starter.start(api_version).await; } /// Provides the whole server configuration. From 6b94f770f54a4001deb9530bb460066ff011211d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jun 2023 15:25:36 +0100 Subject: [PATCH 241/357] release: v2.0.0-alpha.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17616181..89f68468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3124,7 +3124,7 @@ dependencies = [ [[package]] name = "torrust-index-backend" -version = "2.0.0-alpha.1" +version = "2.0.0-alpha.2" dependencies = [ "actix-cors", "actix-multipart", diff --git a/Cargo.toml b/Cargo.toml index a8099b25..e3ec107e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "torrust-index-backend" description = "The backend (API) for the Torrust Index project." license-file = "COPYRIGHT" -version = "2.0.0-alpha.1" +version = "2.0.0-alpha.2" authors = ["Mick van Dijke ", "Wesley Bijleveld "] repository = "https://github.com/torrust/torrust-index-backend" edition = "2021" From 910a41923c215d20a436fc3572988e4db330f4d2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jun 2023 16:56:44 +0100 Subject: [PATCH 242/357] chore: format toml files --- .cargo/config.toml | 3 --- Cargo.toml | 40 ++++++++++++++++++++++++++++++++-------- rustfmt.toml | 1 - 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 9e078085..bb7b3a4b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,6 +3,3 @@ cov = "llvm-cov" cov-html = "llvm-cov --html" cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" time = "build --timings --all-targets" - - - diff --git a/Cargo.toml b/Cargo.toml index e3ec107e..d30c00e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,10 @@ name = "torrust-index-backend" description = "The backend (API) for the Torrust Index project." license-file = "COPYRIGHT" version = "2.0.0-alpha.2" -authors = ["Mick van Dijke ", "Wesley Bijleveld "] +authors = [ + "Mick van Dijke ", + "Wesley Bijleveld ", +] repository = "https://github.com/torrust/torrust-index-backend" edition = "2021" default-run = "main" @@ -17,7 +20,13 @@ actix-multipart = "0.6" actix-cors = "0.6" async-trait = "0.1" futures = "0.3" -sqlx = { version = "0.6", features = [ "runtime-tokio-native-tls", "sqlite", "mysql", "migrate", "time" ] } +sqlx = { version = "0.6", features = [ + "runtime-tokio-native-tls", + "sqlite", + "mysql", + "migrate", + "time", +] } config = "0.13" toml = "0.7" derive_more = "0.99" @@ -32,9 +41,24 @@ rand_core = { version = "0.6", features = ["std"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } jsonwebtoken = "8.3" sha-1 = "0.10" -reqwest = { version = "0.11", features = [ "json", "multipart" ] } -tokio = {version = "1.28", features = ["macros", "io-util", "net", "time", "rt-multi-thread", "fs", "sync", "signal"]} -lettre = { version = "0.10", features = ["builder", "tokio1", "tokio1-rustls-tls", "tokio1-native-tls", "smtp-transport"]} +reqwest = { version = "0.11", features = ["json", "multipart"] } +tokio = { version = "1.28", features = [ + "macros", + "io-util", + "net", + "time", + "rt-multi-thread", + "fs", + "sync", + "signal", +] } +lettre = { version = "0.10", features = [ + "builder", + "tokio1", + "tokio1-rustls-tls", + "tokio1-native-tls", + "smtp-transport", +] } sailfish = "0.6" regex = "1.8" pbkdf2 = { version = "0.12", features = ["simple"] } @@ -46,12 +70,12 @@ text-to-png = "0.2" indexmap = "1.9" thiserror = "1.0" binascii = "0.1" -axum = { version = "0.6.18", features = ["multipart"]} +axum = { version = "0.6.18", features = ["multipart"] } hyper = "0.14.26" -tower-http = { version = "0.4.0", features = ["cors"]} +tower-http = { version = "0.4.0", features = ["cors"] } [dev-dependencies] rand = "0.8" tempfile = "3.5" -uuid = { version = "1.3", features = [ "v4"] } +uuid = { version = "1.3", features = ["v4"] } which = "4.4" diff --git a/rustfmt.toml b/rustfmt.toml index 3e878b27..abbed5ed 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,3 @@ max_width = 130 imports_granularity = "Module" group_imports = "StdExternalCrate" - From f998d9d95acfd4153026331536c4b48532e3a93e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jun 2023 15:28:24 +0100 Subject: [PATCH 243/357] feat: add env var to set permissive CORS policy Running the backend with: ``` TORRUST_IDX_BACK_CORS_PERMISSIVE=true cargo run ``` will make the CORS policy to be permissive, which means you can use a different port for the API and serving the frontend app. It's intented for development purposes. --- src/bootstrap/config.rs | 3 +++ src/web/api/v1/routes.rs | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index f8acdc73..d07c3202 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -12,6 +12,9 @@ pub const ENV_VAR_CONFIG: &str = "TORRUST_IDX_BACK_CONFIG"; /// The `config.toml` file location. pub const ENV_VAR_CONFIG_PATH: &str = "TORRUST_IDX_BACK_CONFIG_PATH"; +/// If present, CORS will be permissive. +pub const ENV_VAR_CORS_PERMISSIVE: &str = "TORRUST_IDX_BACK_CORS_PERMISSIVE"; + // Default values pub const ENV_VAR_DEFAULT_CONFIG_PATH: &str = "./config.toml"; diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 586ec2d7..996d3a6c 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -1,13 +1,14 @@ //! Route initialization for the v1 API. +use std::env; use std::sync::Arc; use axum::routing::get; use axum::Router; +use tower_http::cors::CorsLayer; use super::contexts::about::handlers::about_page_handler; -//use tower_http::cors::CorsLayer; -use super::contexts::{about, proxy, settings, tag, torrent}; -use super::contexts::{category, user}; +use super::contexts::{about, category, proxy, settings, tag, torrent, user}; +use crate::bootstrap::config::ENV_VAR_CORS_PERMISSIVE; use crate::common::AppData; pub const API_VERSION_URL_PREFIX: &str = "v1"; @@ -30,14 +31,13 @@ pub fn router(app_data: Arc) -> Router { .nest("/torrents", torrent::routes::router_for_multiple_resources(app_data.clone())) .nest("/proxy", proxy::routes::router(app_data.clone())); - Router::new() + let router = Router::new() .route("/", get(about_page_handler).with_state(app_data)) - .nest(&format!("/{API_VERSION_URL_PREFIX}"), v1_api_routes) - // For development purposes only. - // - //.layer(CorsLayer::permissive()) // Uncomment this line and the `use` import. - // - // It allows calling the API on a different port. For example - // API: http://localhost:3000/v1 - // Webapp: http://localhost:8080 + .nest(&format!("/{API_VERSION_URL_PREFIX}"), v1_api_routes); + + if env::var(ENV_VAR_CORS_PERMISSIVE).is_ok() { + router.layer(CorsLayer::permissive()) + } else { + router + } } From b4ea3d5201db02bb467741d38a752f74664b21a8 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jun 2023 16:20:16 +0100 Subject: [PATCH 244/357] fix: [#216] allow updating torrent category after upload --- src/databases/database.rs | 4 ++++ src/databases/mysql.rs | 17 ++++++++++++++++ src/databases/sqlite.rs | 17 ++++++++++++++++ src/services/torrent.rs | 9 ++++++++- src/tracker/service.rs | 20 ++++++++++++++++--- src/web/api/v1/contexts/torrent/forms.rs | 2 ++ src/web/api/v1/contexts/torrent/handlers.rs | 1 + src/web/api/v1/contexts/torrent/mod.rs | 2 ++ tests/common/contexts/torrent/forms.rs | 2 ++ .../web/api/v1/contexts/torrent/contract.rs | 8 +++++++- 10 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index ea0c41a0..96d7b16f 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::databases::mysql::Mysql; use crate::databases::sqlite::Sqlite; +use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; @@ -233,6 +234,9 @@ pub trait Database: Sync + Send { /// Update a torrent's description with `torrent_id` and `description`. async fn update_torrent_description(&self, torrent_id: i64, description: &str) -> Result<(), Error>; + /// Update a torrent's category with `torrent_id` and `category_id`. + async fn update_torrent_category(&self, torrent_id: i64, category_id: CategoryId) -> Result<(), Error>; + /// Add a new tag. async fn add_tag(&self, name: &str) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 5ae75050..60991edd 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -8,6 +8,7 @@ use sqlx::{query, query_as, Acquire, ConnectOptions, MySqlPool}; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; +use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; @@ -704,6 +705,22 @@ impl Database for Mysql { }) } + async fn update_torrent_category(&self, torrent_id: i64, category_id: CategoryId) -> Result<(), database::Error> { + query("UPDATE torrust_torrents SET category_id = ? WHERE torrent_id = ?") + .bind(category_id) + .bind(torrent_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::TorrentNotFound) + } + }) + } + async fn add_tag(&self, name: &str) -> Result<(), database::Error> { query("INSERT INTO torrust_torrent_tags (name) VALUES (?)") .bind(name) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 1837e49e..66c509b0 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -8,6 +8,7 @@ use sqlx::{query, query_as, Acquire, ConnectOptions, SqlitePool}; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; +use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; @@ -694,6 +695,22 @@ impl Database for Sqlite { }) } + async fn update_torrent_category(&self, torrent_id: i64, category_id: CategoryId) -> Result<(), database::Error> { + query("UPDATE torrust_torrents SET category_id = $1 WHERE torrent_id = $2") + .bind(category_id) + .bind(torrent_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::TorrentNotFound) + } + }) + } + async fn add_tag(&self, name: &str) -> Result<(), database::Error> { query("INSERT INTO torrust_torrent_tags (name) VALUES (?)") .bind(name) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index e04e0889..7a7262ba 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -8,6 +8,7 @@ use super::user::DbUserRepository; use crate::config::Configuration; use crate::databases::database::{Category, Database, Error, Sorting}; use crate::errors::ServiceError; +use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::{DeletedTorrentResponse, TorrentResponse, TorrentsResponse}; use crate::models::torrent::{AddTorrentRequest, TorrentId, TorrentListing}; @@ -358,6 +359,7 @@ impl Index { info_hash: &InfoHash, title: &Option, description: &Option, + category_id: &Option, tags: &Option>, user_id: &UserId, ) -> Result { @@ -372,7 +374,7 @@ impl Index { } self.torrent_info_repository - .update(&torrent_listing.torrent_id, title, description, tags) + .update(&torrent_listing.torrent_id, title, description, category_id, tags) .await?; let torrent_listing = self @@ -473,6 +475,7 @@ impl DbTorrentInfoRepository { torrent_id: &TorrentId, opt_title: &Option, opt_description: &Option, + opt_category_id: &Option, opt_tags: &Option>, ) -> Result<(), Error> { if let Some(title) = &opt_title { @@ -483,6 +486,10 @@ impl DbTorrentInfoRepository { self.database.update_torrent_description(*torrent_id, description).await?; } + if let Some(category_id) = &opt_category_id { + self.database.update_torrent_category(*torrent_id, *category_id).await?; + } + if let Some(tags) = opt_tags { let mut current_tags: Vec = self .database diff --git a/src/tracker/service.rs b/src/tracker/service.rs index e8b17847..c49c7ac1 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use hyper::StatusCode; use log::error; use serde::{Deserialize, Serialize}; @@ -139,10 +140,23 @@ impl Service { .await .map_err(|_| ServiceError::InternalServerError)?; - if let Ok(torrent_info) = response.json::().await { - Ok(torrent_info) + if response.status() == StatusCode::NOT_FOUND { + return Err(ServiceError::TorrentNotFound); + } + + let body = response.text().await; + + if let Ok(body) = body { + let torrent_info = serde_json::from_str(&body); + + if let Ok(torrent_info) = torrent_info { + Ok(torrent_info) + } else { + error!("Failed to parse torrent info from tracker response"); + Err(ServiceError::InternalServerError) + } } else { - error!("Failed to parse torrent info from tracker response"); + error!("Tracker API response without body"); Err(ServiceError::InternalServerError) } } diff --git a/src/web/api/v1/contexts/torrent/forms.rs b/src/web/api/v1/contexts/torrent/forms.rs index df840c70..fbe9f12c 100644 --- a/src/web/api/v1/contexts/torrent/forms.rs +++ b/src/web/api/v1/contexts/torrent/forms.rs @@ -1,10 +1,12 @@ use serde::Deserialize; +use crate::models::category::CategoryId; use crate::models::torrent_tag::TagId; #[derive(Debug, Deserialize)] pub struct UpdateTorrentInfoForm { pub title: Option, pub description: Option, + pub category: Option, pub tags: Option>, } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index a3381d71..b3e197ed 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -156,6 +156,7 @@ pub async fn update_torrent_info_handler( &info_hash, &update_torrent_info_form.title, &update_torrent_info_form.description, + &update_torrent_info_form.category, &update_torrent_info_form.tags, &user_id, ) diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs index 81c1651d..6041e468 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -241,6 +241,8 @@ //! ---|---|---|---|--- //! `title` | `Option` | The torrent title | No | `MandelbrotSet` //! `description` | `Option` | The torrent description | No | `MandelbrotSet image` +//! `category` | `Option` | The torrent category ID | No | `1` +//! `tags` | `Option>` | The tag Id list | No | `[1,2,3]` //! //! //! Refer to the [`UpdateTorrentInfoForm`](crate::web::api::v1::contexts::torrent::forms::UpdateTorrentInfoForm) diff --git a/tests/common/contexts/torrent/forms.rs b/tests/common/contexts/torrent/forms.rs index 0c4499f1..c6212611 100644 --- a/tests/common/contexts/torrent/forms.rs +++ b/tests/common/contexts/torrent/forms.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; pub struct UpdateTorrentFrom { pub title: Option, pub description: Option, + pub category: Option, + pub tags: Option>, } use reqwest::multipart::Form; diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 69ad61a5..41ba09a4 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -468,7 +468,7 @@ mod for_authenticated_users { } #[tokio::test] - async fn it_should_allow_non_admin_users_to_update_someone_elses_torrents() { + async fn it_should_not_allow_non_admin_users_to_update_someone_elses_torrents() { let mut env = TestEnv::new(); env.start(api::Version::V1).await; @@ -494,6 +494,8 @@ mod for_authenticated_users { UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), + category: None, + tags: None, }, ) .await; @@ -537,6 +539,8 @@ mod for_authenticated_users { UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), + category: None, + tags: None, }, ) .await; @@ -611,6 +615,8 @@ mod for_authenticated_users { UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), + category: None, + tags: None, }, ) .await; From 7a06fe8c6dc6eb840c6d526a1e9ffce90e17a287 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jun 2023 16:50:50 +0100 Subject: [PATCH 245/357] test: [#194] remove database tests Because: - They are hard to maintain because of the table's dependencies. - They are duplicated in E2E tests. And it's easier to set the state you want to test with E2E tests. TODO: runt the E2E test using MySQL to ensure queries are OK. Currently, E2E tests are only executed with SQLite. --- tests/databases/README.md | 47 --------- tests/databases/docker-compose.yml | 12 --- tests/databases/mod.rs | 36 ------- tests/databases/mysql.rs | 15 --- tests/databases/sqlite.rs | 8 -- tests/databases/tests.rs | 159 ----------------------------- tests/mod.rs | 1 - 7 files changed, 278 deletions(-) delete mode 100644 tests/databases/README.md delete mode 100644 tests/databases/docker-compose.yml delete mode 100644 tests/databases/mod.rs delete mode 100644 tests/databases/mysql.rs delete mode 100644 tests/databases/sqlite.rs delete mode 100644 tests/databases/tests.rs diff --git a/tests/databases/README.md b/tests/databases/README.md deleted file mode 100644 index 54a1b842..00000000 --- a/tests/databases/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Persistence Tests - -Torrust requires Docker to run different database systems for testing. - -Start the databases with `docker-compose` before running tests: - -```s -docker-compose -f tests/databases/docker-compose.yml up -``` - -Run all tests using: - -```s -cargo test -``` - -Connect to the DB using MySQL client: - -```s -mysql -h127.0.0.1 -uroot -ppassword torrust-index_test -``` - -Right now only tests for MySQLite are executed. To run tests for MySQL too, -you have to replace this line in `tests/databases/mysql.rs`: - -```rust - -```rust -#[tokio::test] -#[should_panic] -async fn run_mysql_tests() { - panic!("Todo Test Times Out!"); - #[allow(unreachable_code)] - { - run_tests(DATABASE_URL).await; - } -} -``` - -with this: - -```rust -#[tokio::test] -async fn run_mysql_tests() { - run_tests(DATABASE_URL).await; -} -``` diff --git a/tests/databases/docker-compose.yml b/tests/databases/docker-compose.yml deleted file mode 100644 index 4a5501bd..00000000 --- a/tests/databases/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: "3.9" - -services: - - mysql_8: - image: mysql:8.0.30 - ports: - - "3306:3306" - environment: - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: password - MYSQL_DATABASE: torrust-index_test diff --git a/tests/databases/mod.rs b/tests/databases/mod.rs deleted file mode 100644 index 22d83c5e..00000000 --- a/tests/databases/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::future::Future; - -use torrust_index_backend::databases::database; -use torrust_index_backend::databases::database::Database; - -mod mysql; -mod sqlite; -mod tests; - -// used to run tests with a clean database -async fn run_test<'a, T, F, DB: Database + ?Sized>(db_fn: T, db: &'a DB) -where - T: FnOnce(&'a DB) -> F + 'a, - F: Future, -{ - // cleanup database before testing - assert!(db.delete_all_database_rows().await.is_ok()); - - // run test using clean database - db_fn(db).await; -} - -// runs all tests -pub async fn run_tests(db_path: &str) { - let db_res = database::connect(db_path).await; - - assert!(db_res.is_ok()); - - let db_boxed = db_res.unwrap(); - - let db: &dyn Database = db_boxed.as_ref(); - - run_test(tests::it_can_add_a_user, db).await; - run_test(tests::it_can_add_a_torrent_category, db).await; - run_test(tests::it_can_add_a_torrent_and_tracker_stats_to_that_torrent, db).await; -} diff --git a/tests/databases/mysql.rs b/tests/databases/mysql.rs deleted file mode 100644 index e2b58102..00000000 --- a/tests/databases/mysql.rs +++ /dev/null @@ -1,15 +0,0 @@ -#[allow(unused_imports)] -use crate::databases::run_tests; - -#[allow(dead_code)] -const DATABASE_URL: &str = "mysql://root:password@localhost:3306/torrust-index_test"; - -#[tokio::test] -#[should_panic] -async fn run_mysql_tests() { - panic!("Todo Test Times Out!"); - #[allow(unreachable_code)] - { - run_tests(DATABASE_URL).await; - } -} diff --git a/tests/databases/sqlite.rs b/tests/databases/sqlite.rs deleted file mode 100644 index 37d89a97..00000000 --- a/tests/databases/sqlite.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::databases::run_tests; - -const DATABASE_URL: &str = "sqlite::memory:"; - -#[tokio::test] -async fn run_sqlite_tests() { - run_tests(DATABASE_URL).await; -} diff --git a/tests/databases/tests.rs b/tests/databases/tests.rs deleted file mode 100644 index 98c24d60..00000000 --- a/tests/databases/tests.rs +++ /dev/null @@ -1,159 +0,0 @@ -use serde_bytes::ByteBuf; -use torrust_index_backend::databases::database; -use torrust_index_backend::databases::database::Database; -use torrust_index_backend::models::torrent::TorrentListing; -use torrust_index_backend::models::torrent_file::{Torrent, TorrentInfo}; -use torrust_index_backend::models::user::UserProfile; - -// test user options -const TEST_USER_USERNAME: &str = "luckythelab"; -const TEST_USER_EMAIL: &str = "lucky@labradormail.com"; -const TEST_USER_PASSWORD: &str = "imagoodboy"; - -// test category options -const TEST_CATEGORY_NAME: &str = "Labrador Retrievers"; - -// test torrent options -const TEST_TORRENT_TITLE: &str = "Picture of dog treat"; -const TEST_TORRENT_DESCRIPTION: &str = "This is a picture of a dog treat."; -const TEST_TORRENT_FILE_SIZE: i64 = 128_000; -const TEST_TORRENT_SEEDERS: i64 = 437; -const TEST_TORRENT_LEECHERS: i64 = 1289; - -async fn add_test_user(db: &T) -> Result { - db.insert_user_and_get_id(TEST_USER_USERNAME, TEST_USER_EMAIL, TEST_USER_PASSWORD) - .await -} - -async fn add_test_torrent_category(db: &T) -> Result { - db.insert_category_and_get_id(TEST_CATEGORY_NAME).await -} - -pub async fn it_can_add_a_user(db: &T) { - let add_test_user_result = add_test_user(db).await; - - assert!(add_test_user_result.is_ok()); - - let inserted_user_id = add_test_user_result.unwrap(); - - let get_user_profile_from_username_result = db.get_user_profile_from_username(TEST_USER_USERNAME).await; - - // verify that we can grab the newly inserted user's profile data - assert!(get_user_profile_from_username_result.is_ok()); - - let returned_user_profile = get_user_profile_from_username_result.unwrap(); - - // verify that the profile data is as we expect it to be - assert_eq!( - returned_user_profile, - UserProfile { - user_id: inserted_user_id, - username: TEST_USER_USERNAME.to_string(), - email: TEST_USER_EMAIL.to_string(), - email_verified: returned_user_profile.email_verified, - bio: returned_user_profile.bio.clone(), - avatar: returned_user_profile.avatar.clone() - } - ); -} - -pub async fn it_can_add_a_torrent_category(db: &T) { - let add_test_torrent_category_result = add_test_torrent_category(db).await; - - assert!(add_test_torrent_category_result.is_ok()); - - let get_category_from_name_result = db.get_category_from_name(TEST_CATEGORY_NAME).await; - - assert!(get_category_from_name_result.is_ok()); - - let category = get_category_from_name_result.unwrap(); - - assert_eq!(category.name, TEST_CATEGORY_NAME.to_string()); -} - -pub async fn it_can_add_a_torrent_and_tracker_stats_to_that_torrent(db: &T) { - // set pre-conditions - let user_id = add_test_user(db).await.expect("add_test_user failed."); - let torrent_category_id = add_test_torrent_category(db) - .await - .expect("add_test_torrent_category failed."); - - let torrent = Torrent { - info: TorrentInfo { - name: TEST_TORRENT_TITLE.to_string(), - pieces: Some(ByteBuf::from("1234567890123456789012345678901234567890".as_bytes())), - piece_length: 256_000, - md5sum: None, - length: Some(TEST_TORRENT_FILE_SIZE), - files: None, - private: Some(1), - path: None, - root_hash: None, - }, - announce: Some("https://tracker.dutchbits.nl/announce".to_string()), - nodes: None, - encoding: None, - httpseeds: None, - announce_list: None, - creation_date: None, - comment: None, - created_by: None, - }; - - let insert_torrent_and_get_id_result = db - .insert_torrent_and_get_id( - &torrent, - user_id, - torrent_category_id, - TEST_TORRENT_TITLE, - TEST_TORRENT_DESCRIPTION, - ) - .await; - - assert!(insert_torrent_and_get_id_result.is_ok()); - - let torrent_id = insert_torrent_and_get_id_result.unwrap(); - - // add tracker stats to the torrent - let insert_torrent_tracker_stats_result = db - .update_tracker_info( - torrent_id, - "https://tracker.torrust.com", - TEST_TORRENT_SEEDERS, - TEST_TORRENT_LEECHERS, - ) - .await; - - assert!(insert_torrent_tracker_stats_result.is_ok()); - - let get_torrent_listing_from_id_result = db.get_torrent_listing_from_id(torrent_id).await; - - assert!(get_torrent_listing_from_id_result.is_ok()); - - let returned_torrent_listing = get_torrent_listing_from_id_result.unwrap(); - - assert_eq!( - returned_torrent_listing, - TorrentListing { - torrent_id, - uploader: TEST_USER_USERNAME.to_string(), - info_hash: returned_torrent_listing.info_hash.to_string(), - title: TEST_TORRENT_TITLE.to_string(), - description: Some(TEST_TORRENT_DESCRIPTION.to_string()), - category_id: torrent_category_id, - date_uploaded: returned_torrent_listing.date_uploaded.to_string(), - file_size: TEST_TORRENT_FILE_SIZE, - seeders: TEST_TORRENT_SEEDERS, - leechers: TEST_TORRENT_LEECHERS - } - ); - - // check if we get the same info hash on the retrieved torrent from database - let get_torrent_from_id_result = db.get_torrent_from_id(torrent_id).await; - - assert!(get_torrent_from_id_result.is_ok()); - - let returned_torrent = get_torrent_from_id_result.unwrap(); - - assert_eq!(returned_torrent.info_hash(), torrent.info_hash()); -} diff --git a/tests/mod.rs b/tests/mod.rs index 6180292f..4d330909 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,5 +1,4 @@ mod common; -mod databases; mod e2e; pub mod environments; mod upgrades; From 986d2f85a06c37f9cdacc8e56f186d7f8c990e41 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jun 2023 17:20:03 +0100 Subject: [PATCH 246/357] fix: [#194] add missing tables to truncate when reseting the DB When you upgrade from v1.0.0 to v2.0.0 you can run an upgrader which will truncate all the tables in the target database. There were some missing tables in the list. Probably, there wre tables added after creting the upgrader command. --- src/databases/database.rs | 21 +++++++ src/databases/mysql.rs | 56 +++---------------- src/databases/sqlite.rs | 56 +++---------------- .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 2 +- .../databases/sqlite_v2_0_0.rs | 36 +++--------- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 4 +- 6 files changed, 45 insertions(+), 130 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 96d7b16f..0ba2a5a1 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -13,6 +13,27 @@ use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; +/// Database tables to be truncated when upgrading from v1.0.0 to v2.0.0. +/// They must be in the correct order to avoid foreign key errors. +pub const TABLES_TO_TRUNCATE: &[&str] = &[ + "torrust_torrent_announce_urls", + "torrust_torrent_files", + "torrust_torrent_info", + "torrust_torrent_tag_links", + "torrust_torrent_tracker_stats", + "torrust_torrents", + "torrust_tracker_keys", + "torrust_user_authentication", + "torrust_user_bans", + "torrust_user_invitation_uses", + "torrust_user_invitations", + "torrust_user_profiles", + "torrust_user_public_keys", + "torrust_users", + "torrust_categories", + "torrust_torrent_tags", +]; + /// Database drivers. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub enum Driver { diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 60991edd..9b28e51f 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -6,6 +6,7 @@ use chrono::NaiveDateTime; use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions}; use sqlx::{query, query_as, Acquire, ConnectOptions, MySqlPool}; +use super::database::TABLES_TO_TRUNCATE; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; use crate::models::category::CategoryId; @@ -852,55 +853,12 @@ impl Database for Mysql { } async fn delete_all_database_rows(&self) -> Result<(), database::Error> { - query("DELETE FROM torrust_categories;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_torrents;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_tracker_keys;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_users;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_authentication;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_bans;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_invitations;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_profiles;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_torrents;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_public_keys;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; + for table in TABLES_TO_TRUNCATE { + query(&format!("DELETE FROM {table};")) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error)?; + } Ok(()) } diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 66c509b0..8d2123a3 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -6,6 +6,7 @@ use chrono::NaiveDateTime; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{query, query_as, Acquire, ConnectOptions, SqlitePool}; +use super::database::TABLES_TO_TRUNCATE; use crate::databases::database; use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact}; use crate::models::category::CategoryId; @@ -842,55 +843,12 @@ impl Database for Sqlite { } async fn delete_all_database_rows(&self) -> Result<(), database::Error> { - query("DELETE FROM torrust_categories;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_torrents;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_tracker_keys;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_users;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_authentication;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_bans;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_invitations;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_profiles;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_torrents;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; - - query("DELETE FROM torrust_user_public_keys;") - .execute(&self.pool) - .await - .map_err(|_| database::Error::Error)?; + for table in TABLES_TO_TRUNCATE { + query(&format!("DELETE FROM {table};")) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error)?; + } Ok(()) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs index df2054e6..a5f8b0e9 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs @@ -21,7 +21,7 @@ pub async fn migrate_target_database(target_database: Arc) target_database.migrate().await; } -pub async fn reset_target_database(target_database: Arc) { +pub async fn truncate_target_database(target_database: Arc) { println!("Truncating all tables in target database ..."); target_database .delete_all_database_rows() diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index d054ca1c..065d6306 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -6,7 +6,7 @@ use sqlx::sqlite::{SqlitePoolOptions, SqliteQueryResult}; use sqlx::{query, query_as, SqlitePool}; use super::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; -use crate::databases::database; +use crate::databases::database::{self, TABLES_TO_TRUNCATE}; use crate::models::torrent_file::{TorrentFile, TorrentInfo}; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] @@ -261,34 +261,12 @@ impl SqliteDatabaseV2_0_0 { #[allow(clippy::missing_panics_doc)] pub async fn delete_all_database_rows(&self) -> Result<(), database::Error> { - query("DELETE FROM torrust_categories").execute(&self.pool).await.unwrap(); - - query("DELETE FROM torrust_torrents").execute(&self.pool).await.unwrap(); - - query("DELETE FROM torrust_tracker_keys").execute(&self.pool).await.unwrap(); - - query("DELETE FROM torrust_users").execute(&self.pool).await.unwrap(); - - query("DELETE FROM torrust_user_authentication") - .execute(&self.pool) - .await - .unwrap(); - - query("DELETE FROM torrust_user_bans").execute(&self.pool).await.unwrap(); - - query("DELETE FROM torrust_user_invitations") - .execute(&self.pool) - .await - .unwrap(); - - query("DELETE FROM torrust_user_profiles").execute(&self.pool).await.unwrap(); - - query("DELETE FROM torrust_torrents").execute(&self.pool).await.unwrap(); - - query("DELETE FROM torrust_user_public_keys") - .execute(&self.pool) - .await - .unwrap(); + for table in TABLES_TO_TRUNCATE { + query(&format!("DELETE FROM {table};")) + .execute(&self.pool) + .await + .expect("table {table} should be deleted"); + } Ok(()) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 0c0f7a9d..c010d8e2 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -52,7 +52,7 @@ use std::time::SystemTime; use chrono::prelude::{DateTime, Utc}; use text_colorizer::Colorize; -use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{current_db, migrate_target_database, new_db, reset_target_database}; +use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{current_db, migrate_target_database, new_db, truncate_target_database}; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::category_transferrer::transfer_categories; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::transfer_torrents; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::tracker_key_transferrer::transfer_tracker_keys; @@ -120,7 +120,7 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) { println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); migrate_target_database(target_database.clone()).await; - reset_target_database(target_database.clone()).await; + truncate_target_database(target_database.clone()).await; transfer_categories(source_database.clone(), target_database.clone()).await; transfer_users(source_database.clone(), target_database.clone(), date_imported).await; From f85e153d057931ec5554ca53f52a7acdc7546a98 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 26 Jun 2023 17:48:23 +0100 Subject: [PATCH 247/357] feat!: [#144] don't allow to update settings Without restarting the application. This feature was using the `config.toml` file. That approach is not good for theses reasons: - If you use env vars to inject the settings, there is no `config.toml` file. - In dockerized (clouds) envs it's harder to mount a file than injecting env vars. Sometimes it's only allowed to mount a single file. --- src/config.rs | 28 ---------------- src/services/settings.rs | 19 ----------- src/web/api/v1/contexts/settings/handlers.rs | 33 ++----------------- src/web/api/v1/contexts/settings/routes.rs | 7 ++-- tests/common/client.rs | 8 +---- tests/common/contexts/settings/form.rs | 3 -- tests/common/contexts/settings/mod.rs | 1 - .../web/api/v1/contexts/settings/contract.rs | 27 --------------- 8 files changed, 6 insertions(+), 120 deletions(-) delete mode 100644 tests/common/contexts/settings/form.rs diff --git a/src/config.rs b/src/config.rs index 3901d987..9906d2ad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -372,34 +372,6 @@ impl Configuration { fs::write(config_path, toml_string).expect("Could not write to file!"); } - /// Update the settings file based upon a supplied `new_settings`. - /// - /// # Errors - /// - /// Todo: Make an error if the save fails. - /// - /// # Panics - /// - /// Will panic if the configuration file path is not defined. That happens - /// when the configuration was loaded from the environment variable. - pub async fn update_settings(&self, new_settings: TorrustBackend) -> Result<(), ()> { - match &self.config_path { - Some(config_path) => { - let mut settings = self.settings.write().await; - *settings = new_settings; - - drop(settings); - - let _ = self.save_to_file(config_path).await; - - Ok(()) - } - None => panic!( - "Cannot update settings when the config file path is not defined. For example: when it's loaded from env var." - ), - } - } - pub async fn get_all(&self) -> TorrustBackend { let settings_lock = self.settings.read().await; diff --git a/src/services/settings.rs b/src/services/settings.rs index 14ce5240..b768016b 100644 --- a/src/services/settings.rs +++ b/src/services/settings.rs @@ -37,25 +37,6 @@ impl Service { Ok(self.configuration.get_all().await) } - /// It updates all the settings. - /// - /// # Errors - /// - /// It returns an error if the user does not have the required permissions. - pub async fn update_all(&self, torrust_backend: TorrustBackend, user_id: &UserId) -> Result { - let user = self.user_repository.get_compact(user_id).await?; - - // Check if user is administrator - // todo: extract authorization service - if !user.administrator { - return Err(ServiceError::Unauthorized); - } - - let _ = self.configuration.update_settings(torrust_backend).await; - - Ok(self.configuration.get_all().await) - } - /// It gets only the public settings. /// /// # Errors diff --git a/src/web/api/v1/contexts/settings/handlers.rs b/src/web/api/v1/contexts/settings/handlers.rs index fbd5f871..f4d94541 100644 --- a/src/web/api/v1/contexts/settings/handlers.rs +++ b/src/web/api/v1/contexts/settings/handlers.rs @@ -2,13 +2,12 @@ //! context. use std::sync::Arc; -use axum::extract::{self, State}; +use axum::extract::State; use axum::response::{IntoResponse, Json, Response}; use crate::common::AppData; -use crate::config::TorrustBackend; use crate::web::api::v1::extractors::bearer_token::Extract; -use crate::web::api::v1::responses::{self}; +use crate::web::api::v1::responses; /// Get all settings. /// @@ -46,31 +45,3 @@ pub async fn get_site_name_handler(State(app_data): State>) -> Resp Json(responses::OkResponseData { data: site_name }).into_response() } - -/// Update all the settings. -/// -/// # Errors -/// -/// This function will return an error if: -/// -/// - The user does not have permission to update the settings. -/// - The settings could not be updated because they were loaded from env vars. -/// See -#[allow(clippy::unused_async)] -pub async fn update_handler( - State(app_data): State>, - Extract(maybe_bearer_token): Extract, - extract::Json(torrust_backend): extract::Json, -) -> Response { - let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { - Ok(user_id) => user_id, - Err(error) => return error.into_response(), - }; - - let new_settings = match app_data.settings_service.update_all(torrust_backend, &user_id).await { - Ok(new_settings) => new_settings, - Err(error) => return error.into_response(), - }; - - Json(responses::OkResponseData { data: new_settings }).into_response() -} diff --git a/src/web/api/v1/contexts/settings/routes.rs b/src/web/api/v1/contexts/settings/routes.rs index baffa4c2..e0990f52 100644 --- a/src/web/api/v1/contexts/settings/routes.rs +++ b/src/web/api/v1/contexts/settings/routes.rs @@ -3,10 +3,10 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::settings). use std::sync::Arc; -use axum::routing::{get, post}; +use axum::routing::get; use axum::Router; -use super::handlers::{get_all_handler, get_public_handler, get_site_name_handler, update_handler}; +use super::handlers::{get_all_handler, get_public_handler, get_site_name_handler}; use crate::common::AppData; /// Routes for the [`category`](crate::web::api::v1::contexts::category) API context. @@ -14,6 +14,5 @@ pub fn router(app_data: Arc) -> Router { Router::new() .route("/", get(get_all_handler).with_state(app_data.clone())) .route("/name", get(get_site_name_handler).with_state(app_data.clone())) - .route("/public", get(get_public_handler).with_state(app_data.clone())) - .route("/", post(update_handler).with_state(app_data)) + .route("/public", get(get_public_handler).with_state(app_data)) } diff --git a/tests/common/client.rs b/tests/common/client.rs index 25db78f5..7f2528d3 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -3,7 +3,6 @@ use serde::Serialize; use super::connection_info::ConnectionInfo; use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; -use super::contexts::settings::form::UpdateSettings; use super::contexts::tag::forms::{AddTagForm, DeleteTagForm}; use super::contexts::torrent::forms::UpdateTorrentFrom; use super::contexts::torrent::requests::InfoHash; @@ -17,8 +16,7 @@ pub struct Client { } impl Client { - // todo: forms in POST requests can be passed by reference. It's already - // changed for the `update_settings` method. + // todo: forms in POST requests can be passed by reference. fn base_path() -> String { "/v1".to_string() @@ -104,10 +102,6 @@ impl Client { self.http_client.get("/settings", Query::empty()).await } - pub async fn update_settings(&self, update_settings_form: &UpdateSettings) -> TextResponse { - self.http_client.post("/settings", &update_settings_form).await - } - // Context: torrent pub async fn get_torrents(&self, params: Query) -> TextResponse { diff --git a/tests/common/contexts/settings/form.rs b/tests/common/contexts/settings/form.rs deleted file mode 100644 index 1a20fddb..00000000 --- a/tests/common/contexts/settings/form.rs +++ /dev/null @@ -1,3 +0,0 @@ -use super::Settings; - -pub type UpdateSettings = Settings; diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index 8bd0c5f2..d8f24b0e 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -1,4 +1,3 @@ -pub mod form; pub mod responses; use serde::{Deserialize, Serialize}; diff --git a/tests/e2e/web/api/v1/contexts/settings/contract.rs b/tests/e2e/web/api/v1/contexts/settings/contract.rs index 5bd1c420..4684b1c0 100644 --- a/tests/e2e/web/api/v1/contexts/settings/contract.rs +++ b/tests/e2e/web/api/v1/contexts/settings/contract.rs @@ -65,30 +65,3 @@ async fn it_should_allow_admins_to_get_all_the_settings() { assert_json_ok_response(&response); } - -#[tokio::test] -async fn it_should_allow_admins_to_update_all_the_settings() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; - - if !env.is_isolated() { - // This test can't be executed in a non-isolated environment because - // it will change the settings for all the other tests. - return; - } - - let logged_in_admin = new_logged_in_admin(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - - let mut new_settings = env.server_settings().unwrap(); - - new_settings.website.name = "UPDATED NAME".to_string(); - - let response = client.update_settings(&new_settings).await; - - let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, new_settings); - - assert_json_ok_response(&response); -} From 21ee689774a29787a43a3f17d7c9f1d2bcf213ac Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 27 Jun 2023 09:10:55 +0100 Subject: [PATCH 248/357] feat!: [#215] return 404 when torrent is not found The endpoint to get the torrent info details returns a 404 HTTP status code instead of a 400, when the provided infohash does not exist in the database. The endpoint to download a torrent file is also changed. --- src/errors.rs | 2 +- .../web/api/v1/contexts/torrent/contract.rs | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index 16c89353..d5252105 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -215,7 +215,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED, ServiceError::TokenExpired => StatusCode::UNAUTHORIZED, ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED, - ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST, + ServiceError::TorrentNotFound => StatusCode::NOT_FOUND, ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST, ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST, ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 41ba09a4..64f8aac4 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -240,14 +240,23 @@ mod for_guests { } #[tokio::test] - async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { + async fn it_should_return_a_not_found_response_trying_to_get_the_torrent_info_for_a_non_existing_torrent() { let mut env = TestEnv::new(); env.start(api::Version::V1).await; - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let non_existing_info_hash: InfoHash = "443c7602b4fde83d1154d6d9da48808418b181b6".to_string(); + + let response = client.get_torrent(&non_existing_info_hash).await; + + assert_eq!(response.status, 404); + } + + #[tokio::test] + async fn it_should_return_a_not_found_trying_to_download_a_non_existing_torrent() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); @@ -255,8 +264,7 @@ mod for_guests { let response = client.download_torrent(&non_existing_info_hash).await; - // code-review: should this be 404? - assert_eq!(response.status, 400); + assert_eq!(response.status, 404); } #[tokio::test] From 21a1f16a93c91f64bd66f59f915259ee18e57e29 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 27 Jun 2023 13:11:54 +0100 Subject: [PATCH 249/357] feat!: [#97] make torrent category optional The JSON response can now contain a `null` value for the `category`: ```json { "data": { "torrent_id": 1, "uploader": "josecelano", "info_hash": "E8564469C258B1373BC2D5749FB83B1BF83D68A0", "title": "Ubuntu", "description": null, "category": null, "upload_date": "2023-06-27 11:35:09", "file_size": 1261707713, "seeders": 0, "leechers": 0, "files": [ { "path": [ "NNN.url" ], "length": 114, "md5sum": null }, { "path": [ "XXX.url" ], "length": 121, "md5sum": null }, { "path": [ "XXX.avi" ], "length": 1261707478, "md5sum": null } ], "trackers": [ "udp://tracker:6969", ], "magnet_link": "magnet:?xt=urn:btih:E8564469C258B1373BC2D5749FB83B1BF83D68A0&dn=Ubuntu&tr=udp%3A%2F%2Ftracker%3A6969e", "tags": [] } } ``` --- ...27103405_torrust_allow_null_categories.sql | 6 ++++ ...27103405_torrust_allow_null_categories.sql | 28 +++++++++++++++++++ src/models/response.rs | 4 +-- src/models/torrent.rs | 2 +- src/services/torrent.rs | 10 +++++-- 5 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 migrations/mysql/20230627103405_torrust_allow_null_categories.sql create mode 100644 migrations/sqlite3/20230627103405_torrust_allow_null_categories.sql diff --git a/migrations/mysql/20230627103405_torrust_allow_null_categories.sql b/migrations/mysql/20230627103405_torrust_allow_null_categories.sql new file mode 100644 index 00000000..9a540278 --- /dev/null +++ b/migrations/mysql/20230627103405_torrust_allow_null_categories.sql @@ -0,0 +1,6 @@ +-- Step 1: Allow null categories for torrents +ALTER TABLE torrust_torrents MODIFY category_id INTEGER NULL; + +-- Step 2: Set torrent category to NULL when category is deleted +ALTER TABLE `torrust_torrents` DROP FOREIGN KEY `torrust_torrents_ibfk_2`; +ALTER TABLE `torrust_torrents` ADD CONSTRAINT `torrust_torrents_ibfk_2` FOREIGN KEY (`category_id`) REFERENCES `torrust_categories` (`category_id`) ON DELETE SET NULL; diff --git a/migrations/sqlite3/20230627103405_torrust_allow_null_categories.sql b/migrations/sqlite3/20230627103405_torrust_allow_null_categories.sql new file mode 100644 index 00000000..f2c2b13e --- /dev/null +++ b/migrations/sqlite3/20230627103405_torrust_allow_null_categories.sql @@ -0,0 +1,28 @@ +-- Step 1: Create a new table with the new structure +CREATE TABLE IF NOT EXISTS "torrust_torrents_new" ( + "torrent_id" INTEGER NOT NULL, + "uploader_id" INTEGER NOT NULL, + "category_id" INTEGER NULL, + "info_hash" TEXT NOT NULL UNIQUE, + "size" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "pieces" TEXT NOT NULL, + "piece_length" INTEGER NOT NULL, + "private" BOOLEAN DEFAULT NULL, + "root_hash" INT NOT NULL DEFAULT 0, + "date_uploaded" TEXT NOT NULL, + FOREIGN KEY("uploader_id") REFERENCES "torrust_users"("user_id") ON DELETE CASCADE, + FOREIGN KEY("category_id") REFERENCES "torrust_categories"("category_id") ON DELETE SET NULL, + PRIMARY KEY("torrent_id" AUTOINCREMENT) +); + +-- Step 2: Copy rows from the current table to the new table +INSERT INTO torrust_torrents_new (torrent_id, uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) +SELECT torrent_id, uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded +FROM torrust_torrents; + +-- Step 3: Delete the current table +DROP TABLE torrust_torrents; + +-- Step 1: Rename the new table +ALTER TABLE torrust_torrents_new RENAME TO torrust_torrents; diff --git a/src/models/response.rs b/src/models/response.rs index 09476be3..cd8b4f59 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -52,7 +52,7 @@ pub struct TorrentResponse { pub info_hash: String, pub title: String, pub description: Option, - pub category: Category, + pub category: Option, pub upload_date: String, pub file_size: i64, pub seeders: i64, @@ -65,7 +65,7 @@ pub struct TorrentResponse { impl TorrentResponse { #[must_use] - pub fn from_listing(torrent_listing: TorrentListing, category: Category) -> TorrentResponse { + pub fn from_listing(torrent_listing: TorrentListing, category: Option) -> TorrentResponse { TorrentResponse { torrent_id: torrent_listing.torrent_id, uploader: torrent_listing.uploader, diff --git a/src/models/torrent.rs b/src/models/torrent.rs index c06800ca..194b0c10 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -16,7 +16,7 @@ pub struct TorrentListing { pub info_hash: String, pub title: String, pub description: Option, - pub category_id: i64, + pub category_id: Option, pub date_uploaded: String, pub file_size: i64, pub seeders: i64, diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 7a7262ba..c56dc9a3 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -221,7 +221,10 @@ impl Index { let torrent_id = torrent_listing.torrent_id; - let category = self.category_repository.get_by_id(&torrent_listing.category_id).await?; + let category = match torrent_listing.category_id { + Some(category_id) => Some(self.category_repository.get_by_id(&category_id).await?), + None => None, + }; let mut torrent_response = TorrentResponse::from_listing(torrent_listing, category); @@ -382,7 +385,10 @@ impl Index { .one_torrent_by_torrent_id(&torrent_listing.torrent_id) .await?; - let category = self.category_repository.get_by_id(&torrent_listing.category_id).await?; + let category = match torrent_listing.category_id { + Some(category_id) => Some(self.category_repository.get_by_id(&category_id).await?), + None => None, + }; let torrent_response = TorrentResponse::from_listing(torrent_listing, category); From 7803d5aaa9aebb1054243cccf25a3157b02242f5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 27 Jun 2023 16:04:55 +0100 Subject: [PATCH 250/357] feat!: [#217] lowercase infohashes --- ...4318_torrust_covert_infohashes_to_lowercase.sql | 1 + ...4318_torrust_covert_infohashes_to_lowercase.sql | 1 + src/databases/mysql.rs | 6 +++--- src/databases/sqlite.rs | 6 +++--- src/models/torrent_file.rs | 2 +- src/web/api/v1/contexts/torrent/handlers.rs | 14 ++++++++++---- tests/e2e/web/api/v1/contexts/torrent/contract.rs | 4 ++-- 7 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 migrations/mysql/20230627144318_torrust_covert_infohashes_to_lowercase.sql create mode 100644 migrations/sqlite3/20230627144318_torrust_covert_infohashes_to_lowercase.sql diff --git a/migrations/mysql/20230627144318_torrust_covert_infohashes_to_lowercase.sql b/migrations/mysql/20230627144318_torrust_covert_infohashes_to_lowercase.sql new file mode 100644 index 00000000..7014ed01 --- /dev/null +++ b/migrations/mysql/20230627144318_torrust_covert_infohashes_to_lowercase.sql @@ -0,0 +1 @@ +UPDATE torrust_torrents SET info_hash = LOWER(info_hash); \ No newline at end of file diff --git a/migrations/sqlite3/20230627144318_torrust_covert_infohashes_to_lowercase.sql b/migrations/sqlite3/20230627144318_torrust_covert_infohashes_to_lowercase.sql new file mode 100644 index 00000000..7014ed01 --- /dev/null +++ b/migrations/sqlite3/20230627144318_torrust_covert_infohashes_to_lowercase.sql @@ -0,0 +1 @@ +UPDATE torrust_torrents SET info_hash = LOWER(info_hash); \ No newline at end of file diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 9b28e51f..bd4c6b48 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -444,7 +444,7 @@ impl Database for Mysql { let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") .bind(uploader_id) .bind(category_id) - .bind(info_hash.to_uppercase()) + .bind(info_hash.to_lowercase()) .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) .bind(pieces) @@ -582,7 +582,7 @@ impl Database for Mysql { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) - .bind(info_hash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string + .bind(info_hash.to_hex_string().to_lowercase()) .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) @@ -652,7 +652,7 @@ impl Database for Mysql { WHERE tt.info_hash = ? GROUP BY torrent_id" ) - .bind(info_hash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string + .bind(info_hash.to_hex_string().to_lowercase()) .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 8d2123a3..b1cfc89e 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -434,7 +434,7 @@ impl Database for Sqlite { let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") .bind(uploader_id) .bind(category_id) - .bind(info_hash.to_uppercase()) + .bind(info_hash.to_lowercase()) .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) .bind(pieces) @@ -572,7 +572,7 @@ impl Database for Sqlite { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", ) - .bind(info_hash.to_hex_string().to_uppercase()) // `info_hash` is stored as uppercase hex string + .bind(info_hash.to_hex_string().to_lowercase()) .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) @@ -642,7 +642,7 @@ impl Database for Sqlite { WHERE tt.info_hash = ? GROUP BY ts.torrent_id" ) - .bind(info_hash.to_string().to_uppercase()) // `info_hash` is stored as uppercase + .bind(info_hash.to_string().to_lowercase()) .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 57cf3c36..801aa1c6 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -193,7 +193,7 @@ impl Torrent { #[must_use] pub fn info_hash(&self) -> String { - from_bytes(&self.calculate_info_hash_as_bytes()) + from_bytes(&self.calculate_info_hash_as_bytes()).to_lowercase() } #[must_use] diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index b3e197ed..dd728db9 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -57,6 +57,12 @@ pub async fn upload_torrent_handler( #[derive(Deserialize)] pub struct InfoHashParam(pub String); +impl InfoHashParam { + fn lowercase(&self) -> String { + self.0.to_lowercase() + } +} + /// Returns the torrent as a byte stream `application/x-bittorrent`. /// /// # Errors @@ -68,7 +74,7 @@ pub async fn download_torrent_handler( Extract(maybe_bearer_token): Extract, Path(info_hash): Path, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { Ok(opt_user_id) => opt_user_id, @@ -114,7 +120,7 @@ pub async fn get_torrent_info_handler( Extract(maybe_bearer_token): Extract, Path(info_hash): Path, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { Ok(opt_user_id) => opt_user_id, @@ -143,7 +149,7 @@ pub async fn update_torrent_info_handler( Path(info_hash): Path, extract::Json(update_torrent_info_form): extract::Json, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { Ok(user_id) => user_id, @@ -182,7 +188,7 @@ pub async fn delete_torrent_handler( Extract(maybe_bearer_token): Extract, Path(info_hash): Path, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.0) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { Ok(user_id) => user_id, diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 64f8aac4..73d47ed8 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -176,7 +176,7 @@ mod for_guests { let expected_torrent = TorrentDetails { torrent_id: uploaded_torrent.torrent_id, uploader: uploader.username, - info_hash: test_torrent.file_info.info_hash.to_uppercase(), + info_hash: test_torrent.file_info.info_hash.to_lowercase(), title: test_torrent.index_info.title.clone(), description: test_torrent.index_info.description, category: Category { @@ -203,7 +203,7 @@ mod for_guests { magnet_link: format!( // cspell:disable-next-line "magnet:?xt=urn:btih:{}&dn={}&tr={}&tr={}", - test_torrent.file_info.info_hash.to_uppercase(), + test_torrent.file_info.info_hash.to_lowercase(), urlencoding::encode(&test_torrent.index_info.title), encoded_tracker_url, encoded_tracker_url From acf5728e32bd0e38f7289516b8710e98d24f5d62 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 27 Jun 2023 17:05:25 +0100 Subject: [PATCH 251/357] docs: proxied images can only be PNG --- docs/media/torrust_logo.png | Bin 0 -> 193957 bytes src/web/api/v1/contexts/proxy/mod.rs | 16 ++++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 docs/media/torrust_logo.png diff --git a/docs/media/torrust_logo.png b/docs/media/torrust_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..28abf137dc162d1d75cedf3253fdf44e634da208 GIT binary patch literal 193957 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%0Aq=H}{j)v#Y{LL#C=_VoY>?|-! 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 +//! +//! The percent encoded image URL: +//! +//! ```text +//! https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png +//! ``` //! //! For unauthenticated clients: //! @@ -26,7 +38,7 @@ //! --header "cache-control: no-cache" \ //! --header "pragma: no-cache" \ //! --output mandelbrotset.jpg \ -//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2Fthumb%2F2%2F21%2FMandel_zoom_00_mandelbrot_set.jpg%2F1280px-Mandel_zoom_00_mandelbrot_set.jpg +//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png //! ``` //! //! You will receive an image with the text "Sign in to see image" instead. @@ -39,7 +51,7 @@ //! --header "cache-control: no-cache" \ //! --header "pragma: no-cache" \ //! --output mandelbrotset.jpg \ -//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2Fthumb%2F2%2F21%2FMandel_zoom_00_mandelbrot_set.jpg%2F1280px-Mandel_zoom_00_mandelbrot_set.jpg +//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png //! ``` pub mod handlers; pub mod responses; From 802df100976aa047876843e20b83e148dc8490be Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 27 Jun 2023 18:31:50 +0100 Subject: [PATCH 252/357] fix: [#227] load error images in image proxy before serving them Error images in image proxy were not been loaded before serving them, resulting in not sending the iamge data to the frontend. --- src/ui/proxy.rs | 11 ++++++----- src/web/api/v1/contexts/proxy/mod.rs | 10 +++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/ui/proxy.rs b/src/ui/proxy.rs index a744c5b7..78dd2fc2 100644 --- a/src/ui/proxy.rs +++ b/src/ui/proxy.rs @@ -7,11 +7,11 @@ use crate::cache::image::manager::Error; pub static ERROR_IMAGE_LOADER: Once = Once::new(); -pub static mut ERROR_IMAGE_URL_IS_UNREACHABLE: Bytes = Bytes::new(); -pub static mut ERROR_IMAGE_URL_IS_NOT_AN_IMAGE: Bytes = Bytes::new(); -pub static mut ERROR_IMAGE_TOO_BIG: Bytes = Bytes::new(); -pub static mut ERROR_IMAGE_USER_QUOTA_MET: Bytes = Bytes::new(); -pub static mut ERROR_IMAGE_UNAUTHENTICATED: Bytes = Bytes::new(); +static mut ERROR_IMAGE_URL_IS_UNREACHABLE: Bytes = Bytes::new(); +static mut ERROR_IMAGE_URL_IS_NOT_AN_IMAGE: Bytes = Bytes::new(); +static mut ERROR_IMAGE_TOO_BIG: Bytes = Bytes::new(); +static mut ERROR_IMAGE_USER_QUOTA_MET: Bytes = Bytes::new(); +static mut ERROR_IMAGE_UNAUTHENTICATED: Bytes = Bytes::new(); const ERROR_IMG_FONT_SIZE: u8 = 16; const ERROR_IMG_COLOR: &str = "Red"; @@ -33,6 +33,7 @@ pub fn load_error_images() { } pub fn map_error_to_image(error: &Error) -> Bytes { + load_error_images(); unsafe { match error { Error::UrlIsUnreachable => ERROR_IMAGE_URL_IS_UNREACHABLE.clone(), diff --git a/src/web/api/v1/contexts/proxy/mod.rs b/src/web/api/v1/contexts/proxy/mod.rs index ea5b5dbd..a6adae1a 100644 --- a/src/web/api/v1/contexts/proxy/mod.rs +++ b/src/web/api/v1/contexts/proxy/mod.rs @@ -9,7 +9,7 @@ //! - Avoid storing images on the server. //! //! The proxy service is a simple cache that stores the images in memory. -//! +//! //! **NOTICE:** For now, it only supports PNG images. //! //! **NOTICE:** The proxy service is not intended to be used as a general @@ -20,13 +20,13 @@ //! with the text "Sign in to see image" instead. //! //! # Example -//! +//! //! The PNG image: -//! +//! //! -//! +//! //! The percent encoded image URL: -//! +//! //! ```text //! https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png //! ``` From ba8b2c20367f3257315c8836197b2dbc560b9d0d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 28 Jun 2023 12:40:35 +0100 Subject: [PATCH 253/357] feat!: change default API port to 3001 To avoid conflict with the frontend default port for nuxt. --- Dockerfile | 4 ++-- README.md | 2 +- compose.yaml | 2 +- config-idx-back.local.toml | 2 +- config.local.toml | 2 +- docker/README.md | 4 ++-- docker/bin/run.sh | 2 +- src/config.rs | 4 ++-- src/lib.rs | 6 +++--- src/services/proxy.rs | 2 +- src/web/api/v1/auth.rs | 6 +++--- src/web/api/v1/contexts/about/mod.rs | 4 ++-- src/web/api/v1/contexts/category/mod.rs | 6 +++--- src/web/api/v1/contexts/proxy/mod.rs | 4 ++-- src/web/api/v1/contexts/settings/mod.rs | 12 ++++++------ src/web/api/v1/contexts/tag/mod.rs | 6 +++--- src/web/api/v1/contexts/torrent/mod.rs | 12 ++++++------ src/web/api/v1/contexts/user/mod.rs | 10 +++++----- tests/e2e/environment.rs | 2 +- tests/e2e/mod.rs | 2 +- tests/environments/shared.rs | 4 ++-- 21 files changed, 49 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index 08744910..ffdd7ef0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ FROM chef as development WORKDIR /app ARG UID=1000 ARG RUN_AS_USER=appuser -ARG IDX_BACK_API_PORT=3000 +ARG IDX_BACK_API_PORT=3001 # Add the app user for development ENV USER=appuser ENV UID=$UID @@ -57,7 +57,7 @@ RUN strip /app/target/x86_64-unknown-linux-musl/release/main FROM alpine:latest WORKDIR /app ARG RUN_AS_USER=appuser -ARG IDX_BACK_API_PORT=3000 +ARG IDX_BACK_API_PORT=3001 RUN apk --no-cache add ca-certificates ENV TZ=Etc/UTC ENV RUN_AS_USER=$RUN_AS_USER diff --git a/README.md b/README.md index 73224ade..7067293c 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ git clone https://github.com/torrust/torrust-index-backend.git \ And then run `cargo run` twice. The first time to generate the `config.toml` file and the second time to run the backend with the default configuration. -After running the tracker the API will be available at . +After running the tracker the API will be available at . ## Documentation diff --git a/compose.yaml b/compose.yaml index 8c672f00..2c46d9df 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,7 +16,7 @@ services: networks: - server_side ports: - - 3000:3000 + - 3001:3001 # todo: implement healthcheck #healthcheck: # test: diff --git a/config-idx-back.local.toml b/config-idx-back.local.toml index 9b6264f6..4b359800 100644 --- a/config-idx-back.local.toml +++ b/config-idx-back.local.toml @@ -11,7 +11,7 @@ token = "MyAccessToken" token_valid_seconds = 7257600 [net] -port = 3000 +port = 3001 [auth] email_on_signup = "Optional" diff --git a/config.local.toml b/config.local.toml index 3bb1093e..06f89a3c 100644 --- a/config.local.toml +++ b/config.local.toml @@ -11,7 +11,7 @@ token = "MyAccessToken" token_valid_seconds = 7257600 [net] -port = 3000 +port = 3001 [auth] email_on_signup = "Optional" diff --git a/docker/README.md b/docker/README.md index 3dbfa038..be47bfad 100644 --- a/docker/README.md +++ b/docker/README.md @@ -33,7 +33,7 @@ Run using the pre-built public docker image: export TORRUST_IDX_BACK_USER_UID=$(id -u) docker run -it \ --user="$TORRUST_IDX_BACK_USER_UID" \ - --publish 3000:3000/tcp \ + --publish 3001:3001/tcp \ --volume "$(pwd)/storage":"/app/storage" \ torrust/index-backend ``` @@ -75,7 +75,7 @@ After running the "up" command you will have three running containers: ```s $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES -e35b14edaceb torrust-idx-back "cargo run" 19 seconds ago Up 17 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp torrust-idx-back-1 +e35b14edaceb torrust-idx-back "cargo run" 19 seconds ago Up 17 seconds 0.0.0.0:3001->3001/tcp, :::3001->3001/tcp torrust-idx-back-1 ddbad9fb496a torrust/tracker:develop "/app/torrust-tracker" 19 seconds ago Up 18 seconds 0.0.0.0:1212->1212/tcp, :::1212->1212/tcp, 0.0.0.0:6969->6969/udp, :::6969->6969/udp, 7070/tcp torrust-tracker-1 f1d991d62170 mysql:8.0 "docker-entrypoint.s…" 3 hours ago Up 18 seconds (healthy) 0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp torrust-mysql-1 torrust-mysql-1 diff --git a/docker/bin/run.sh b/docker/bin/run.sh index 92417f9a..48b86f02 100755 --- a/docker/bin/run.sh +++ b/docker/bin/run.sh @@ -5,7 +5,7 @@ TORRUST_IDX_BACK_CONFIG=$(cat config.toml) docker run -it \ --user="$TORRUST_IDX_BACK_USER_UID" \ - --publish 3000:3000/tcp \ + --publish 3001:3001/tcp \ --env TORRUST_IDX_BACK_CONFIG="$TORRUST_IDX_BACK_CONFIG" \ --volume "$(pwd)/storage":"/app/storage" \ torrust-index-backend diff --git a/src/config.rs b/src/config.rs index 9906d2ad..d75d6f6e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,7 +80,7 @@ pub const FREE_PORT: u16 = 0; /// The the base URL for the API. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Network { - /// The port to listen on. Default to `3000`. + /// The port to listen on. Default to `3001`. pub port: u16, /// The base URL for the API. For example: `http://localhost`. /// If not set, the base URL will be inferred from the request. @@ -90,7 +90,7 @@ pub struct Network { impl Default for Network { fn default() -> Self { Self { - port: 3000, + port: 3001, base_url: None, } } diff --git a/src/lib.rs b/src/lib.rs index ae01a9a5..faffb360 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -113,7 +113,7 @@ //! && export TORRUST_IDX_BACK_USER_UID=1000 \ //! && docker run -it \ //! --user="$TORRUST_IDX_BACK_USER_UID" \ -//! --publish 3000:3000/tcp \ +//! --publish 3001:3001/tcp \ //! --volume "$(pwd)/storage":"/app/storage" \ //! torrust/index-backend //! ``` @@ -167,7 +167,7 @@ //! token_valid_seconds = 7257600 //! //! [net] -//! port = 3000 +//! port = 3001 //! //! [auth] //! email_on_signup = "Optional" @@ -223,7 +223,7 @@ //! //! ## API //! -//! Running the tracker with the default configuration will expose the REST API on port 3000: +//! Running the backend with the default configuration will expose the REST API on port 3001: //! //! ## Tracker Statistics Importer //! diff --git a/src/services/proxy.rs b/src/services/proxy.rs index 248747c1..9ea5ef3d 100644 --- a/src/services/proxy.rs +++ b/src/services/proxy.rs @@ -5,7 +5,7 @@ //! //! Sample URL: //! -//! +//! use std::sync::Arc; use bytes::Bytes; diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs index 3967aa28..f98436e3 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/v1/auth.rs @@ -17,7 +17,7 @@ //! --header "Content-Type: application/json" \ //! --request POST \ //! --data '{"username":"indexadmin","email":"indexadmin@torrust.com","password":"BenoitMandelbrot1924","confirm_password":"BenoitMandelbrot1924"}' \ -//! http://127.0.0.1:3000/v1/user/register +//! http://127.0.0.1:3001/v1/user/register //! ``` //! //! **NOTICE**: The first user is automatically an administrator. Currently, @@ -34,7 +34,7 @@ //! --header "Content-Type: application/json" \ //! --request POST \ //! --data '{"login":"indexadmin","password":"BenoitMandelbrot1924"}' \ -//! http://127.0.0.1:3000/v1/user/login +//! http://127.0.0.1:3001/v1/user/login //! ``` //! //! **Response** @@ -68,7 +68,7 @@ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request POST \ //! --data '{"name":"new category","icon":null}' \ -//! http://127.0.0.1:3000/v1/category +//! http://127.0.0.1:3001/v1/category //! ``` //! //! **Response** diff --git a/src/web/api/v1/contexts/about/mod.rs b/src/web/api/v1/contexts/about/mod.rs index bde0696a..6f397ab6 100644 --- a/src/web/api/v1/contexts/about/mod.rs +++ b/src/web/api/v1/contexts/about/mod.rs @@ -17,7 +17,7 @@ //! **Example request** //! //! ```bash -//! curl "http://127.0.0.1:3000/v1/about" +//! curl "http://127.0.0.1:3001/v1/about" //! ``` //! //! **Example response** `200` @@ -49,7 +49,7 @@ //! **Example request** //! //! ```bash -//! curl "http://127.0.0.1:3000/v1/about/license" +//! curl "http://127.0.0.1:3001/v1/about/license" //! ``` //! //! **Example response** `200` diff --git a/src/web/api/v1/contexts/category/mod.rs b/src/web/api/v1/contexts/category/mod.rs index 804faf27..c6ed8a71 100644 --- a/src/web/api/v1/contexts/category/mod.rs +++ b/src/web/api/v1/contexts/category/mod.rs @@ -20,7 +20,7 @@ //! **Example request** //! //! ```bash -//! curl "http://127.0.0.1:3000/v1/category" +//! curl "http://127.0.0.1:3001/v1/category" //! ``` //! //! **Example response** `200` @@ -84,7 +84,7 @@ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request POST \ //! --data '{"name":"new category","icon":null}' \ -//! http://127.0.0.1:3000/v1/category +//! http://127.0.0.1:3001/v1/category //! ``` //! //! **Example response** `200` @@ -122,7 +122,7 @@ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request DELETE \ //! --data '{"name":"new category","icon":null}' \ -//! http://127.0.0.1:3000/v1/category +//! http://127.0.0.1:3001/v1/category //! ``` //! //! **Example response** `200` diff --git a/src/web/api/v1/contexts/proxy/mod.rs b/src/web/api/v1/contexts/proxy/mod.rs index a6adae1a..f0d54ce9 100644 --- a/src/web/api/v1/contexts/proxy/mod.rs +++ b/src/web/api/v1/contexts/proxy/mod.rs @@ -38,7 +38,7 @@ //! --header "cache-control: no-cache" \ //! --header "pragma: no-cache" \ //! --output mandelbrotset.jpg \ -//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png +//! http://0.0.0.0:3001/v1/proxy/image/https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png //! ``` //! //! You will receive an image with the text "Sign in to see image" instead. @@ -51,7 +51,7 @@ //! --header "cache-control: no-cache" \ //! --header "pragma: no-cache" \ //! --output mandelbrotset.jpg \ -//! http://0.0.0.0:3000/v1/proxy/image/https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png +//! http://0.0.0.0:3001/v1/proxy/image/https%3A%2F%2Fraw.githubusercontent.com%2Ftorrust%2Ftorrust-index-backend%2Fdevelop%2Fdocs%2Fmedia%2Ftorrust_logo.png //! ``` pub mod handlers; pub mod responses; diff --git a/src/web/api/v1/contexts/settings/mod.rs b/src/web/api/v1/contexts/settings/mod.rs index 40f511f2..371a46db 100644 --- a/src/web/api/v1/contexts/settings/mod.rs +++ b/src/web/api/v1/contexts/settings/mod.rs @@ -22,7 +22,7 @@ //! --header "Content-Type: application/json" \ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request GET \ -//! "http://127.0.0.1:3000/v1/settings" +//! "http://127.0.0.1:3001/v1/settings" //! ``` //! //! **Example response** `200` @@ -41,7 +41,7 @@ //! "token_valid_seconds": 7257600 //! }, //! "net": { -//! "port": 3000, +//! "port": 3001, //! "base_url": null //! }, //! "auth": { @@ -101,8 +101,8 @@ //! --header "Content-Type: application/json" \ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request POST \ -//! --data '{"website":{"name":"Torrust"},"tracker":{"url":"udp://localhost:6969","mode":"Public","api_url":"http://localhost:1212","token":"MyAccessToken","token_valid_seconds":7257600},"net":{"port":3000,"base_url":null},"auth":{"email_on_signup":"Optional","min_password_length":6,"max_password_length":64,"secret_key":"MaxVerstappenWC2021"},"database":{"connect_url":"sqlite://./storage/database/data.db?mode=rwc"},"mail":{"email_verification_enabled":false,"from":"example@email.com","reply_to":"noreply@email.com","username":"","password":"","server":"","port":25},"image_cache":{"max_request_timeout_ms":1000,"capacity":128000000,"entry_size_limit":4000000,"user_quota_period_seconds":3600,"user_quota_bytes":64000000},"api":{"default_torrent_page_size":10,"max_torrent_page_size":30},"tracker_statistics_importer":{"torrent_info_update_interval":3600}}' \ -//! "http://127.0.0.1:3000/v1/settings" +//! --data '{"website":{"name":"Torrust"},"tracker":{"url":"udp://localhost:6969","mode":"Public","api_url":"http://localhost:1212","token":"MyAccessToken","token_valid_seconds":7257600},"net":{"port":3001,"base_url":null},"auth":{"email_on_signup":"Optional","min_password_length":6,"max_password_length":64,"secret_key":"MaxVerstappenWC2021"},"database":{"connect_url":"sqlite://./storage/database/data.db?mode=rwc"},"mail":{"email_verification_enabled":false,"from":"example@email.com","reply_to":"noreply@email.com","username":"","password":"","server":"","port":25},"image_cache":{"max_request_timeout_ms":1000,"capacity":128000000,"entry_size_limit":4000000,"user_quota_period_seconds":3600,"user_quota_bytes":64000000},"api":{"default_torrent_page_size":10,"max_torrent_page_size":30},"tracker_statistics_importer":{"torrent_info_update_interval":3600}}' \ +//! "http://127.0.0.1:3001/v1/settings" //! ``` //! //! The response contains the settings that were updated. @@ -124,7 +124,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request GET \ -//! "http://127.0.0.1:3000/v1/settings/name" +//! "http://127.0.0.1:3001/v1/settings/name" //! ``` //! //! **Example response** `200` @@ -147,7 +147,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request GET \ -//! "http://127.0.0.1:3000/v1/settings/public" +//! "http://127.0.0.1:3001/v1/settings/public" //! ``` //! //! **Example response** `200` diff --git a/src/web/api/v1/contexts/tag/mod.rs b/src/web/api/v1/contexts/tag/mod.rs index 1d4d77de..eb4dd68d 100644 --- a/src/web/api/v1/contexts/tag/mod.rs +++ b/src/web/api/v1/contexts/tag/mod.rs @@ -20,7 +20,7 @@ //! **Example request** //! //! ```bash -//! curl "http://127.0.0.1:3000/v1/tags" +//! curl "http://127.0.0.1:3001/v1/tags" //! ``` //! //! **Example response** `200` @@ -64,7 +64,7 @@ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request POST \ //! --data '{"name":"new tag"}' \ -//! http://127.0.0.1:3000/v1/tag +//! http://127.0.0.1:3001/v1/tag //! ``` //! //! **Example response** `200` @@ -101,7 +101,7 @@ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request DELETE \ //! --data '{"tag_id":1}' \ -//! http://127.0.0.1:3000/v1/tag +//! http://127.0.0.1:3001/v1/tag //! ``` //! //! **Example response** `200` diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs index 6041e468..6553ba7f 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -28,7 +28,7 @@ //! --form "description=MandelbrotSet image" \ //! --form "category=software" \ //! --form "torrent=@docs/media/mandelbrot_2048x2048_infohash_v1.png.torrent;type=application/x-bittorrent" \ -//! "http://127.0.0.1:3000/v1/torrent/upload" +//! "http://127.0.0.1:3001/v1/torrent/upload" //! ``` //! //! **Example response** `200` @@ -63,7 +63,7 @@ //! --header "Content-Type: application/x-bittorrent" \ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --output mandelbrot_2048x2048_infohash_v1.png.torrent \ -//! "http://127.0.0.1:3000/v1/torrent/download/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! "http://127.0.0.1:3001/v1/torrent/download/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` //! //! **Example response** `200` @@ -105,7 +105,7 @@ //! --header "Content-Type: application/json" \ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request GET \ -//! "http://127.0.0.1:3000/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! "http://127.0.0.1:3001/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` //! //! **Example response** `200` @@ -191,7 +191,7 @@ //! --header "Content-Type: application/json" \ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request GET \ -//! "http://127.0.0.1:3000/v1/torrents" +//! "http://127.0.0.1:3001/v1/torrents" //! ``` //! //! **Example response** `200` @@ -256,7 +256,7 @@ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request PUT \ //! --data '{"title":"MandelbrotSet", "description":"MandelbrotSet image"}' \ -//! "http://127.0.0.1:3000/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! "http://127.0.0.1:3001/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` //! //! **Example response** `200` @@ -312,7 +312,7 @@ //! --header "Content-Type: application/json" \ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request DELETE \ -//! "http://127.0.0.1:3000/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" +//! "http://127.0.0.1:3001/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` //! //! **Example response** `200` diff --git a/src/web/api/v1/contexts/user/mod.rs b/src/web/api/v1/contexts/user/mod.rs index 0b0b0eb5..4f4682e0 100644 --- a/src/web/api/v1/contexts/user/mod.rs +++ b/src/web/api/v1/contexts/user/mod.rs @@ -60,7 +60,7 @@ //! --header "Content-Type: application/json" \ //! --request POST \ //! --data '{"username":"indexadmin","email":"indexadmin@torrust.com","password":"BenoitMandelbrot1924","confirm_password":"BenoitMandelbrot1924"}' \ -//! http://127.0.0.1:3000/v1/user/register +//! http://127.0.0.1:3001/v1/user/register //! ``` //! //! For more information about the registration process, refer to the [`auth`](crate::web::api::v1::auth) @@ -107,7 +107,7 @@ //! --header "Content-Type: application/json" \ //! --request POST \ //! --data '{"login":"indexadmin","password":"BenoitMandelbrot1924"}' \ -//! http://127.0.0.1:3000/v1/user/login +//! http://127.0.0.1:3001/v1/user/login //! ``` //! //! For more information about the login process, refer to the [`auth`](crate::web::api::v1::auth) @@ -135,7 +135,7 @@ //! --header "Content-Type: application/json" \ //! --request POST \ //! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ -//! http://127.0.0.1:3000/v1/user/token/verify +//! http://127.0.0.1:3001/v1/user/token/verify //! ``` //! //! **Example response** `200` @@ -181,7 +181,7 @@ //! --header "Content-Type: application/json" \ //! --request POST \ //! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ -//! http://127.0.0.1:3000/v1/user/token/renew +//! http://127.0.0.1:3001/v1/user/token/renew //! ``` //! //! **Example response** `200` @@ -227,7 +227,7 @@ //! --header "Content-Type: application/json" \ //! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ //! --request DELETE \ -//! http://127.0.0.1:3000/v1/user/ban/indexadmin +//! http://127.0.0.1:3001/v1/user/ban/indexadmin //! ``` //! //! **Example response** `200` diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 4684fd82..d5022785 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -86,7 +86,7 @@ impl TestEnv { } /// Provides the API server socket address. - /// For example: `localhost:3000`. + /// For example: `localhost:3001`. pub fn server_socket_addr(&self) -> Option { match self.state() { State::RunningShared => self.shared.as_ref().unwrap().server_socket_addr(), diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 2b909fd9..3d0c58a0 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -7,7 +7,7 @@ //! set the environment variable `TORRUST_IDX_BACK_E2E_SHARED` to `true`. //! //! > **NOTICE**: The server must be running before running the tests. The -//! server url is hardcoded to `http://localhost:3000` for now. We are planning +//! server url is hardcoded to `http://localhost:3001` for now. We are planning //! to make it configurable in the future via a environment variable. //! //! ```text diff --git a/tests/environments/shared.rs b/tests/environments/shared.rs index 1920f0cd..d9db57be 100644 --- a/tests/environments/shared.rs +++ b/tests/environments/shared.rs @@ -25,7 +25,7 @@ impl TestEnv { #[must_use] pub fn server_socket_addr(&self) -> Option { // If the E2E configuration uses port 0 in the future instead of a - // predefined port (right now we are using port 3000) we will + // predefined port (right now we are using port 3001) we will // need to pass an env var with the port used by the server. Some(self.authority.clone()) } @@ -34,7 +34,7 @@ impl TestEnv { impl Default for TestEnv { fn default() -> Self { Self { - authority: "localhost:3000".to_string(), + authority: "localhost:3001".to_string(), } } } From fe25778f8ed3e3d8055526cf4a571d40d4925f4c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 3 Jul 2023 12:12:57 +0100 Subject: [PATCH 254/357] chore: udpate rust toolchain 1.72.0-nightly Fixing cargo fmt and clipply errors and warnings --- Cargo.lock | 713 +++++++++--------- src/app.rs | 5 + src/cache/image/manager.rs | 10 + src/cache/mod.rs | 2 +- src/config.rs | 4 + .../commands/import_tracker_statistics.rs | 5 + src/databases/mysql.rs | 6 +- src/databases/sqlite.rs | 6 +- src/mailer.rs | 6 +- src/models/torrent_file.rs | 23 +- src/services/authentication.rs | 5 + src/services/user.rs | 9 + .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 5 + .../databases/sqlite_v1_0_0.rs | 5 + .../databases/sqlite_v2_0_0.rs | 15 + .../transferrers/torrent_transferrer.rs | 2 +- src/utils/clock.rs | 6 + src/utils/regex.rs | 5 + src/web/api/v1/auth.rs | 4 + src/web/api/v1/contexts/proxy/handlers.rs | 4 +- src/web/api/v1/contexts/torrent/handlers.rs | 20 +- tests/environments/isolated.rs | 4 + .../torrent_transferrer_tester.rs | 2 +- 23 files changed, 474 insertions(+), 392 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89f68468..b688e8ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,19 +4,19 @@ version = 3 [[package]] name = "actix-codec" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a7559404a7f3573127aab53c08ce37a6c6a315c374a31070f3c91cd1b4a7fe" +checksum = "617a8268e3537fe1d8c9ead925fca49ef6400927ee7bc26750e90ecee14ce4b8" dependencies = [ - "bitflags", + "bitflags 1.3.2", "bytes", "futures-core", "futures-sink", - "log", "memchr", "pin-project-lite", "tokio", "tokio-util", + "tracing", ] [[package]] @@ -45,8 +45,8 @@ dependencies = [ "actix-service", "actix-utils", "ahash 0.8.3", - "base64 0.21.0", - "bitflags", + "base64 0.21.2", + "bitflags 1.3.2", "brotli", "bytes", "bytestring", @@ -236,6 +236,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "addr2line" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +dependencies = [ + "gimli", +] + [[package]] name = "adler" version = "1.0.2" @@ -267,9 +276,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" dependencies = [ "memchr", ] @@ -289,6 +298,18 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56fc6cf8dc8c4158eed8649f9b8b0ea1518eb62b544fe9490d66fa0b349eafe9" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -323,19 +344,19 @@ checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" [[package]] name = "arrayvec" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "async-trait" -version = "0.1.68" +version = "0.1.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" +checksum = "7b2d0f03b3640e3a630367e40c468cb7f309529c708ed1d88597047b0e7c6ef7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.23", ] [[package]] @@ -372,7 +393,7 @@ checksum = "f8175979259124331c1d7bf6586ee7e0da434155e4b2d48ec2c8386281d8df39" dependencies = [ "async-trait", "axum-core", - "bitflags", + "bitflags 1.3.2", "bytes", "futures-util", "http", @@ -414,6 +435,21 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backtrace" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + [[package]] name = "base64" version = "0.13.1" @@ -422,9 +458,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" +checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" [[package]] name = "base64ct" @@ -444,6 +480,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + [[package]] name = "blake2" version = "0.10.6" @@ -485,9 +527,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.12.1" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b1ce199063694f33ffb7dd4e0ee620741495c32833cde5aa08f02a0bf96f0c8" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" [[package]] name = "bytemuck" @@ -533,26 +575,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.24" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ + "android-tzdata", "iana-time-zone", - "num-integer", "num-traits", "winapi", ] -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - [[package]] name = "colored" version = "2.0.0" @@ -624,9 +656,9 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" +checksum = "03e69e28e9f7f77debdedbaafa2866e1de9ba56df55a8bd7cfc724c25a09987c" dependencies = [ "libc", ] @@ -667,9 +699,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.15" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" dependencies = [ "cfg-if", ] @@ -694,50 +726,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cxx" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61f1b6389c3fe1c316bf8a4dccc90a38208354b330925bce1f74a6c4756eb93" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cee708e8962df2aeb38f594aae5d827c022b6460ac71a7a3e2c3c2aae5a07b" -dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn 2.0.15", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7944172ae7e4068c533afbb984114a56c46e9ccddda550499caa222902c7f7bb" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.15", -] - [[package]] name = "darling" version = "0.14.4" @@ -819,9 +807,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", @@ -852,7 +840,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" dependencies = [ - "base64 0.21.0", + "base64 0.21.2", "memchr", ] @@ -871,6 +859,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1" + [[package]] name = "errno" version = "0.3.1" @@ -939,12 +933,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" +checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" dependencies = [ "crc32fast", - "miniz_oxide 0.6.2", + "miniz_oxide", ] [[package]] @@ -999,9 +993,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" dependencies = [ "percent-encoding", ] @@ -1073,7 +1067,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.23", ] [[package]] @@ -1118,20 +1112,26 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", "libc", "wasi", ] +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + [[package]] name = "h2" -version = "0.3.18" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f8a914c2987b688368b5138aa05321db91f4090cf26118185672ad588bce21" +checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" dependencies = [ "bytes", "fnv", @@ -1139,7 +1139,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap", + "indexmap 1.9.3", "slab", "tokio", "tokio-util", @@ -1155,13 +1155,23 @@ dependencies = [ "ahash 0.7.6", ] +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +dependencies = [ + "ahash 0.8.3", + "allocator-api2", +] + [[package]] name = "hashlink" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" +checksum = "312f66718a2d7789ffef4f4b7b213138ed9f1eb3aa1d0d82fc99f88fb3ffd26f" dependencies = [ - "hashbrown", + "hashbrown 0.14.0", ] [[package]] @@ -1182,15 +1192,6 @@ dependencies = [ "libc", ] -[[package]] -name = "hermit-abi" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.3.1" @@ -1274,9 +1275,9 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "hyper" -version = "0.14.26" +version = "0.14.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" dependencies = [ "bytes", "futures-channel", @@ -1311,9 +1312,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.56" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1325,12 +1326,11 @@ dependencies = [ [[package]] name = "iana-time-zone-haiku" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "cxx", - "cxx-build", + "cc", ] [[package]] @@ -1349,6 +1349,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1356,7 +1366,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", ] [[package]] @@ -1370,9 +1390,9 @@ dependencies = [ [[package]] name = "io-lifetimes" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" dependencies = [ "hermit-abi 0.3.1", "libc", @@ -1381,9 +1401,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.7.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" +checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" [[package]] name = "itertools" @@ -1396,9 +1416,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +checksum = "c0aa48fab2893d8a49caa94082ae8488f4e1050d73b367881dcd2198f4199fd8" [[package]] name = "itoap" @@ -1423,9 +1443,9 @@ checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" [[package]] name = "js-sys" -version = "0.3.61" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" dependencies = [ "wasm-bindgen", ] @@ -1447,7 +1467,7 @@ version = "8.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ - "base64 0.21.0", + "base64 0.21.2", "pem", "ring", "serde", @@ -1461,7 +1481,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a53776d271cfb873b17c618af0298445c88afc52837f3e948fa3fafd131f449" dependencies = [ - "arrayvec 0.7.2", + "arrayvec 0.7.4", ] [[package]] @@ -1486,7 +1506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d" dependencies = [ "async-trait", - "base64 0.21.0", + "base64 0.21.2", "email-encoding", "email_address", "fastrand", @@ -1494,7 +1514,7 @@ dependencies = [ "futures-util", "hostname", "httpdate", - "idna", + "idna 0.3.0", "mime", "native-tls", "nom", @@ -1511,15 +1531,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.142" +version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" [[package]] name = "libm" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" [[package]] name = "libsqlite3-sys" @@ -1532,15 +1552,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "link-cplusplus" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" -dependencies = [ - "cc", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1549,9 +1560,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e8776872cdc2f073ccaab02e336fa321328c1e02646ebcb9d2108d0baab480d" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "local-channel" @@ -1573,9 +1584,9 @@ checksum = "e34f76eb3611940e0e7d53a9aaa4e6a3151f69541a282fd0dad5571420c53ff1" [[package]] name = "lock_api" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" dependencies = [ "autocfg", "scopeguard", @@ -1583,12 +1594,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" [[package]] name = "match_cfg" @@ -1645,15 +1653,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" -dependencies = [ - "adler", -] - [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1666,14 +1665,14 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.45.0", + "windows-sys 0.48.0", ] [[package]] @@ -1735,9 +1734,9 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2399c9463abc5f909349d8aa9ba080e0b88b3ce2885389b60b993f39b1a56905" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" dependencies = [ "byteorder", "lazy_static", @@ -1783,27 +1782,36 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi 0.3.1", "libc", ] +[[package]] +name = "object" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" -version = "1.17.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "openssl" -version = "0.10.52" +version = "0.10.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b8574602df80f7b85fdfc5392fa884a4e3b3f4f35402c070ab34c3d3f78d56" +checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cfg-if", "foreign-types", "libc", @@ -1820,7 +1828,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.23", ] [[package]] @@ -1831,9 +1839,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.87" +version = "0.9.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e17f59264b2809d77ae94f0e1ebabc434773f370d6ca667bd223ea10e06cc7e" +checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6" dependencies = [ "cc", "libc", @@ -1848,7 +1856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" dependencies = [ "dlv-list", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -1869,7 +1877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", - "parking_lot_core 0.9.7", + "parking_lot_core 0.9.8", ] [[package]] @@ -1888,15 +1896,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.2.16", + "redox_syscall 0.3.5", "smallvec", - "windows-sys 0.45.0", + "windows-targets", ] [[package]] @@ -1960,15 +1968,15 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "pest" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e68e84bfb01f0507134eac1e9b410a12ba379d064eab48c50ba4ce329a527b70" +checksum = "f73935e4d55e2abf7f130186537b19e7a4abc886a0252380b59248af473a3fc9" dependencies = [ "thiserror", "ucd-trie", @@ -1976,9 +1984,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b79d4c71c865a25a4322296122e3924d30bc8ee0834c8bfc8b95f7f054afbfb" +checksum = "aef623c9bbfa0eedf5a0efba11a5ee83209c326653ca31ff019bec3a95bfff2b" dependencies = [ "pest", "pest_generator", @@ -1986,22 +1994,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c435bf1076437b851ebc8edc3a18442796b30f1728ffea6262d59bbe28b077e" +checksum = "b3e8cba4ec22bada7fc55ffe51e2deb6a0e0db2d0b7ab0b103acc80d2510c190" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.23", ] [[package]] name = "pest_meta" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "745a452f8eb71e39ffd8ee32b3c5f51d03845f99786fa9b68db6ff509c505411" +checksum = "a01f71cb40bd8bb94232df14b946909e14660e33fc05db3e50ae2a82d7ea0ca0" dependencies = [ "once_cell", "pest", @@ -2016,29 +2024,29 @@ checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468" [[package]] name = "pin-project" -version = "1.0.12" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +checksum = "030ad2bc4db10a8944cb0d837f158bdfec4d4a4873ab701a95046770d11f8842" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.0.12" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.23", ] [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" [[package]] name = "pin-utils" @@ -2070,21 +2078,21 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" [[package]] name = "png" -version = "0.17.8" +version = "0.17.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaeebc51f9e7d2c150d3f3bfeb667f2aa985db5ef1e3d212847bdedb488beeaa" +checksum = "59871cc5b6cce7eaccca5a802b4173377a1c2ba90654246789a8fa2334426d11" dependencies = [ - "bitflags", + "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide 0.7.1", + "miniz_oxide", ] [[package]] @@ -2095,27 +2103,27 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.56" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" +checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.26" +version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" dependencies = [ "proc-macro2", ] [[package]] name = "quoted_printable" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a24039f627d8285853cc90dcddf8c1ebfaa91f834566948872b225b9a28ed1b6" +checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49" [[package]] name = "rand" @@ -2159,7 +2167,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -2168,14 +2176,14 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] name = "regex" -version = "1.8.1" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" +checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" dependencies = [ "aho-corasick", "memchr", @@ -2184,17 +2192,17 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" +checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" [[package]] name = "reqwest" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b71749df584b7f4cac2c426c127a7c785a5106cc98f7a8feb044115f0fa254" +checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" dependencies = [ - "base64 0.21.0", + "base64 0.21.2", "bytes", "encoding_rs", "futures-core", @@ -2272,7 +2280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" dependencies = [ "base64 0.13.1", - "bitflags", + "bitflags 1.3.2", "serde", ] @@ -2315,6 +2323,12 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + [[package]] name = "rustc_version" version = "0.4.0" @@ -2326,11 +2340,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.37.15" +version = "0.37.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0661814f891c57c930a610266415528da53c4933e6dea5fb350cbfe048a9ece" +checksum = "8818fa822adcc98b18fedbb3632a6a33213c070556b5aa7c4c8cc21cff565c4c" dependencies = [ - "bitflags", + "bitflags 1.3.2", "errno", "io-lifetimes", "libc", @@ -2340,9 +2354,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07180898a28ed6a7f7ba2311594308f595e3dd2e3c3812fa0a80a47b45f17e5d" +checksum = "e32ca28af694bc1bbf399c33a516dbdf1c90090b8ab23c2bc24f834aa2247f5f" dependencies = [ "log", "ring", @@ -2352,11 +2366,11 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" +checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" dependencies = [ - "base64 0.21.0", + "base64 0.21.2", ] [[package]] @@ -2381,7 +2395,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44561062e583c4873162861261f16fd1d85fe927c4904d71329a4fe43dc355ef" dependencies = [ - "bitflags", + "bitflags 1.3.2", "bytemuck", "smallvec", "ttf-parser", @@ -2430,8 +2444,8 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.15", - "toml 0.7.3", + "syn 2.0.23", + "toml 0.7.5", ] [[package]] @@ -2459,12 +2473,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" -[[package]] -name = "scratch" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" - [[package]] name = "sct" version = "0.7.0" @@ -2477,11 +2485,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.8.2" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254" +checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation", "core-foundation-sys", "libc", @@ -2490,9 +2498,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4" +checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" dependencies = [ "core-foundation-sys", "libc", @@ -2506,9 +2514,9 @@ checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" [[package]] name = "serde" -version = "1.0.160" +version = "1.0.164" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" +checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" dependencies = [ "serde_derive", ] @@ -2534,20 +2542,20 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.160" +version = "1.0.164" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291a097c63d8497e00160b166a967a4a79c64f3facdd01cbd7502231688d77df" +checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.23", ] [[package]] name = "serde_json" -version = "1.0.96" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3" dependencies = [ "itoa", "ryu", @@ -2556,10 +2564,11 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" +checksum = "0b1b6471d7496b051e03f1958802a73f88b947866f5146f329e47e36554f4e55" dependencies = [ + "itoa", "serde", ] @@ -2574,9 +2583,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" dependencies = [ "serde", ] @@ -2617,9 +2626,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" dependencies = [ "cfg-if", "cpufeatures", @@ -2747,7 +2756,7 @@ checksum = "fa8241483a83a3f33aa5fff7e7d9def398ff9990b2752b6c6112b83c6d246029" dependencies = [ "ahash 0.7.6", "atoi", - "bitflags", + "bitflags 1.3.2", "byteorder", "bytes", "crc", @@ -2765,7 +2774,7 @@ dependencies = [ "generic-array", "hashlink", "hex", - "indexmap", + "indexmap 1.9.3", "itoa", "libc", "libsqlite3-sys", @@ -2838,9 +2847,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "svgtypes" @@ -2864,9 +2873,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.15" +version = "2.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822" +checksum = "59fb7d6d8281a51045d62b8eb3a7d1ce347b76f312af50cd3dc0af39c87c1737" dependencies = [ "proc-macro2", "quote", @@ -2881,24 +2890,16 @@ checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "tempfile" -version = "3.5.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" +checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" dependencies = [ + "autocfg", "cfg-if", "fastrand", "redox_syscall 0.3.5", "rustix", - "windows-sys 0.45.0", -] - -[[package]] -name = "termcolor" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" -dependencies = [ - "winapi-util", + "windows-sys 0.48.0", ] [[package]] @@ -2945,14 +2946,14 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.23", ] [[package]] name = "time" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" +checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" dependencies = [ "itoa", "serde", @@ -2962,15 +2963,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" [[package]] name = "time-macros" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36" +checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" dependencies = [ "time-core", ] @@ -3006,11 +3007,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.28.0" +version = "1.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c786bf8134e5a3a166db9b29ab8f48134739014a3eca7bc6bfa95d673b136f" +checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" dependencies = [ "autocfg", + "backtrace", "bytes", "libc", "mio", @@ -3031,7 +3033,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.15", + "syn 2.0.23", ] [[package]] @@ -3046,9 +3048,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ "rustls", "tokio", @@ -3090,9 +3092,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b403acf6f2bb0859c93c7f0d967cb4a75a7ac552100f9322faf64dc047669b21" +checksum = "1ebafdf5ad1220cb59e7d17cf4d2c72015297b75b19a10472f99b89225089240" dependencies = [ "serde", "serde_spanned", @@ -3102,20 +3104,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.19.8" +version = "0.19.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" +checksum = "266f016b7f039eec8a1a80dfe6156b633d208b9fccca5e4db1d6775b0c4e34a7" dependencies = [ - "indexmap", + "indexmap 2.0.0", "serde", "serde_spanned", "toml_datetime", @@ -3140,7 +3142,7 @@ dependencies = [ "fern", "futures", "hyper", - "indexmap", + "indexmap 1.9.3", "jsonwebtoken", "lettre", "log", @@ -3162,7 +3164,7 @@ dependencies = [ "text-to-png", "thiserror", "tokio", - "toml 0.7.3", + "toml 0.7.5", "tower-http", "urlencoding", "uuid", @@ -3187,11 +3189,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" +checksum = "a8bd22a874a2d0b70452d5597b12c537331d49060824a95f49f108994f94aa4c" dependencies = [ - "bitflags", + "bitflags 2.3.3", "bytes", "futures-core", "futures-util", @@ -3217,10 +3219,11 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.38" +version = "0.1.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9cf6a813d3f40c88b0b6b6f29a5c95c6cdbf97c1f9cc53fb820200f5ad814d" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" dependencies = [ + "cfg-if", "log", "pin-project-lite", "tracing-core", @@ -3228,9 +3231,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" dependencies = [ "once_cell", ] @@ -3294,9 +3297,9 @@ checksum = "07547e3ee45e28326cc23faac56d44f58f16ab23e413db526debce3b0bfd2742" [[package]] name = "unicode-ident" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" +checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" [[package]] name = "unicode-normalization" @@ -3325,12 +3328,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -3345,12 +3342,12 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "url" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" dependencies = [ "form_urlencoded", - "idna", + "idna 0.4.0", "percent-encoding", ] @@ -3389,9 +3386,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b55a3fef2a1e3b3a00ce878640918820d3c51081576ac657d23af9fc7928fdb" +checksum = "d023da39d1fde5a8a3fe1f3e01ca9632ada0a63e9797de55a879d6e2236277be" dependencies = [ "getrandom", ] @@ -3410,11 +3407,10 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "want" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "log", "try-lock", ] @@ -3426,9 +3422,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3436,24 +3432,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.23", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.34" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454" +checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" dependencies = [ "cfg-if", "js-sys", @@ -3463,9 +3459,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3473,28 +3469,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.23", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.84" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" [[package]] name = "web-sys" -version = "0.3.61" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" dependencies = [ "js-sys", "wasm-bindgen", @@ -3502,9 +3498,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa54963694b65584e170cf5dc46aeb4dcaa5584e652ff5f3952e56d66aff0125" +checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" dependencies = [ "rustls-webpki", ] @@ -3536,15 +3532,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3557,7 +3544,7 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "windows-targets 0.48.0", + "windows-targets", ] [[package]] @@ -3575,44 +3562,20 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.48.0", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-targets", ] [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" dependencies = [ "windows_aarch64_gnullvm 0.48.0", "windows_aarch64_msvc 0.48.0", @@ -3709,9 +3672,9 @@ checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" [[package]] name = "winnow" -version = "0.4.1" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28" +checksum = "ca0ace3845f0d96209f0375e6d367e3eb87eb65d27d445bdc9f1843a26f39448" dependencies = [ "memchr", ] @@ -3727,9 +3690,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.4" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" +checksum = "5a56c84a8ccd4258aed21c92f70c0f6dea75356b6892ae27c24139da456f9336" [[package]] name = "xmlparser" diff --git a/src/app.rs b/src/app.rs index e0e263ef..5bc096b2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,6 +28,11 @@ pub struct Running { pub tracker_data_importer_handle: tokio::task::JoinHandle<()>, } +/// Runs the application. +/// +/// # Panics +/// +/// It panics if there is an error connecting to the database. #[allow(clippy::too_many_lines)] pub async fn run(configuration: Configuration, api_version: &Version) -> Running { let log_level = configuration.settings.read().await.log_level.clone(); diff --git a/src/cache/image/manager.rs b/src/cache/image/manager.rs index 40367ca9..24a7e771 100644 --- a/src/cache/image/manager.rs +++ b/src/cache/image/manager.rs @@ -19,6 +19,11 @@ pub enum Error { type UserQuotas = HashMap; +/// Returns the current time in seconds. +/// +/// # Panics +/// +/// This function will panic if the current time is before the UNIX EPOCH. #[must_use] pub fn now_in_secs() -> u64 { SystemTime::now() @@ -87,6 +92,11 @@ pub struct ImageCacheService { } impl ImageCacheService { + /// Create a new image cache service. + /// + /// # Panics + /// + /// This function will panic if the image cache could not be created. pub async fn new(cfg: Arc) -> Self { let settings = cfg.settings.read().await; diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 1696cdb8..a2a9bd81 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -98,7 +98,7 @@ impl BytesCache { pub fn total_size(&self) -> usize { let mut size: usize = 0; - for (_, entry) in self.bytes_table.iter() { + for (_, entry) in &self.bytes_table { size += entry.bytes.len(); } diff --git a/src/config.rs b/src/config.rs index d75d6f6e..5a9f1713 100644 --- a/src/config.rs +++ b/src/config.rs @@ -362,6 +362,10 @@ impl Configuration { } /// Returns the save to file of this [`Configuration`]. + /// + /// # Panics + /// + /// This function will panic if it can't write to the file. pub async fn save_to_file(&self, config_path: &str) { let settings = self.settings.read().await; diff --git a/src/console/commands/import_tracker_statistics.rs b/src/console/commands/import_tracker_statistics.rs index 579cca0c..8d0c111b 100644 --- a/src/console/commands/import_tracker_statistics.rs +++ b/src/console/commands/import_tracker_statistics.rs @@ -70,6 +70,11 @@ fn print_usage() { ); } +/// Import Tracker Statistics Command +/// +/// # Panics +/// +/// Panics if arguments cannot be parsed. pub async fn run_importer() { parse_args().expect("unable to parse command arguments"); import().await; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index bd4c6b48..c06784e4 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -329,7 +329,7 @@ impl Database for Mysql { let category_filter_query = if let Some(c) = categories { let mut i = 0; let mut category_filters = String::new(); - for category in c.iter() { + for category in c { // don't take user input in the db query if let Ok(sanitized_category) = self.get_category_from_name(category).await { let mut str = format!("tc.name = '{}'", sanitized_category.name); @@ -352,7 +352,7 @@ impl Database for Mysql { let tag_filter_query = if let Some(t) = tags { let mut i = 0; let mut tag_filters = String::new(); - for tag in t.iter() { + for tag in t { // don't take user input in the db query if let Ok(sanitized_tag) = self.get_tag_from_name(tag).await { let mut str = format!("tl.tag_id = '{}'", sanitized_tag.tag_id); @@ -479,7 +479,7 @@ impl Database for Mysql { } else { let files = torrent.info.files.as_ref().unwrap(); - for file in files.iter() { + for file in files { let path = file.path.join("/"); let _ = query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, length, path) VALUES (?, ?, ?, ?)") diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index b1cfc89e..3ecc0a50 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -319,7 +319,7 @@ impl Database for Sqlite { let category_filter_query = if let Some(c) = categories { let mut i = 0; let mut category_filters = String::new(); - for category in c.iter() { + for category in c { // don't take user input in the db query if let Ok(sanitized_category) = self.get_category_from_name(category).await { let mut str = format!("tc.name = '{}'", sanitized_category.name); @@ -342,7 +342,7 @@ impl Database for Sqlite { let tag_filter_query = if let Some(t) = tags { let mut i = 0; let mut tag_filters = String::new(); - for tag in t.iter() { + for tag in t { // don't take user input in the db query if let Ok(sanitized_tag) = self.get_tag_from_name(tag).await { let mut str = format!("tl.tag_id = '{}'", sanitized_tag.tag_id); @@ -469,7 +469,7 @@ impl Database for Sqlite { } else { let files = torrent.info.files.as_ref().unwrap(); - for file in files.iter() { + for file in files { let path = file.path.join("/"); let _ = query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, length, path) VALUES (?, ?, ?, ?)") diff --git a/src/mailer.rs b/src/mailer.rs index e55f26f9..3ef83e0d 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -58,11 +58,15 @@ impl Service { } } - /// Send Verification Email + /// Send Verification Email. /// /// # Errors /// /// This function will return an error if unable to send an email. + /// + /// # Panics + /// + /// This function will panic if the multipart builder had an error. pub async fn send_verification_mail( &self, to: &str, diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 801aa1c6..5a21c68a 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -50,6 +50,12 @@ impl TorrentInfo { } } + /// It returns the root hash as a `i64` value. + /// + /// # Panics + /// + /// This function will panic if the root hash cannot be converted into a + /// `i64` value. #[must_use] pub fn get_root_hash_as_i64(&self) -> i64 { match &self.root_hash { @@ -96,6 +102,11 @@ pub struct Torrent { } impl Torrent { + /// It hydrates a `Torrent` struct from the database data. + /// + /// # Panics + /// + /// This function will panic if the `torrent_info.pieces` is not a valid hex string. #[must_use] pub fn from_db_info_files_and_announce_urls( torrent_info: DbTorrentInfo, @@ -180,6 +191,11 @@ impl Torrent { } } + /// It calculates the info hash of the torrent file. + /// + /// # Panics + /// + /// This function will panic if the `info` part of the torrent file cannot be serialized. #[must_use] pub fn calculate_info_hash_as_bytes(&self) -> [u8; 20] { let info_bencoded = ser::to_bytes(&self.info).expect("variable `info` was not able to be serialized."); @@ -204,7 +220,7 @@ impl Torrent { None => 0, Some(files) => { let mut file_size = 0; - for file in files.iter() { + for file in files { file_size += file.length; } file_size @@ -213,6 +229,11 @@ impl Torrent { } } + /// It returns the announce urls of the torrent file. + /// + /// # Panics + /// + /// This function will panic if both the `announce_list` and the `announce` are `None`. #[must_use] pub fn announce_urls(&self) -> Vec { match &self.announce_list { diff --git a/src/services/authentication.rs b/src/services/authentication.rs index ccc0685f..e04342a4 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -120,6 +120,11 @@ impl JsonWebToken { } /// Create Json Web Token. + /// + /// # Panics + /// + /// This function will panic if the default encoding algorithm does not ç + /// match the encoding key. pub async fn sign(&self, user: UserCompact) -> String { let settings = self.cfg.settings.read().await; diff --git a/src/services/user.rs b/src/services/user.rs index a0211546..fd1be257 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -61,6 +61,10 @@ impl RegistrationService { /// * `ServiceError::FailedToSendVerificationEmail` if unable to send the required verification email. /// * An error if unable to successfully hash the password. /// * An error if unable to insert user into the database. + /// + /// # Panics + /// + /// This function will panic if the email is required, but missing. pub async fn register_user(&self, registration_form: &RegistrationForm, api_base_url: &str) -> Result { info!("registering user: {}", registration_form.username); @@ -328,6 +332,11 @@ impl DbBannedUserList { /// # Errors /// /// It returns an error if there is a database error. + /// + /// # Panics + /// + /// It panics if the expiration date cannot be parsed. It should never + /// happen as the date is hardcoded for now. pub async fn add(&self, user_id: &UserId) -> Result<(), Error> { // todo: add reason and `date_expiry` parameters to request. diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs index a5f8b0e9..44af94f9 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs @@ -21,6 +21,11 @@ pub async fn migrate_target_database(target_database: Arc) target_database.migrate().await; } +/// It truncates all tables in the target database. +/// +/// # Panics +/// +/// It panics if it cannot truncate the tables. pub async fn truncate_target_database(target_database: Arc) { println!("Truncating all tables in target database ..."); target_database diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index ae15a037..f1a410d1 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -58,6 +58,11 @@ pub struct SqliteDatabaseV1_0_0 { } impl SqliteDatabaseV1_0_0 { + /// It creates a new instance of the `SqliteDatabaseV1_0_0`. + /// + /// # Panics + /// + /// This function will panic if it is unable to create the database pool. pub async fn new(database_url: &str) -> Self { let db = SqlitePoolOptions::new() .connect(database_url) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 065d6306..8fbf3aa2 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -49,6 +49,11 @@ impl TorrentRecordV2 { } } +/// It converts a timestamp in seconds to a datetime string. +/// +/// # Panics +/// +/// It panics if the timestamp is too big and it overflows i64. Very future! #[must_use] pub fn convert_timestamp_to_datetime(timestamp: i64) -> String { // The expected format in database is: 2022-11-04 09:53:57 @@ -66,6 +71,11 @@ pub struct SqliteDatabaseV2_0_0 { } impl SqliteDatabaseV2_0_0 { + /// Creates a new instance of the database. + /// + /// # Panics + /// + /// It panics if it cannot create the database pool. pub async fn new(database_url: &str) -> Self { let db = SqlitePoolOptions::new() .connect(database_url) @@ -74,6 +84,11 @@ impl SqliteDatabaseV2_0_0 { Self { pool: db } } + /// It migrates the database to the latest version. + /// + /// # Panics + /// + /// It panics if it cannot run the migrations. pub async fn migrate(&self) { sqlx::migrate!("migrations/sqlite3") .run(&self.pool) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs index 5e6f9656..271331e4 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs @@ -96,7 +96,7 @@ pub async fn transfer_torrents( // Multiple files are being shared let files = torrent_from_file.info.files.as_ref().unwrap(); - for file in files.iter() { + for file in files { println!( "[v2][torrust_torrent_files][multiple-file-torrent] adding torrent file: {:?} ...", &file diff --git a/src/utils/clock.rs b/src/utils/clock.rs index 4c4f0bf0..b17ee48b 100644 --- a/src/utils/clock.rs +++ b/src/utils/clock.rs @@ -1,3 +1,9 @@ +/// Returns the current timestamp in seconds. +/// +/// # Panics +/// +/// This function should never panic unless the current timestamp from the +/// time library is negative, which should never happen. #[must_use] pub fn now() -> u64 { u64::try_from(chrono::prelude::Utc::now().timestamp()).expect("timestamp should be positive") diff --git a/src/utils/regex.rs b/src/utils/regex.rs index f423fdaf..356c315d 100644 --- a/src/utils/regex.rs +++ b/src/utils/regex.rs @@ -1,5 +1,10 @@ use regex::Regex; +/// Validates an email address. +/// +/// # Panics +/// +/// It panics if the regex fails to compile. #[must_use] pub fn validate_email_address(email_address_to_be_checked: &str) -> bool { let email_regex = Regex::new(r"^([a-z\d_+]([a-z\d_+.]*[a-z\d_+])?)@([a-z\d]+([\-.][a-z\d]+)*\.[a-z]{2,6})") diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs index f98436e3..e52542cc 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/v1/auth.rs @@ -142,6 +142,10 @@ impl Authentication { } /// Parses the token from the `Authorization` header. +/// +/// # Panics +/// +/// This function will panic if the `Authorization` header is not a valid `String`. pub fn parse_token(authorization: &HeaderValue) -> String { let split: Vec<&str> = authorization .to_str() diff --git a/src/web/api/v1/contexts/proxy/handlers.rs b/src/web/api/v1/contexts/proxy/handlers.rs index d31e112a..b1188735 100644 --- a/src/web/api/v1/contexts/proxy/handlers.rs +++ b/src/web/api/v1/contexts/proxy/handlers.rs @@ -22,7 +22,9 @@ pub async fn get_proxy_image_handler( return png_image(map_error_to_image(&Error::Unauthenticated)); } - let Ok(user_id) = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await else { return png_image(map_error_to_image(&Error::Unauthenticated)) }; + let Ok(user_id) = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await else { + return png_image(map_error_to_image(&Error::Unauthenticated)); + }; // code-review: Handling status codes in the frontend other tan OK is quite a pain. // Return OK for now. diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index dd728db9..7da3e385 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -74,7 +74,9 @@ pub async fn download_torrent_handler( Extract(maybe_bearer_token): Extract, Path(info_hash): Path, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { + return ServiceError::BadRequest.into_response(); + }; let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { Ok(opt_user_id) => opt_user_id, @@ -86,7 +88,9 @@ pub async fn download_torrent_handler( Err(error) => return error.into_response(), }; - let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { return ServiceError::InternalServerError.into_response() }; + let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { + return ServiceError::InternalServerError.into_response(); + }; torrent_file_response(bytes) } @@ -120,7 +124,9 @@ pub async fn get_torrent_info_handler( Extract(maybe_bearer_token): Extract, Path(info_hash): Path, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { + return ServiceError::BadRequest.into_response(); + }; let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { Ok(opt_user_id) => opt_user_id, @@ -149,7 +155,9 @@ pub async fn update_torrent_info_handler( Path(info_hash): Path, extract::Json(update_torrent_info_form): extract::Json, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { + return ServiceError::BadRequest.into_response(); + }; let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { Ok(user_id) => user_id, @@ -188,7 +196,9 @@ pub async fn delete_torrent_handler( Extract(maybe_bearer_token): Extract, Path(info_hash): Path, ) -> Response { - let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { return ServiceError::BadRequest.into_response() }; + let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { + return ServiceError::BadRequest.into_response(); + }; let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { Ok(user_id) => user_id, diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index 96a7179b..0977f2bb 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -24,6 +24,10 @@ impl TestEnv { /// Provides a test environment with a default configuration for testing /// application. + /// + /// # Panics + /// + /// Panics if the temporary directory cannot be created. #[must_use] pub fn with_test_configuration() -> Self { let temp_dir = TempDir::new().expect("failed to create a temporary directory"); diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs index ecc3511c..e23a668b 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs @@ -167,7 +167,7 @@ impl TorrentTester { let files = torrent_file.info.files.as_ref().unwrap(); // Files in torrent file - for file in files.iter() { + for file in files { let file_path = file.path.join("/"); // Find file in database From a534e38c92d6c1ed2fd091c93d495f21ab3fb5c9 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 3 Jul 2023 11:58:33 +0100 Subject: [PATCH 255/357] fix: [#230] make sure user exist in upload torrent endpoint We get the user ID from the Json Web Token, but that does not mean the user actually exists. The session could be valid, but the user could have been removed from the database. --- src/services/torrent.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index c56dc9a3..5d12e119 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -99,6 +99,8 @@ impl Index { /// * Unable to insert the torrent into the database. /// * Unable to add the torrent to the whitelist. pub async fn add_torrent(&self, mut torrent_request: AddTorrentRequest, user_id: UserId) -> Result { + let _user = self.user_repository.get_compact(&user_id).await?; + torrent_request.torrent.set_announce_urls(&self.configuration).await; let category = self From db1612c3222261a5a9c0ee35fa08116fdd5dd85a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 3 Jul 2023 15:32:30 +0100 Subject: [PATCH 256/357] ci: add upload coverage report to codecov --- .github/workflows/codecov.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/codecov.yml diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 00000000..7ed08b73 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,40 @@ +name: Upload Code Coverage + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + components: rustfmt, llvm-tools-preview + - name: Build + run: cargo build --release + env: + CARGO_INCREMENTAL: "0" + RUSTFLAGS: "-Cinstrument-coverage" + RUSTDOCFLAGS: "-Cinstrument-coverage" + - name: Test + run: cargo test --all-features --no-fail-fast + env: + CARGO_INCREMENTAL: "0" + RUSTFLAGS: "-Cinstrument-coverage" + RUSTDOCFLAGS: "-Cinstrument-coverage" + - name: Install grcov + run: if [[ ! -e ~/.cargo/bin/grcov ]]; then cargo install grcov; fi + - name: Run grcov + run: grcov . --binary-path target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../**' --ignore '/*' -o coverage.lcov + - uses: codecov/codecov-action@v3 + with: + files: ./coverage.lcov + flags: rust + fail_ci_if_error: true # optional (default = false) \ No newline at end of file From b06942129490dc2511d12af3c39b93fb4ff7fa7e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 3 Jul 2023 15:34:19 +0100 Subject: [PATCH 257/357] chore: normalize workflow names --- .github/workflows/codecov.yml | 4 ++++ .github/workflows/publish_crate.yml | 2 +- .github/workflows/test_docker.yml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 7ed08b73..4f8fb02a 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -23,6 +23,8 @@ jobs: CARGO_INCREMENTAL: "0" RUSTFLAGS: "-Cinstrument-coverage" RUSTDOCFLAGS: "-Cinstrument-coverage" + - name: Install torrent edition tool (needed for testing) + run: cargo install imdl - name: Test run: cargo test --all-features --no-fail-fast env: @@ -34,6 +36,8 @@ jobs: - name: Run grcov run: grcov . --binary-path target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../**' --ignore '/*' -o coverage.lcov - uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: files: ./coverage.lcov flags: rust diff --git a/.github/workflows/publish_crate.yml b/.github/workflows/publish_crate.yml index 1d104d72..c41fb549 100644 --- a/.github/workflows/publish_crate.yml +++ b/.github/workflows/publish_crate.yml @@ -1,4 +1,4 @@ -name: Publish crate +name: Publish Crate on: push: diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 30da662d..efb54e60 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -1,4 +1,4 @@ -name: Test Docker build +name: Test Docker Build on: push: From 734e6cb3a9ca184a73bb349d15d9734add49c39e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 7 Jul 2023 17:09:12 +0100 Subject: [PATCH 258/357] feat: add cargo dependency: email_address To validate emails. --- Cargo.lock | 4 ++++ Cargo.toml | 1 + 2 files changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b688e8ce..107b960b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,6 +849,9 @@ name = "email_address" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -3139,6 +3142,7 @@ dependencies = [ "chrono", "config", "derive_more", + "email_address", "fern", "futures", "hyper", diff --git a/Cargo.toml b/Cargo.toml index d30c00e9..726bce55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ binascii = "0.1" axum = { version = "0.6.18", features = ["multipart"] } hyper = "0.14.26" tower-http = { version = "0.4.0", features = ["cors"] } +email_address = "0.2.4" [dev-dependencies] rand = "0.8" From e627ef99cc935d241bf54aaec8b20578329f6f1a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 7 Jul 2023 18:18:03 +0100 Subject: [PATCH 259/357] refactor: use a third party package for email valildation --- project-words.txt | 2 ++ src/services/user.rs | 2 +- src/utils/mod.rs | 2 +- src/utils/regex.rs | 38 -------------------- src/utils/validation.rs | 79 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 40 deletions(-) delete mode 100644 src/utils/regex.rs create mode 100644 src/utils/validation.rs diff --git a/project-words.txt b/project-words.txt index b770bb51..59fe8833 100644 --- a/project-words.txt +++ b/project-words.txt @@ -15,11 +15,13 @@ Cyberneering datetime DATETIME Dont +dotless Grünwald hasher Hasher hexlify httpseeds +ICANN imagoodboy imdl indexadmin diff --git a/src/services/user.rs b/src/services/user.rs index fd1be257..f8a25b93 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -13,7 +13,7 @@ use crate::errors::ServiceError; use crate::mailer; use crate::mailer::VerifyClaims; use crate::models::user::{UserCompact, UserId, UserProfile}; -use crate::utils::regex::validate_email_address; +use crate::utils::validation::validate_email_address; use crate::web::api::v1::contexts::user::forms::RegistrationForm; /// Since user email could be optional, we need a way to represent "no email" diff --git a/src/utils/mod.rs b/src/utils/mod.rs index cf6e4d3a..ebb62358 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ pub mod clock; pub mod hex; pub mod parse_torrent; -pub mod regex; +pub mod validation; diff --git a/src/utils/regex.rs b/src/utils/regex.rs deleted file mode 100644 index 356c315d..00000000 --- a/src/utils/regex.rs +++ /dev/null @@ -1,38 +0,0 @@ -use regex::Regex; - -/// Validates an email address. -/// -/// # Panics -/// -/// It panics if the regex fails to compile. -#[must_use] -pub fn validate_email_address(email_address_to_be_checked: &str) -> bool { - let email_regex = Regex::new(r"^([a-z\d_+]([a-z\d_+.]*[a-z\d_+])?)@([a-z\d]+([\-.][a-z\d]+)*\.[a-z]{2,6})") - .expect("regex failed to compile"); - - email_regex.is_match(email_address_to_be_checked) -} - -#[cfg(test)] -mod tests { - use crate::utils::regex::validate_email_address; - - #[test] - fn validate_email_address_test() { - assert!(!validate_email_address("test")); - - assert!(!validate_email_address("test@")); - - assert!(!validate_email_address("test@torrust")); - - assert!(!validate_email_address("test@torrust.")); - - assert!(!validate_email_address("test@.")); - - assert!(!validate_email_address("test@.com")); - - assert!(validate_email_address("test@torrust.com")); - - assert!(validate_email_address("t@torrust.org")); - } -} diff --git a/src/utils/validation.rs b/src/utils/validation.rs new file mode 100644 index 00000000..9c4eeb8a --- /dev/null +++ b/src/utils/validation.rs @@ -0,0 +1,79 @@ +use std::str::FromStr; + +use email_address::EmailAddress; +use regex::Regex; + +const MIN_DOMAIN_LENGTH: usize = 4; + +/// Validates an email address. +/// +/// # Panics +/// +/// It panics if the email address is invalid. This should not happen +/// because the email address is previously validated. +#[must_use] +pub fn validate_email_address(email_address_to_be_checked: &str) -> bool { + if !EmailAddress::is_valid(email_address_to_be_checked) { + return false; + } + + let email = EmailAddress::from_str(email_address_to_be_checked).expect("Invalid email address"); + + // We reject anyway the email if it's a dotless domain name. + domain_has_extension(email.domain()) +} + +/// Returns true if the string representing a domain has an extension. +/// +/// It does not check if the extension is valid. +fn domain_has_extension(domain: &str) -> bool { + if domain.len() < MIN_DOMAIN_LENGTH { + return false; + } + + Regex::new(r".*\..*").expect("Invalid regex").is_match(domain) +} + +#[cfg(test)] +mod tests { + + mod for_email_validation { + use crate::utils::validation::validate_email_address; + + #[test] + fn it_should_accept_valid_email_addresses() { + assert!(validate_email_address("test@torrust.com")); + assert!(validate_email_address("t@torrust.org")); + } + + #[test] + fn it_should_not_accept_invalid_email_addresses() { + assert!(!validate_email_address("test")); + assert!(!validate_email_address("test@")); + assert!(!validate_email_address("test@torrust.")); + assert!(!validate_email_address("test@.")); + assert!(!validate_email_address("test@.com")); + + // Notice that local domain name with no TLD are valid, + // although ICANN highly discourages dotless email addresses + assert!(!validate_email_address("test@torrust")); + } + } + + mod for_domain_validation { + use crate::utils::validation::domain_has_extension; + + #[test] + fn it_should_accept_valid_domain_with_extension() { + assert!(domain_has_extension("a.io")); + assert!(domain_has_extension("a.com")); + } + + #[test] + fn it_should_not_accept_dotless_domains() { + assert!(!domain_has_extension("")); + assert!(!domain_has_extension(".")); + assert!(!domain_has_extension("a.")); + } + } +} From 6347cdb1c9e673024285bd5fc6fd9826313087ab Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 10 Jul 2023 12:49:21 +0100 Subject: [PATCH 260/357] chore(release): 2.0.0-alpha.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 107b960b..75b1d31e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3129,7 +3129,7 @@ dependencies = [ [[package]] name = "torrust-index-backend" -version = "2.0.0-alpha.2" +version = "2.0.0-alpha.3" dependencies = [ "actix-cors", "actix-multipart", diff --git a/Cargo.toml b/Cargo.toml index 726bce55..3798323d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "torrust-index-backend" description = "The backend (API) for the Torrust Index project." license-file = "COPYRIGHT" -version = "2.0.0-alpha.2" +version = "2.0.0-alpha.3" authors = [ "Mick van Dijke ", "Wesley Bijleveld ", From 6bf1b1914688c22602bd52343d318033099523e9 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 10 Jul 2023 17:01:04 +0100 Subject: [PATCH 261/357] ci: fix cargo publish command Copy/paste error. The command to publish crates wsa copied from the Tracker which has more than one package (cargo workspace). --- .github/workflows/publish_crate.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/publish_crate.yml b/.github/workflows/publish_crate.yml index c41fb549..ae7547f6 100644 --- a/.github/workflows/publish_crate.yml +++ b/.github/workflows/publish_crate.yml @@ -49,11 +49,6 @@ jobs: toolchain: stable - name: Publish workspace packages - run: | - cargo publish -p torrust-tracker-located-error - cargo publish -p torrust-tracker-primitives - cargo publish -p torrust-tracker-configuration - cargo publish -p torrust-tracker-test-helpers - cargo publish -p torrust-tracker + run: cargo publish env: CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_TOKEN }} From 946ea977b274c2cd4fc345879f529c4fc35fca83 Mon Sep 17 00:00:00 2001 From: Alex Wellnitz Date: Sun, 30 Jul 2023 00:59:21 +0200 Subject: [PATCH 262/357] #72: Add minimum length in Torrent Title --- src/services/torrent.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 5d12e119..3f7bcb94 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -98,11 +98,16 @@ impl Index { /// * Unable to get the category from the database. /// * Unable to insert the torrent into the database. /// * Unable to add the torrent to the whitelist. + /// * Torrent title is too short. pub async fn add_torrent(&self, mut torrent_request: AddTorrentRequest, user_id: UserId) -> Result { let _user = self.user_repository.get_compact(&user_id).await?; torrent_request.torrent.set_announce_urls(&self.configuration).await; + if torrent_request.metadata.title.len() < 3 { + return Err(ServiceError::BadRequest); + } + let category = self .category_repository .get_by_name(&torrent_request.metadata.category) From a15af487962deeed8137ca129069f52a8a8d54bf Mon Sep 17 00:00:00 2001 From: Alex Wellnitz Date: Mon, 31 Jul 2023 08:43:56 +0200 Subject: [PATCH 263/357] #72: Specific error code added --- src/errors.rs | 4 ++++ src/services/torrent.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/errors.rs b/src/errors.rs index d5252105..59432ab7 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -94,6 +94,9 @@ pub enum ServiceError { #[display(fmt = "Only .torrent files can be uploaded.")] InvalidFileType, + #[display(fmt = "Torrent title is too short.")] + InvalidTorrentTitleLength, + #[display(fmt = "Bad request.")] BadRequest, @@ -219,6 +222,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST, ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST, ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, + &ServiceError::InvalidTorrentTitleLength => StatusCode::BAD_REQUEST, ServiceError::BadRequest => StatusCode::BAD_REQUEST, ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, ServiceError::InvalidTag => StatusCode::BAD_REQUEST, diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 3f7bcb94..c16633bf 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -105,7 +105,7 @@ impl Index { torrent_request.torrent.set_announce_urls(&self.configuration).await; if torrent_request.metadata.title.len() < 3 { - return Err(ServiceError::BadRequest); + return Err(ServiceError::InvalidTorrentTitleLength); } let category = self From 7db0275ae7d95567270478587d4a728f97502714 Mon Sep 17 00:00:00 2001 From: Alex Wellnitz Date: Mon, 31 Jul 2023 13:14:11 +0200 Subject: [PATCH 264/357] Remove referencing the reference --- src/errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors.rs b/src/errors.rs index 59432ab7..01b64d2d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -222,7 +222,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST, ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST, ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, - &ServiceError::InvalidTorrentTitleLength => StatusCode::BAD_REQUEST, + ServiceError::InvalidTorrentTitleLength => StatusCode::BAD_REQUEST, ServiceError::BadRequest => StatusCode::BAD_REQUEST, ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, ServiceError::InvalidTag => StatusCode::BAD_REQUEST, From 182888357d9ed1a603e84ba32aac39e35b2e4998 Mon Sep 17 00:00:00 2001 From: Alex Wellnitz Date: Mon, 31 Jul 2023 13:20:02 +0200 Subject: [PATCH 265/357] #72: Constant added so you can easily adjust the value --- src/services/torrent.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index c16633bf..cd31aaad 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -18,6 +18,8 @@ use crate::models::user::UserId; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{tracker, AsCSV}; +const MIN_TORRENT_TITLE_LENGTH: u32 = 3; + pub struct Index { configuration: Arc, tracker_statistics_importer: Arc, @@ -104,7 +106,7 @@ impl Index { torrent_request.torrent.set_announce_urls(&self.configuration).await; - if torrent_request.metadata.title.len() < 3 { + if torrent_request.metadata.title.len() < MIN_TORRENT_TITLE_LENGTH { return Err(ServiceError::InvalidTorrentTitleLength); } From f739657ff83ff2d03f9c919e44eb35fef5f022b6 Mon Sep 17 00:00:00 2001 From: Alex Wellnitz Date: Mon, 31 Jul 2023 13:33:38 +0200 Subject: [PATCH 266/357] #72: Change MIN_TORRENT_TITLE_LENGTH type from u32 to usize for Rust compatibility --- src/services/torrent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index cd31aaad..585a5b55 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -18,7 +18,7 @@ use crate::models::user::UserId; use crate::tracker::statistics_importer::StatisticsImporter; use crate::{tracker, AsCSV}; -const MIN_TORRENT_TITLE_LENGTH: u32 = 3; +const MIN_TORRENT_TITLE_LENGTH: usize = 3; pub struct Index { configuration: Arc, From dd1dc0cfc7f133202d96b0c1673226a12f3519fd Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Jul 2023 18:01:16 +0100 Subject: [PATCH 267/357] chore: add dependencies: hex, uuid We need them to generate random torrents. We use an UUID for the torrent name and contents and we nned the hex package to generate the torrent file "pieces" field. --- Cargo.lock | 1 + Cargo.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 75b1d31e..8b1bca63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3145,6 +3145,7 @@ dependencies = [ "email_address", "fern", "futures", + "hex", "hyper", "indexmap 1.9.3", "jsonwebtoken", diff --git a/Cargo.toml b/Cargo.toml index 3798323d..4684a1e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,8 @@ axum = { version = "0.6.18", features = ["multipart"] } hyper = "0.14.26" tower-http = { version = "0.4.0", features = ["cors"] } email_address = "0.2.4" +hex = "0.4.3" +uuid = { version = "1.3", features = ["v4"] } [dev-dependencies] rand = "0.8" From 30bf79e39ba76d2229af75345ccb881a1e9086d0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 28 Jul 2023 19:22:34 +0100 Subject: [PATCH 268/357] feat: new endpoint to generate random torrents For now, it will be used only for testing purposes. We need to generate random torrent in Cypress in the Index Frontend app. --- src/web/api/v1/contexts/torrent/handlers.rs | 64 +++++++++++++++++++- src/web/api/v1/contexts/torrent/responses.rs | 12 +++- src/web/api/v1/contexts/torrent/routes.rs | 8 ++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 7da3e385..868ae871 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -8,6 +8,8 @@ use axum::extract::{self, Multipart, Path, Query, State}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; +use sha1::{Digest, Sha1}; +use uuid::Uuid; use super::forms::UpdateTorrentInfoForm; use super::responses::{new_torrent_response, torrent_file_response}; @@ -15,6 +17,7 @@ use crate::common::AppData; use crate::errors::ServiceError; use crate::models::info_hash::InfoHash; use crate::models::torrent::{AddTorrentRequest, Metadata}; +use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::torrent_tag::TagId; use crate::services::torrent::ListingRequest; use crate::utils::parse_torrent; @@ -92,7 +95,7 @@ pub async fn download_torrent_handler( return ServiceError::InternalServerError.into_response(); }; - torrent_file_response(bytes) + torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name)) } /// It returns a list of torrents matching the search criteria. @@ -214,6 +217,65 @@ pub async fn delete_torrent_handler( } } +/// Returns a random torrent as a byte stream `application/x-bittorrent`. +/// +/// This is useful for testing purposes. +/// +/// # Errors +/// +/// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. +#[allow(clippy::unused_async)] +pub async fn create_random_torrent_handler(State(_app_data): State>) -> Response { + let torrent = generate_random_torrent(); + + let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { + return ServiceError::InternalServerError.into_response(); + }; + + torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name)) +} + +/// It generates a random single-file torrent for testing purposes. +fn generate_random_torrent() -> Torrent { + let id = Uuid::new_v4(); + + let file_contents = format!("{id}\n"); + + let torrent_info = DbTorrentInfo { + torrent_id: 1, + info_hash: String::new(), + name: format!("file-{id}.txt"), + pieces: sha1(&file_contents), + piece_length: 16384, + private: None, + root_hash: 0, + }; + + let torrent_files: Vec = vec![TorrentFile { + path: vec![String::new()], + length: 37, // Number of bytes for the UUID plus one char for line break (`0a`). + md5sum: None, + }]; + + let torrent_announce_urls: Vec> = vec![]; + + Torrent::from_db_info_files_and_announce_urls(torrent_info, torrent_files, torrent_announce_urls) +} + +fn sha1(data: &str) -> String { + // Create a Sha1 object + let mut hasher = Sha1::new(); + + // Write input message + hasher.update(data.as_bytes()); + + // Read hash digest and consume hasher + let result = hasher.finalize(); + + // Convert the hash (a byte array) to a string of hex characters + hex::encode(result) +} + /// Extracts the [`TorrentRequest`] from the multipart form payload. /// /// # Errors diff --git a/src/web/api/v1/contexts/torrent/responses.rs b/src/web/api/v1/contexts/torrent/responses.rs index 3197c5a0..be5e50f5 100644 --- a/src/web/api/v1/contexts/torrent/responses.rs +++ b/src/web/api/v1/contexts/torrent/responses.rs @@ -24,6 +24,14 @@ pub fn new_torrent_response(torrent_id: TorrentId, info_hash: &str) -> Json) -> Response { - (StatusCode::OK, [(header::CONTENT_TYPE, "application/x-bittorrent")], bytes).into_response() +pub fn torrent_file_response(bytes: Vec, filename: &str) -> Response { + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/x-bittorrent"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename={filename}")), + ], + bytes, + ) + .into_response() } diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs index a5c7ed78..f2df6bbe 100644 --- a/src/web/api/v1/contexts/torrent/routes.rs +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -7,8 +7,8 @@ use axum::routing::{delete, get, post, put}; use axum::Router; use super::handlers::{ - delete_torrent_handler, download_torrent_handler, get_torrent_info_handler, get_torrents_handler, - update_torrent_info_handler, upload_torrent_handler, + create_random_torrent_handler, delete_torrent_handler, download_torrent_handler, get_torrent_info_handler, + get_torrents_handler, update_torrent_info_handler, upload_torrent_handler, }; use crate::common::AppData; @@ -27,5 +27,7 @@ pub fn router_for_single_resources(app_data: Arc) -> Router { /// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for multiple resources. pub fn router_for_multiple_resources(app_data: Arc) -> Router { - Router::new().route("/", get(get_torrents_handler).with_state(app_data)) + Router::new() + .route("/", get(get_torrents_handler).with_state(app_data.clone())) + .route("/random", get(create_random_torrent_handler).with_state(app_data)) } From b269ecbfcd55785ff153325aebb9285f59a5a8e4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Jul 2023 15:41:19 +0100 Subject: [PATCH 269/357] feat!: change random torrent generator endpoint The new enpopint is: `http://0.0.0.0:3001/v1/torrent/meta-info/random/:uuid` The segments have changed to differenciate the indexed torrent from the torrent file (meta-indo). An indexed torrent is a torrent file (meta-info file) with some extra classification metadata: title, description, category and tags. There is also a new PATH param `:uuid` which is an UUID to identify the generated torrent file. The UUID is used for: - The torrent file name - The sample contents for a text file ffrom which we generate the torrent file. --- src/web/api/v1/contexts/torrent/handlers.rs | 21 ++++++++++++++++----- src/web/api/v1/contexts/torrent/routes.rs | 13 +++++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 868ae871..a3e40327 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -217,6 +217,15 @@ pub async fn delete_torrent_handler( } } +#[derive(Debug, Deserialize)] +pub struct UuidParam(pub String); + +impl UuidParam { + fn value(&self) -> String { + self.0.to_lowercase() + } +} + /// Returns a random torrent as a byte stream `application/x-bittorrent`. /// /// This is useful for testing purposes. @@ -225,8 +234,12 @@ pub async fn delete_torrent_handler( /// /// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. #[allow(clippy::unused_async)] -pub async fn create_random_torrent_handler(State(_app_data): State>) -> Response { - let torrent = generate_random_torrent(); +pub async fn create_random_torrent_handler(State(_app_data): State>, Path(uuid): Path) -> Response { + let Ok(uuid) = Uuid::parse_str(&uuid.value()) else { + return ServiceError::BadRequest.into_response(); + }; + + let torrent = generate_random_torrent(uuid); let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { return ServiceError::InternalServerError.into_response(); @@ -236,9 +249,7 @@ pub async fn create_random_torrent_handler(State(_app_data): State> } /// It generates a random single-file torrent for testing purposes. -fn generate_random_torrent() -> Torrent { - let id = Uuid::new_v4(); - +fn generate_random_torrent(id: Uuid) -> Torrent { let file_contents = format!("{id}\n"); let torrent_info = DbTorrentInfo { diff --git a/src/web/api/v1/contexts/torrent/routes.rs b/src/web/api/v1/contexts/torrent/routes.rs index f2df6bbe..1c529599 100644 --- a/src/web/api/v1/contexts/torrent/routes.rs +++ b/src/web/api/v1/contexts/torrent/routes.rs @@ -21,13 +21,18 @@ pub fn router_for_single_resources(app_data: Arc) -> Router { Router::new() .route("/upload", post(upload_torrent_handler).with_state(app_data.clone())) - .route("/download/:info_hash", get(download_torrent_handler).with_state(app_data)) + .route( + "/download/:info_hash", + get(download_torrent_handler).with_state(app_data.clone()), + ) + .route( + "/meta-info/random/:uuid", + get(create_random_torrent_handler).with_state(app_data), + ) .nest("/:info_hash", torrent_info_routes) } /// Routes for the [`torrent`](crate::web::api::v1::contexts::torrent) API context for multiple resources. pub fn router_for_multiple_resources(app_data: Arc) -> Router { - Router::new() - .route("/", get(get_torrents_handler).with_state(app_data.clone())) - .route("/random", get(create_random_torrent_handler).with_state(app_data)) + Router::new().route("/", get(get_torrents_handler).with_state(app_data)) } From dfa260e85abe742aa4dfb2a6f419b0a7870afb07 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Jul 2023 15:47:27 +0100 Subject: [PATCH 270/357] fix: clippy warnings alter updating clippy to clippy 0.1.73 --- src/models/info_hash.rs | 2 +- src/web/api/v1/contexts/torrent/handlers.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs index 3925d4a4..342d0fc3 100644 --- a/src/models/info_hash.rs +++ b/src/models/info_hash.rs @@ -195,7 +195,7 @@ impl Ord for InfoHash { impl std::cmp::PartialOrd for InfoHash { fn partial_cmp(&self, other: &InfoHash) -> Option { - self.0.partial_cmp(&other.0) + Some(self.cmp(other)) } } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index a3e40327..85bbd1f9 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -309,7 +309,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result = vec![]; while let Some(mut field) = payload.next_field().await.unwrap() { - let name = field.name().unwrap().clone(); + let name = field.name().unwrap(); match name { "title" => { @@ -342,7 +342,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { - let content_type = field.content_type().unwrap().clone(); + let content_type = field.content_type().unwrap(); if content_type != "application/x-bittorrent" { return Err(ServiceError::InvalidFileType); From b2870b95a032083136527b3b9fbde3bf3f0f25df Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Jul 2023 16:18:56 +0100 Subject: [PATCH 271/357] refactor: extract torrent file service to generate random torrent files. It was moved from the handler. --- src/models/torrent_file.rs | 15 +++++ src/services/mod.rs | 1 + src/services/torrent_file.rs | 64 +++++++++++++++++++++ src/web/api/v1/contexts/torrent/handlers.rs | 44 +------------- 4 files changed, 82 insertions(+), 42 deletions(-) create mode 100644 src/services/torrent_file.rs diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 5a21c68a..6fe249f2 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -4,6 +4,7 @@ use serde_bytes::ByteBuf; use sha1::{Digest, Sha1}; use crate::config::Configuration; +use crate::services::torrent_file::NewTorrentInfoRequest; use crate::utils::hex::{from_bytes, into_bytes}; #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] @@ -102,6 +103,20 @@ pub struct Torrent { } impl Torrent { + #[must_use] + pub fn from_new_torrent_info_request(new_torrent_info: NewTorrentInfoRequest) -> Self { + let torrent_info = DbTorrentInfo { + torrent_id: 1, + info_hash: String::new(), + name: new_torrent_info.name, + pieces: new_torrent_info.pieces, + piece_length: 16384, + private: None, + root_hash: 0, + }; + Torrent::from_db_info_files_and_announce_urls(torrent_info, new_torrent_info.files, new_torrent_info.announce_urls) + } + /// It hydrates a `Torrent` struct from the database data. /// /// # Panics diff --git a/src/services/mod.rs b/src/services/mod.rs index a8886af7..b19bab37 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,4 +6,5 @@ pub mod proxy; pub mod settings; pub mod tag; pub mod torrent; +pub mod torrent_file; pub mod user; diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs new file mode 100644 index 00000000..9eb423c2 --- /dev/null +++ b/src/services/torrent_file.rs @@ -0,0 +1,64 @@ +//! This module contains the services related to torrent file management. +use sha1::{Digest, Sha1}; +use uuid::Uuid; + +use crate::models::torrent_file::{Torrent, TorrentFile}; + +pub struct NewTorrentInfoRequest { + pub name: String, + pub pieces: String, + pub piece_length: i64, + pub private: Option, + pub root_hash: i64, + pub files: Vec, + pub announce_urls: Vec>, +} + +/// It generates a random single-file torrent for testing purposes. +/// +/// The torrent will contain a single text file with the UUID as its content. +/// +/// # Panics +/// +/// This function will panic if the sample file contents length in bytes is +/// greater than `i64::MAX`. +#[must_use] +pub fn generate_random_torrent(id: Uuid) -> Torrent { + // Content of the file from which the torrent will be generated. + // We use the UUID as the content of the file. + let file_contents = format!("{id}\n"); + + let torrent_files: Vec = vec![TorrentFile { + path: vec![String::new()], + length: i64::try_from(file_contents.len()).expect("file contents size in bytes cannot exceed i64::MAX"), + md5sum: None, + }]; + + let torrent_announce_urls: Vec> = vec![]; + + let torrent_info_request = NewTorrentInfoRequest { + name: format!("file-{id}.txt"), + pieces: sha1(&file_contents), + piece_length: 16384, + private: None, + root_hash: 0, + files: torrent_files, + announce_urls: torrent_announce_urls, + }; + + Torrent::from_new_torrent_info_request(torrent_info_request) +} + +fn sha1(data: &str) -> String { + // Create a Sha1 object + let mut hasher = Sha1::new(); + + // Write input message + hasher.update(data.as_bytes()); + + // Read hash digest and consume hasher + let result = hasher.finalize(); + + // Convert the hash (a byte array) to a string of hex characters + hex::encode(result) +} diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 85bbd1f9..724821e1 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -8,7 +8,6 @@ use axum::extract::{self, Multipart, Path, Query, State}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; -use sha1::{Digest, Sha1}; use uuid::Uuid; use super::forms::UpdateTorrentInfoForm; @@ -17,9 +16,9 @@ use crate::common::AppData; use crate::errors::ServiceError; use crate::models::info_hash::InfoHash; use crate::models::torrent::{AddTorrentRequest, Metadata}; -use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::torrent_tag::TagId; use crate::services::torrent::ListingRequest; +use crate::services::torrent_file::generate_random_torrent; use crate::utils::parse_torrent; use crate::web::api::v1::auth::get_optional_logged_in_user; use crate::web::api::v1::extractors::bearer_token::Extract; @@ -226,7 +225,7 @@ impl UuidParam { } } -/// Returns a random torrent as a byte stream `application/x-bittorrent`. +/// Returns a random torrent file as a byte stream `application/x-bittorrent`. /// /// This is useful for testing purposes. /// @@ -248,45 +247,6 @@ pub async fn create_random_torrent_handler(State(_app_data): State> torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name)) } -/// It generates a random single-file torrent for testing purposes. -fn generate_random_torrent(id: Uuid) -> Torrent { - let file_contents = format!("{id}\n"); - - let torrent_info = DbTorrentInfo { - torrent_id: 1, - info_hash: String::new(), - name: format!("file-{id}.txt"), - pieces: sha1(&file_contents), - piece_length: 16384, - private: None, - root_hash: 0, - }; - - let torrent_files: Vec = vec![TorrentFile { - path: vec![String::new()], - length: 37, // Number of bytes for the UUID plus one char for line break (`0a`). - md5sum: None, - }]; - - let torrent_announce_urls: Vec> = vec![]; - - Torrent::from_db_info_files_and_announce_urls(torrent_info, torrent_files, torrent_announce_urls) -} - -fn sha1(data: &str) -> String { - // Create a Sha1 object - let mut hasher = Sha1::new(); - - // Write input message - hasher.update(data.as_bytes()); - - // Read hash digest and consume hasher - let result = hasher.finalize(); - - // Convert the hash (a byte array) to a string of hex characters - hex::encode(result) -} - /// Extracts the [`TorrentRequest`] from the multipart form payload. /// /// # Errors From 40c4df0000dfdd6a0761a6133660944011dde22e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Jul 2023 16:36:30 +0100 Subject: [PATCH 272/357] refactor: extract hasher service --- src/services/hasher.rs | 28 ++++++++++++++++++++++++++++ src/services/mod.rs | 1 + src/services/torrent_file.rs | 16 +--------------- 3 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 src/services/hasher.rs diff --git a/src/services/hasher.rs b/src/services/hasher.rs new file mode 100644 index 00000000..3ee4f6e8 --- /dev/null +++ b/src/services/hasher.rs @@ -0,0 +1,28 @@ +//! Hashing service +use sha1::{Digest, Sha1}; + +// Calculate the sha1 hash of a string +#[must_use] +pub fn sha1(data: &str) -> String { + // Create a Sha1 object + let mut hasher = Sha1::new(); + + // Write input message + hasher.update(data.as_bytes()); + + // Read hash digest and consume hasher + let result = hasher.finalize(); + + // Convert the hash (a byte array) to a string of hex characters + hex::encode(result) +} + +#[cfg(test)] +mod tests { + use crate::services::hasher::sha1; + + #[test] + fn it_should_hash_an_string() { + assert_eq!(sha1("hello world"), "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index b19bab37..b2431aec 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod about; pub mod authentication; pub mod category; +pub mod hasher; pub mod proxy; pub mod settings; pub mod tag; diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 9eb423c2..3d02951d 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -1,8 +1,8 @@ //! This module contains the services related to torrent file management. -use sha1::{Digest, Sha1}; use uuid::Uuid; use crate::models::torrent_file::{Torrent, TorrentFile}; +use crate::services::hasher::sha1; pub struct NewTorrentInfoRequest { pub name: String, @@ -48,17 +48,3 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { Torrent::from_new_torrent_info_request(torrent_info_request) } - -fn sha1(data: &str) -> String { - // Create a Sha1 object - let mut hasher = Sha1::new(); - - // Write input message - hasher.update(data.as_bytes()); - - // Read hash digest and consume hasher - let result = hasher.finalize(); - - // Convert the hash (a byte array) to a string of hex characters - hex::encode(result) -} From 4b6f25cb9ac68945c813111de6a97201eda18d8e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Jul 2023 16:59:11 +0100 Subject: [PATCH 273/357] test: add test for random torrent file generator service --- src/services/torrent_file.rs | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 3d02951d..eac6d823 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -48,3 +48,45 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { Torrent::from_new_torrent_info_request(torrent_info_request) } + +#[cfg(test)] +mod tests { + use serde_bytes::ByteBuf; + use uuid::Uuid; + + use crate::models::torrent_file::{Torrent, TorrentInfo}; + use crate::services::torrent_file::generate_random_torrent; + + #[test] + fn it_should_generate_a_random_meta_info_file() { + let uuid = Uuid::parse_str("d6170378-2c14-4ccc-870d-2a8e15195e23").unwrap(); + + let torrent = generate_random_torrent(uuid); + + let expected_torrent = Torrent { + info: TorrentInfo { + name: "file-d6170378-2c14-4ccc-870d-2a8e15195e23.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + 62, 231, 243, 51, 234, 165, 204, 209, 51, 132, 163, 133, 249, 50, 107, 46, 24, 15, 251, 32, + ])), + piece_length: 16384, + md5sum: None, + length: Some(37), + files: None, + private: Some(0), + path: None, + root_hash: None, + }, + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent, expected_torrent); + } +} From 0f163cf08d47fdfac38fc2f3417450d1b00b863d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 31 Jul 2023 17:22:04 +0100 Subject: [PATCH 274/357] refactor: invert the dependency between Torrent named constructors The constructor to hidrate the object from the database should depeden on the other to create a new Torrent from scracth. In fact, the `torrent_id` and `info_hash` in the `DbTorrentInfo` are not needed. --- src/models/torrent_file.rs | 50 +++++++++++++++++++----------------- src/services/torrent_file.rs | 4 +++ 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 6fe249f2..8489d90b 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -103,31 +103,13 @@ pub struct Torrent { } impl Torrent { - #[must_use] - pub fn from_new_torrent_info_request(new_torrent_info: NewTorrentInfoRequest) -> Self { - let torrent_info = DbTorrentInfo { - torrent_id: 1, - info_hash: String::new(), - name: new_torrent_info.name, - pieces: new_torrent_info.pieces, - piece_length: 16384, - private: None, - root_hash: 0, - }; - Torrent::from_db_info_files_and_announce_urls(torrent_info, new_torrent_info.files, new_torrent_info.announce_urls) - } - - /// It hydrates a `Torrent` struct from the database data. + /// It builds a `Torrent` from a `NewTorrentInfoRequest`. /// /// # Panics /// /// This function will panic if the `torrent_info.pieces` is not a valid hex string. #[must_use] - pub fn from_db_info_files_and_announce_urls( - torrent_info: DbTorrentInfo, - torrent_files: Vec, - torrent_announce_urls: Vec>, - ) -> Self { + pub fn from_new_torrent_info_request(torrent_info: NewTorrentInfoRequest) -> Self { let private = u8::try_from(torrent_info.private.unwrap_or(0)).ok(); // the info part of the torrent file @@ -152,8 +134,9 @@ impl Torrent { } // either set the single file or the multiple files information - if torrent_files.len() == 1 { - let torrent_file = torrent_files + if torrent_info.files.len() == 1 { + let torrent_file = torrent_info + .files .first() .expect("vector `torrent_files` should have at least one element"); @@ -175,7 +158,7 @@ impl Torrent { info.path = path; } else { - info.files = Some(torrent_files); + info.files = Some(torrent_info.files); } Self { @@ -184,13 +167,32 @@ impl Torrent { nodes: None, encoding: None, httpseeds: None, - announce_list: Some(torrent_announce_urls), + announce_list: Some(torrent_info.announce_urls), creation_date: None, comment: None, created_by: None, } } + /// It hydrates a `Torrent` struct from the database data. + #[must_use] + pub fn from_db_info_files_and_announce_urls( + torrent_info: DbTorrentInfo, + torrent_files: Vec, + torrent_announce_urls: Vec>, + ) -> Self { + let torrent_info_request = NewTorrentInfoRequest { + name: torrent_info.name, + pieces: torrent_info.pieces, + piece_length: torrent_info.piece_length, + private: torrent_info.private, + root_hash: torrent_info.root_hash, + files: torrent_files, + announce_urls: torrent_announce_urls, + }; + Torrent::from_new_torrent_info_request(torrent_info_request) + } + /// Sets the announce url to the tracker url and removes all other trackers /// if the torrent is private. pub async fn set_announce_urls(&mut self, cfg: &Configuration) { diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index eac6d823..5c119930 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -4,6 +4,10 @@ use uuid::Uuid; use crate::models::torrent_file::{Torrent, TorrentFile}; use crate::services::hasher::sha1; +/// It contains the information required to create a new torrent file. +/// +/// It's not the full in-memory representation of a torrent file. The full +/// in-memory representation is the `Torrent` struct. pub struct NewTorrentInfoRequest { pub name: String, pub pieces: String, From 414c6c12a80924ca16c8fbe29ad08b57aabf1cf1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 2 Aug 2023 15:37:06 +0100 Subject: [PATCH 275/357] feat: add a custom header with infohash to the download endpoints Endpoint where you can download a torrent file have now a custom header containing the torrent infohash: The name of the header is: "x-torrust-torrent-infohash" It is added becuase in the frontend we need the infohash in a cypress test and this way we do not need to parse the downloaded torrent which is faster and simpler. --- src/web/api/v1/contexts/torrent/handlers.rs | 4 +- src/web/api/v1/contexts/torrent/responses.rs | 40 ++++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 724821e1..2621ff9c 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -94,7 +94,7 @@ pub async fn download_torrent_handler( return ServiceError::InternalServerError.into_response(); }; - torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name)) + torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash()) } /// It returns a list of torrents matching the search criteria. @@ -244,7 +244,7 @@ pub async fn create_random_torrent_handler(State(_app_data): State> return ServiceError::InternalServerError.into_response(); }; - torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name)) + torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash()) } /// Extracts the [`TorrentRequest`] from the multipart form payload. diff --git a/src/web/api/v1/contexts/torrent/responses.rs b/src/web/api/v1/contexts/torrent/responses.rs index be5e50f5..33ec2a19 100644 --- a/src/web/api/v1/contexts/torrent/responses.rs +++ b/src/web/api/v1/contexts/torrent/responses.rs @@ -1,6 +1,6 @@ use axum::response::{IntoResponse, Response}; use axum::Json; -use hyper::{header, StatusCode}; +use hyper::{header, HeaderMap, StatusCode}; use serde::{Deserialize, Serialize}; use crate::models::torrent::TorrentId; @@ -23,15 +23,33 @@ pub fn new_torrent_response(torrent_id: TorrentId, info_hash: &str) -> Json, filename: &str) -> Response { - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "application/x-bittorrent"), - (header::CONTENT_DISPOSITION, &format!("attachment; filename={filename}")), - ], - bytes, - ) - .into_response() +pub fn torrent_file_response(bytes: Vec, filename: &str, info_hash: &str) -> Response { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + "application/x-bittorrent" + .parse() + .expect("HTTP content type header should be valid"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + format!("attachment; filename={filename}") + .parse() + .expect("Torrent filename should be a valid header value for the content disposition header"), + ); + headers.insert( + "x-torrust-torrent-infohash", + info_hash + .parse() + .expect("Torrent infohash should be a valid header value for the content disposition header"), + ); + + (StatusCode::OK, headers, bytes).into_response() } From f7f76fff7e972cfa6f14748678a88e56596e9cad Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 2 Aug 2023 15:46:20 +0100 Subject: [PATCH 276/357] fix: clippy warning --- src/services/torrent.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 585a5b55..77e358c4 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -333,7 +333,6 @@ impl Index { let page_size = request.page_size.unwrap_or(default_torrent_page_size); // Guard that page size does not exceed the maximum - let max_torrent_page_size = max_torrent_page_size; let page_size = if page_size > max_torrent_page_size { max_torrent_page_size } else { From 9de7aa7e5563e091219c9a1b9c37f7c544ae5fcc Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 2 Aug 2023 17:22:55 +0100 Subject: [PATCH 277/357] dev: tighten lint for build and clippy --- .cargo/config.toml | 20 ++++++++++++++++++++ .vscode/settings.json | 23 +++++++++++++++++++---- src/app.rs | 2 +- src/cache/mod.rs | 2 +- src/databases/mysql.rs | 14 +++++++------- src/databases/sqlite.rs | 14 +++++++------- src/models/info_hash.rs | 4 ++-- src/services/torrent.rs | 11 ++++++----- src/services/user.rs | 4 ++-- src/tracker/statistics_importer.rs | 11 ++++++----- 10 files changed, 71 insertions(+), 34 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index bb7b3a4b..35196651 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,3 +3,23 @@ cov = "llvm-cov" cov-html = "llvm-cov --html" cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" time = "build --timings --all-targets" + +[build] +rustflags = [ + "-D", + "warnings", + "-D", + "future-incompatible", + "-D", + "let-underscore", + "-D", + "nonstandard-style", + "-D", + "rust-2018-compatibility", + "-D", + "rust-2018-idioms", + "-D", + "rust-2021-compatibility", + "-D", + "unused", +] diff --git a/.vscode/settings.json b/.vscode/settings.json index 9966a630..3bf0969e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,22 @@ "[rust]": { "editor.formatOnSave": true }, - "rust-analyzer.checkOnSave.command": "clippy", - "rust-analyzer.checkOnSave.allTargets": true, - "rust-analyzer.checkOnSave.extraArgs": ["--", "-W", "clippy::pedantic"] -} + "rust-analyzer.checkOnSave": true, + "rust-analyzer.check.command": "clippy", + "rust-analyzer.check.allTargets": true, + "rust-analyzer.check.extraArgs": [ + "--", + "-D", + "clippy::correctness", + "-D", + "clippy::suspicious", + "-W", + "clippy::complexity", + "-W", + "clippy::perf", + "-W", + "clippy::style", + "-W", + "clippy::pedantic", + ], +} \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 5bc096b2..fce0cfe5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -162,7 +162,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running loop { interval.tick().await; if let Some(tracker) = weak_tracker_statistics_importer.upgrade() { - let _ = tracker.import_all_torrents_statistics().await; + drop(tracker.import_all_torrents_statistics().await); } else { break; } diff --git a/src/cache/mod.rs b/src/cache/mod.rs index a2a9bd81..4dfc5af3 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -121,7 +121,7 @@ impl BytesCache { } // Remove the old entry so that a new entry will be added as last in the queue. - let _ = self.bytes_table.shift_remove(&key); + drop(self.bytes_table.shift_remove(&key)); let bytes_cache_entry = BytesCacheEntry::new(bytes); diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index c06784e4..95a77812 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -73,7 +73,7 @@ impl Database for Mysql { // rollback transaction on error if let Err(e) = insert_user_auth_result { - let _ = tx.rollback().await; + drop(tx.rollback().await); return Err(e); } @@ -100,11 +100,11 @@ impl Database for Mysql { // commit or rollback transaction and return user_id on success match insert_user_profile_result { Ok(_) => { - let _ = tx.commit().await; + drop(tx.commit().await); Ok(i64::overflowing_add_unsigned(0, user_id).0) } Err(e) => { - let _ = tx.rollback().await; + drop(tx.rollback().await); Err(e) } } @@ -497,7 +497,7 @@ impl Database for Mysql { // rollback transaction on error if let Err(e) = insert_torrent_files_result { - let _ = tx.rollback().await; + drop(tx.rollback().await); return Err(e); } @@ -531,7 +531,7 @@ impl Database for Mysql { // rollback transaction on error if let Err(e) = insert_torrent_announce_urls_result { - let _ = tx.rollback().await; + drop(tx.rollback().await); return Err(e); } @@ -558,11 +558,11 @@ impl Database for Mysql { // commit or rollback transaction and return user_id on success match insert_torrent_info_result { Ok(_) => { - let _ = tx.commit().await; + drop(tx.commit().await); Ok(torrent_id) } Err(e) => { - let _ = tx.rollback().await; + drop(tx.rollback().await); Err(e) } } diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 3ecc0a50..2c69169b 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -74,7 +74,7 @@ impl Database for Sqlite { // rollback transaction on error if let Err(e) = insert_user_auth_result { - let _ = tx.rollback().await; + drop(tx.rollback().await); return Err(e); } @@ -101,11 +101,11 @@ impl Database for Sqlite { // commit or rollback transaction and return user_id on success match insert_user_profile_result { Ok(_) => { - let _ = tx.commit().await; + drop(tx.commit().await); Ok(user_id) } Err(e) => { - let _ = tx.rollback().await; + drop(tx.rollback().await); Err(e) } } @@ -487,7 +487,7 @@ impl Database for Sqlite { // rollback transaction on error if let Err(e) = insert_torrent_files_result { - let _ = tx.rollback().await; + drop(tx.rollback().await); return Err(e); } @@ -521,7 +521,7 @@ impl Database for Sqlite { // rollback transaction on error if let Err(e) = insert_torrent_announce_urls_result { - let _ = tx.rollback().await; + drop(tx.rollback().await); return Err(e); } @@ -548,11 +548,11 @@ impl Database for Sqlite { // commit or rollback transaction and return user_id on success match insert_torrent_info_result { Ok(_) => { - let _ = tx.commit().await; + drop(tx.commit().await); Ok(torrent_id) } Err(e) => { - let _ = tx.rollback().await; + drop(tx.rollback().await); Err(e) } } diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs index 342d0fc3..1908b674 100644 --- a/src/models/info_hash.rs +++ b/src/models/info_hash.rs @@ -167,7 +167,7 @@ impl InfoHash { } impl std::fmt::Display for InfoHash { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut chars = [0u8; 40]; binascii::bin2hex(&self.0, &mut chars).expect("failed to hexlify"); write!(f, "{}", std::str::from_utf8(&chars).unwrap()) @@ -271,7 +271,7 @@ struct InfoHashVisitor; impl<'v> serde::de::Visitor<'v> for InfoHashVisitor { type Value = InfoHash; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(formatter, "a 40 character long hash") } diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 77e358c4..e8e6cef9 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -118,10 +118,11 @@ impl Index { let torrent_id = self.torrent_repository.add(&torrent_request, user_id, category).await?; - let _ = self - .tracker_statistics_importer - .import_torrent_statistics(torrent_id, &torrent_request.torrent.info_hash()) - .await; + drop( + self.tracker_statistics_importer + .import_torrent_statistics(torrent_id, &torrent_request.torrent.info_hash()) + .await, + ); // We always whitelist the torrent on the tracker because even if the tracker mode is `public` // it could be changed to `private` later on. @@ -131,7 +132,7 @@ impl Index { .await { // If the torrent can't be whitelisted somehow, remove the torrent from database - let _ = self.torrent_repository.delete(&torrent_id).await; + drop(self.torrent_repository.delete(&torrent_id).await); return Err(e); } diff --git a/src/services/user.rs b/src/services/user.rs index f8a25b93..b144241e 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -126,7 +126,7 @@ impl RegistrationService { // If this is the first created account, give administrator rights if user_id == 1 { - let _ = self.user_repository.grant_admin_role(&user_id).await; + drop(self.user_repository.grant_admin_role(&user_id).await); } if settings.mail.email_verification_enabled && opt_email.is_some() { @@ -141,7 +141,7 @@ impl RegistrationService { .await; if mail_res.is_err() { - let _ = self.user_repository.delete(&user_id).await; + drop(self.user_repository.delete(&user_id).await); return Err(ServiceError::FailedToSendVerificationEmail); } } diff --git a/src/tracker/statistics_importer.rs b/src/tracker/statistics_importer.rs index f2ae0dce..a3f1f6b8 100644 --- a/src/tracker/statistics_importer.rs +++ b/src/tracker/statistics_importer.rs @@ -69,13 +69,14 @@ impl StatisticsImporter { /// found. pub async fn import_torrent_statistics(&self, torrent_id: i64, info_hash: &str) -> Result { if let Ok(torrent_info) = self.tracker_service.get_torrent_info(info_hash).await { - let _ = self - .database - .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) - .await; + drop( + self.database + .update_tracker_info(torrent_id, &self.tracker_url, torrent_info.seeders, torrent_info.leechers) + .await, + ); Ok(torrent_info) } else { - let _ = self.database.update_tracker_info(torrent_id, &self.tracker_url, 0, 0).await; + drop(self.database.update_tracker_info(torrent_id, &self.tracker_url, 0, 0).await); Err(ServiceError::TorrentNotFound) } } From 2e6fe1239e1183a111e6dbca6badaee29b828d6e Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 3 Aug 2023 10:00:53 +0100 Subject: [PATCH 278/357] ci: overhaul coverage workflow --- .github/workflows/codecov.yml | 44 ----------------------- .github/workflows/coverage.yaml | 63 +++++++++++++++++++++++++++++++++ codecov.yml | 6 ++++ project-words.txt | 10 ++++++ 4 files changed, 79 insertions(+), 44 deletions(-) delete mode 100644 .github/workflows/codecov.yml create mode 100644 .github/workflows/coverage.yaml create mode 100644 codecov.yml diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index 4f8fb02a..00000000 --- a/.github/workflows/codecov.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Upload Code Coverage - -on: - push: - pull_request: - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: nightly - components: rustfmt, llvm-tools-preview - - name: Build - run: cargo build --release - env: - CARGO_INCREMENTAL: "0" - RUSTFLAGS: "-Cinstrument-coverage" - RUSTDOCFLAGS: "-Cinstrument-coverage" - - name: Install torrent edition tool (needed for testing) - run: cargo install imdl - - name: Test - run: cargo test --all-features --no-fail-fast - env: - CARGO_INCREMENTAL: "0" - RUSTFLAGS: "-Cinstrument-coverage" - RUSTDOCFLAGS: "-Cinstrument-coverage" - - name: Install grcov - run: if [[ ! -e ~/.cargo/bin/grcov ]]; then cargo install grcov; fi - - name: Run grcov - run: grcov . --binary-path target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../**' --ignore '/*' -o coverage.lcov - - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - with: - files: ./coverage.lcov - flags: rust - fail_ci_if_error: true # optional (default = false) \ No newline at end of file diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 00000000..1a0dfeef --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,63 @@ +name: Coverage + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + report: + name: Report + runs-on: ubuntu-latest + env: + CARGO_INCREMENTAL: "0" + RUSTFLAGS: "-Z profile -C codegen-units=1 -C inline-threshold=0 -C link-dead-code -C overflow-checks=off -C panic=abort -Z panic_abort_tests" + RUSTDOCFLAGS: "-Z profile -C codegen-units=1 -C inline-threshold=0 -C link-dead-code -C overflow-checks=off -C panic=abort -Z panic_abort_tests" + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v3 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + components: llvm-tools-preview + + - id: cache + name: Enable Workflow Cache + uses: Swatinem/rust-cache@v2 + + - id: tools + name: Install Tools + uses: taiki-e/install-action@v2 + with: + tool: grcov + + - id: imdl + name: Install Intermodal + run: cargo install imdl + + - id: check + name: Run Build Checks + run: cargo check --workspace --all-targets --all-features + + - id: test + name: Run Unit Tests + run: cargo test --workspace --all-targets --all-features + + - id: coverage + name: Generate Coverage Report + uses: alekitto/grcov@v0.2 + + - id: upload + name: Upload Coverage Report + uses: codecov/codecov-action@v3 + with: + files: ${{ steps.coverage.outputs.report }} + verbose: true + fail_ci_if_error: true diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..f0878195 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +coverage: + status: + project: + default: + target: auto + threshold: 0.5% diff --git a/project-words.txt b/project-words.txt index 59fe8833..31530f59 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1,5 +1,6 @@ actix addrs +alekitto AUTOINCREMENT bencode bencoded @@ -7,6 +8,8 @@ Benoit binascii btih chrono +codecov +codegen compatiblelicenses creativecommons creds @@ -16,6 +19,8 @@ datetime DATETIME Dont dotless +dtolnay +grcov Grünwald hasher Hasher @@ -27,6 +32,7 @@ imdl indexadmin indexmap infohash +Intermodal jsonwebtoken leechers Leechers @@ -49,6 +55,8 @@ reqwest Roadmap ROADMAP rowid +RUSTDOCFLAGS +RUSTFLAGS sgxj singlepart sqlx @@ -56,6 +64,8 @@ strftime sublicensable sublist subpoints +Swatinem +taiki tempdir tempfile thiserror From c3e61eabf3048676021161dcb80b45fb6e644d2c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 3 Aug 2023 18:15:11 +0100 Subject: [PATCH 279/357] fix: [#242] wrong infohash when info dict contains source field When you define a "source" field value in the "info" dictionary inside the torrent file the field changes the infohash value. We did not save that field in the database and in the in-memory struct `TorrentInfo` so the calculated infohash was wrong becuase this field belongs to the `info` key. --- .../mysql/20230803160604_torrust_torrents_add_source.sql | 1 + .../sqlite3/20230803160604_torrust_torrents_add_source.sql | 1 + src/databases/mysql.rs | 3 ++- src/databases/sqlite.rs | 3 ++- src/models/torrent_file.rs | 3 +++ src/services/torrent_file.rs | 1 + 6 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 migrations/mysql/20230803160604_torrust_torrents_add_source.sql create mode 100644 migrations/sqlite3/20230803160604_torrust_torrents_add_source.sql diff --git a/migrations/mysql/20230803160604_torrust_torrents_add_source.sql b/migrations/mysql/20230803160604_torrust_torrents_add_source.sql new file mode 100644 index 00000000..5bee0b38 --- /dev/null +++ b/migrations/mysql/20230803160604_torrust_torrents_add_source.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN source TEXT DEFAULT NULL diff --git a/migrations/sqlite3/20230803160604_torrust_torrents_add_source.sql b/migrations/sqlite3/20230803160604_torrust_torrents_add_source.sql new file mode 100644 index 00000000..5bee0b38 --- /dev/null +++ b/migrations/sqlite3/20230803160604_torrust_torrents_add_source.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN source TEXT DEFAULT NULL diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 95a77812..ecde3bf7 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -441,7 +441,7 @@ impl Database for Mysql { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -451,6 +451,7 @@ impl Database for Mysql { .bind(torrent.info.piece_length) .bind(private) .bind(root_hash) + .bind(torrent.info.source.clone()) .execute(&self.pool) .await .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 2c69169b..98cb836f 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -431,7 +431,7 @@ impl Database for Sqlite { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -441,6 +441,7 @@ impl Database for Sqlite { .bind(torrent.info.piece_length) .bind(private) .bind(root_hash) + .bind(torrent.info.source.clone()) .execute(&self.pool) .await .map(|v| v.last_insert_rowid()) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 8489d90b..87689fe8 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -38,6 +38,8 @@ pub struct TorrentInfo { #[serde(default)] #[serde(rename = "root hash")] pub root_hash: Option, + #[serde(default)] + pub source: Option, } impl TorrentInfo { @@ -123,6 +125,7 @@ impl Torrent { private, path: None, root_hash: None, + source: None, }; // a torrent file has a root hash or a pieces key, but not both. diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 5c119930..6e474d99 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -80,6 +80,7 @@ mod tests { private: Some(0), path: None, root_hash: None, + source: None, }, announce: None, announce_list: Some(vec![]), From 6fc4050e3203bfa6bc1dc1d4064962900da7bed2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 4 Aug 2023 13:39:31 +0100 Subject: [PATCH 280/357] test: [#242] add tests for infohash calculation --- src/models/torrent_file.rs | 228 +++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 87689fe8..16080481 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -298,3 +298,231 @@ pub struct DbTorrentInfo { pub struct DbTorrentAnnounceUrl { pub tracker_url: String, } + +#[cfg(test)] +mod tests { + + mod info_hash_calculation_for_version_v1 { + + use serde_bytes::ByteBuf; + + use crate::models::torrent_file::{Torrent, TorrentInfo}; + + #[test] + fn the_parsed_torrent_file_should_calculated_the_torrent_info_hash() { + /* The sample.txt content (`mandelbrot`): + + ``` + 6d616e64656c62726f740a + ``` + + The sample.txt.torrent content: + + ``` + 6431303a6372656174656420627931383a71426974746f7272656e742076 + 342e352e3431333a6372656174696f6e2064617465693136393131343935 + 373265343a696e666f64363a6c656e67746869313165343a6e616d653130 + 3a73616d706c652e74787431323a7069656365206c656e67746869313633 + 383465363a70696563657332303ad491587f1c42dff0cb0ff5c2b8cefe22 + b3ad310a6565 + ``` + + ```json + { + "created by": "qBittorrent v4.5.4", + "creation date": 1691149572, + "info": { + "length": 11, + "name": "sample.txt", + "piece length": 16384, + "pieces": "D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A" + } + } + ``` + */ + + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: None, + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + } + + mod infohash_should_be_calculated_for { + + use serde_bytes::ByteBuf; + + use crate::models::torrent_file::{Torrent, TorrentFile, TorrentInfo}; + + #[test] + fn a_simple_single_file_torrent() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: None, + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + } + + #[test] + fn a_simple_multi_file_torrent() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: None, + files: Some(vec![TorrentFile { + path: vec!["sample.txt".to_string()], + length: sample_data_in_txt_file.len().try_into().unwrap(), + md5sum: None, + }]), + private: None, + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "aa2aca91ab650c4d249c475ca3fa604f2ccb0d2a"); + } + + #[test] + fn a_simple_single_file_torrent_with_a_source() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: None, + path: None, + root_hash: None, + source: Some("ABC".to_string()), // The tracker three-letter code + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "ccc1cf4feb59f3fa85c96c9be1ebbafcfe8a9cc8"); + } + + #[test] + fn a_simple_single_file_private_torrent() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: Some(1), + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "d3a558d0a19aaa23ba6f9f430f40924d10fefa86"); + } + } + } +} From bc04231c1d475cd323a8f6adb594c2dbaed052cc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 4 Aug 2023 14:20:18 +0100 Subject: [PATCH 281/357] doc: [#242] improve infohash generation documentaion --- src/models/info_hash.rs | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs index 1908b674..16b43bc3 100644 --- a/src/models/info_hash.rs +++ b/src/models/info_hash.rs @@ -128,7 +128,75 @@ //! //! You can hash that byte string with //! +//! > NOTICE: you need to remove the line breaks from the byte string before hashing. +//! +//! ```text +//! 64363a6c656e6774686931373232303465343a6e616d6532343a6d616e64656c62726f745f3230343878323034382e706e6731323a7069656365206c656e67746869313633383465363a7069656365733232303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c779621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290e5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f98e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61a6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866266f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e7454f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc110bb2fc2b49a585b6fc7674449365 +//! ``` +//! //! The result is a 20-char string: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +//! +//! The `info` dictionary can contain more fields, like the following example: +//! +//! ```json +//! { +//! "length": 172204, +//! "name": "mandelbrot_2048x2048.png", +//! "piece length": 16384, +//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" +//! "private": 1, +//! "md5sum": "e2ea6317cbdf0f9e223f9cc80af54000 +//! "source": "GGn", +//! } +//! ``` +//! +//! Refer to the struct [`TorrentInfo`](crate::models::torrent_file::TorrentInfo) for more info. +//! +//! Regarding the `source` field, it is not clear was was the initial intention +//! for that field. It could be an string to identify the source of the torrent. +//! But it has been used by private trackers to identify the tracker that +//! created the torrent and it's usually a three-char string. Refer to +//! for more info. +//! +//! The `md5sum` field is a string with the MD5 hash of the file. It seems is +//! not used by the protocol. +//! +//! Some fields are exclusive to `BitTorrent` v2. +//! +//! For the [`]BitTorrent` Version 1 specification](https://www.bittorrent.org/beps/bep_0003.html) there are two types of torrent +//! files: single file and multiple files. Some fields are only valid for one +//! type of torrent file. +//! +//! An example for a single-file torrent info dictionary: +//! +//! ```json +//! { +//! "length": 11, +//! "name": "sample.txt", +//! "piece length": 16384, +//! "pieces": "D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A" +//! } +//! ``` +//! +//! An example for a multi-file torrent info dictionary: +//! +//! ```json +//! { +//! "files": [ +//! { +//! "length": 11, +//! "path": [ +//! "sample.txt" +//! ] +//! } +//! ], +//! "name": "sample", +//! "piece length": 16384, +//! "pieces": "D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A" +//! } +//! ``` +//! +//! An example torrent creator implementation can be found [here](https://www.bittorrent.org/beps/bep_0052_torrent_creator.py). use std::panic::Location; use thiserror::Error; From 8fe0955c8488ef88b3eeb3c4371b0ad129bc41ab Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 4 Aug 2023 15:54:40 +0100 Subject: [PATCH 282/357] fix: clippy warning --- src/services/user.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/services/user.rs b/src/services/user.rs index b144241e..358e7431 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -129,20 +129,17 @@ impl RegistrationService { drop(self.user_repository.grant_admin_role(&user_id).await); } - if settings.mail.email_verification_enabled && opt_email.is_some() { - let mail_res = self - .mailer - .send_verification_mail( - &opt_email.expect("variable `email` is checked above"), - ®istration_form.username, - user_id, - api_base_url, - ) - .await; - - if mail_res.is_err() { - drop(self.user_repository.delete(&user_id).await); - return Err(ServiceError::FailedToSendVerificationEmail); + if settings.mail.email_verification_enabled { + if let Some(email) = opt_email { + let mail_res = self + .mailer + .send_verification_mail(&email, ®istration_form.username, user_id, api_base_url) + .await; + + if mail_res.is_err() { + drop(self.user_repository.delete(&user_id).await); + return Err(ServiceError::FailedToSendVerificationEmail); + } } } From c6346a5c678fd63f74379769e00d589d157f5232 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Aug 2023 14:07:30 +0100 Subject: [PATCH 283/357] test: [#223] run E2E with MySQL too E2E tests are only executed with SQLite. --- .github/workflows/develop.yml | 4 +- compose.yaml | 2 +- config-idx-back.local.toml | 3 +- config-idx-back.mysql.local.toml | 46 ++++++++++++++++ docker/bin/e2e/mysql/e2e-env-down.sh | 3 ++ docker/bin/e2e/mysql/e2e-env-reset.sh | 33 ++++++++++++ docker/bin/e2e/mysql/e2e-env-restart.sh | 4 ++ docker/bin/e2e/mysql/e2e-env-up.sh | 12 +++++ docker/bin/e2e/mysql/run-e2e-tests.sh | 70 +++++++++++++++++++++++++ tests/e2e/config.rs | 11 ++-- tests/e2e/environment.rs | 50 ++++++++++++++++-- 11 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 config-idx-back.mysql.local.toml create mode 100755 docker/bin/e2e/mysql/e2e-env-down.sh create mode 100755 docker/bin/e2e/mysql/e2e-env-reset.sh create mode 100755 docker/bin/e2e/mysql/e2e-env-restart.sh create mode 100755 docker/bin/e2e/mysql/e2e-env-up.sh create mode 100755 docker/bin/e2e/mysql/run-e2e-tests.sh diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 2ee931b2..77a621ea 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -29,5 +29,7 @@ jobs: - uses: taiki-e/install-action@nextest - name: Test Coverage run: cargo llvm-cov nextest - - name: E2E Tests + - name: E2E Tests with SQLite run: ./docker/bin/run-e2e-tests.sh + - name: E2E Tests with MySQL + run: ./docker/bin/e2e/mysql/run-e2e-tests.sh diff --git a/compose.yaml b/compose.yaml index 2c46d9df..8bf4741e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -87,7 +87,7 @@ services: environment: - MYSQL_ROOT_HOST=% - MYSQL_ROOT_PASSWORD=root_secret_password - - MYSQL_DATABASE=torrust_index_backend + - MYSQL_DATABASE=${TORRUST_IDX_BACK_MYSQL_DATABASE:-torrust_index_backend_e2e_testing} - MYSQL_USER=db_user - MYSQL_PASSWORD=db_user_secret_password networks: diff --git a/config-idx-back.local.toml b/config-idx-back.local.toml index 4b359800..f050b4fb 100644 --- a/config-idx-back.local.toml +++ b/config-idx-back.local.toml @@ -20,8 +20,7 @@ max_password_length = 64 secret_key = "MaxVerstappenWC2021" [database] -connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc" # SQLite -#connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL +connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc" [mail] email_verification_enabled = false diff --git a/config-idx-back.mysql.local.toml b/config-idx-back.mysql.local.toml new file mode 100644 index 00000000..beeda6e8 --- /dev/null +++ b/config-idx-back.mysql.local.toml @@ -0,0 +1,46 @@ +log_level = "info" + +[website] +name = "Torrust" + +[tracker] +url = "udp://tracker:6969" +mode = "Public" +api_url = "http://tracker:1212" +token = "MyAccessToken" +token_valid_seconds = 7257600 + +[net] +port = 3001 + +[auth] +email_on_signup = "Optional" +min_password_length = 6 +max_password_length = 64 +secret_key = "MaxVerstappenWC2021" + +[database] +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend_e2e_testing" + +[mail] +email_verification_enabled = false +from = "example@email.com" +reply_to = "noreply@email.com" +username = "" +password = "" +server = "mailcatcher" +port = 1025 + +[image_cache] +max_request_timeout_ms = 1000 +capacity = 128000000 +entry_size_limit = 4000000 +user_quota_period_seconds = 3600 +user_quota_bytes = 64000000 + +[api] +default_torrent_page_size = 10 +max_torrent_page_size = 30 + +[tracker_statistics_importer] +torrent_info_update_interval = 3600 diff --git a/docker/bin/e2e/mysql/e2e-env-down.sh b/docker/bin/e2e/mysql/e2e-env-down.sh new file mode 100755 index 00000000..5e50d101 --- /dev/null +++ b/docker/bin/e2e/mysql/e2e-env-down.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker compose down diff --git a/docker/bin/e2e/mysql/e2e-env-reset.sh b/docker/bin/e2e/mysql/e2e-env-reset.sh new file mode 100755 index 00000000..46e690f3 --- /dev/null +++ b/docker/bin/e2e/mysql/e2e-env-reset.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +./docker/bin/e2e/mysql/e2e-env-down.sh + +# Delete the databases and recreate them. + +# Index Backend + +# Database credentials +MYSQL_USER="root" +MYSQL_PASSWORD="root_secret_password" +MYSQL_HOST="localhost" +MYSQL_DATABASE="torrust_index_backend_e2e_testing" + +# Create the MySQL database for the index backend. Assumes MySQL client is installed. +echo "Creating MySQL database $MYSQL_DATABASE for E2E testing ..." +mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "DROP DATABASE IF EXISTS $MYSQL_DATABASE; CREATE DATABASE $MYSQL_DATABASE;" + +# Tracker + +# Delete tracker database +rm -f ./storage/database/torrust_tracker_e2e_testing.db + +# Generate storage directory if it does not exist +mkdir -p "./storage/database" + +# Generate the sqlite database for the tracker if it does not exist +if ! [ -f "./storage/database/torrust_tracker_e2e_testing.db" ]; then + touch ./storage/database/torrust_tracker_e2e_testing.db + echo ";" | sqlite3 ./storage/database/torrust_tracker_e2e_testing.db +fi + +./docker/bin/e2e/mysql/e2e-env-up.sh diff --git a/docker/bin/e2e/mysql/e2e-env-restart.sh b/docker/bin/e2e/mysql/e2e-env-restart.sh new file mode 100755 index 00000000..04f9f7ee --- /dev/null +++ b/docker/bin/e2e/mysql/e2e-env-restart.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +./docker/bin/e2e/mysql/e2e-env-down.sh +./docker/bin/e2e/mysql/e2e-env-up.sh diff --git a/docker/bin/e2e/mysql/e2e-env-up.sh b/docker/bin/e2e/mysql/e2e-env-up.sh new file mode 100755 index 00000000..195409b1 --- /dev/null +++ b/docker/bin/e2e/mysql/e2e-env-up.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + docker compose build + +TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.mysql.local.toml) \ + TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_backend_e2e_testing" \ + TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ + TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + docker compose up -d + diff --git a/docker/bin/e2e/mysql/run-e2e-tests.sh b/docker/bin/e2e/mysql/run-e2e-tests.sh new file mode 100755 index 00000000..14eada4a --- /dev/null +++ b/docker/bin/e2e/mysql/run-e2e-tests.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +CURRENT_USER_NAME=$(whoami) +CURRENT_USER_ID=$(id -u) +echo "User name: $CURRENT_USER_NAME" +echo "User id: $CURRENT_USER_ID" + +TORRUST_IDX_BACK_USER_UID=$CURRENT_USER_ID +TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID +export TORRUST_IDX_BACK_USER_UID +export TORRUST_TRACKER_USER_UID + +wait_for_container_to_be_healthy() { + local container_name="$1" + local max_retries="$2" + local retry_interval="$3" + local retry_count=0 + + while [ $retry_count -lt "$max_retries" ]; do + container_health="$(docker inspect --format='{{json .State.Health}}' "$container_name")" + if [ "$container_health" != "{}" ]; then + container_status="$(echo "$container_health" | jq -r '.Status')" + if [ "$container_status" == "healthy" ]; then + echo "Container $container_name is healthy" + return 0 + fi + fi + + retry_count=$((retry_count + 1)) + echo "Waiting for container $container_name to become healthy (attempt $retry_count of $max_retries)..." + sleep "$retry_interval" + done + + echo "Timeout reached, container $container_name is not healthy" + return 1 +} + +# Install tool to create torrent files +cargo install imdl || exit 1 + +cp .env.local .env || exit 1 +./bin/install.sh || exit 1 + +# Start E2E testing environment +./docker/bin/e2e/mysql/e2e-env-up.sh || exit 1 + +wait_for_container_to_be_healthy torrust-mysql-1 10 3 +# todo: implement healthchecks for tracker and backend and wait until they are healthy +#wait_for_container torrust-tracker-1 10 3 +#wait_for_container torrust-idx-back-1 10 3 +sleep 20s + +# Just to make sure that everything is up and running +docker ps + +# Database credentials +MYSQL_USER="root" +MYSQL_PASSWORD="root_secret_password" +MYSQL_HOST="localhost" +MYSQL_DATABASE="torrust_index_backend_e2e_testing" + +# Create the MySQL database for the index backend. Assumes MySQL client is installed. +echo "Creating MySQL database $MYSQL_DATABASE for for E2E testing ..." +mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE;" + +# Run E2E tests with shared app instance +TORRUST_IDX_BACK_E2E_SHARED=true TORRUST_IDX_BACK_E2E_CONFIG_PATH="./config-idx-back.mysql.local.toml" cargo test || exit 1 + +# Stop E2E testing environment +./docker/bin/e2e/mysql/e2e-env-down.sh diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index f3179f43..60595c79 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -1,7 +1,7 @@ //! Initialize configuration for the shared E2E tests environment from a //! config file `config.toml` or env var. //! -//! All environment variables are prefixed with `TORRUST_IDX_BACK_`. +//! All environment variables are prefixed with `TORRUST_IDX_BACK_E2E`. use std::env; use torrust_index_backend::config::Configuration; @@ -14,6 +14,9 @@ pub const ENV_VAR_E2E_SHARED: &str = "TORRUST_IDX_BACK_E2E_SHARED"; /// The whole `config.toml` file content. It has priority over the config file. pub const ENV_VAR_E2E_CONFIG: &str = "TORRUST_IDX_BACK_E2E_CONFIG"; +/// The `config.toml` file location. +pub const ENV_VAR_E2E_CONFIG_PATH: &str = "TORRUST_IDX_BACK_E2E_CONFIG_PATH"; + // Default values pub const ENV_VAR_E2E_DEFAULT_CONFIG_PATH: &str = "./config-idx-back.local.toml"; @@ -29,9 +32,11 @@ pub async fn init_shared_env_configuration() -> Configuration { Configuration::load_from_env_var(ENV_VAR_E2E_CONFIG).unwrap() } else { - println!("Loading configuration for E2E env from config file `{ENV_VAR_E2E_DEFAULT_CONFIG_PATH}`"); + let config_path = env::var(ENV_VAR_E2E_CONFIG_PATH).unwrap_or_else(|_| ENV_VAR_E2E_DEFAULT_CONFIG_PATH.to_string()); + + println!("Loading configuration from config file `{config_path}`"); - match Configuration::load_from_file(ENV_VAR_E2E_DEFAULT_CONFIG_PATH).await { + match Configuration::load_from_file(&config_path).await { Ok(config) => config, Err(error) => { panic!("{}", error) diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index d5022785..1dee9651 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -1,5 +1,6 @@ use std::env; +use torrust_index_backend::databases::database; use torrust_index_backend::web::api::Version; use super::config::{init_shared_env_configuration, ENV_VAR_E2E_SHARED}; @@ -95,12 +96,53 @@ impl TestEnv { } } - /// Provides the database connect URL. - /// For example: `sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc`. + /// Provides a database connect URL to connect to the database. For example: + /// + /// `sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc`. + /// + /// It's used to run SQL queries against the database needed for some tests. pub fn database_connect_url(&self) -> Option { - self.starting_settings + let internal_connect_url = self + .starting_settings .as_ref() - .map(|settings| settings.database.connect_url.clone()) + .map(|settings| settings.database.connect_url.clone()); + + match self.state() { + State::RunningShared => { + if let Some(db_path) = internal_connect_url { + let maybe_db_driver = database::get_driver(&db_path); + + return match maybe_db_driver { + Ok(db_driver) => match db_driver { + database::Driver::Sqlite3 => Some(db_path), + database::Driver::Mysql => Some(Self::overwrite_mysql_host(&db_path, "localhost")), + }, + Err(_) => None, + }; + } + None + } + State::RunningIsolated => internal_connect_url, + State::Stopped => None, + } + } + + /// It overrides the "Host" in a `SQLx` database connection URL. For example: + /// + /// For: + /// + /// `mysql://root:root_secret_password@mysql:3306/torrust_index_backend_e2e_testing`. + /// + /// It changes the `mysql` host name to `localhost`: + /// + /// `mysql://root:root_secret_password@localhost:3306/torrust_index_backend_e2e_testing`. + /// + /// For E2E tests, we use docker compose, internally the backend connects to + /// the database using the "mysql" host, which is the docker compose service + /// name, but tests connects directly to the localhost since the `MySQL` + /// is exposed to the host. + fn overwrite_mysql_host(db_path: &str, new_host: &str) -> String { + db_path.replace("@mysql:", &format!("@{new_host}:")) } fn state(&self) -> State { From 22b8f8afbaf08ae1bfd00b3a70e8985c2ff5c5e5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Aug 2023 17:05:27 +0100 Subject: [PATCH 284/357] fix: [#223] HTTP error status code trying to insert duplicate category in MySQL Using MySQL the endpoint to inser categories returns a different HTTP status code. It should return a 400 and It was returning a 500. The reason is we parse the error message and for MySQL the error message is not the same as SQLite: MySQL: ``` Error: Duplicate entry 'category name 118802' for key 'torrust_categories.name' ``` It has been changed but we should now rely on concrete error messages. Besides we should not relay on the database contrains, mahybe we should check in the handler that the category does not exist. --- src/databases/mysql.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index ecde3bf7..b35fede6 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -250,7 +250,9 @@ impl Database for Mysql { .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("UNIQUE") { + if err.message().contains("Duplicate entry") { + // Example error message when you try to insert a duplicate category: + // Error: Duplicate entry 'category name SAMPLE_NAME' for key 'torrust_categories.name' database::Error::CategoryAlreadyExists } else { database::Error::Error From 0b2967864388560ac445bd1225650a83fdeeff8a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 9 Aug 2023 18:15:36 +0100 Subject: [PATCH 285/357] refactor(ci): reorganize E2E testing scripts --- .github/workflows/develop.yml | 7 +-- ....toml => config-idx-back.sqlite.local.toml | 0 docker/bin/e2e-env-down.sh | 3 - docker/bin/e2e-env-restart.sh | 4 -- docker/bin/e2e/mysql/e2e-env-down.sh | 3 - docker/bin/e2e/mysql/e2e-env-reset.sh | 4 +- docker/bin/e2e/mysql/e2e-env-restart.sh | 2 +- docker/bin/e2e/{mysql => }/run-e2e-tests.sh | 31 +++++++++- docker/bin/{ => e2e/sqlite}/e2e-env-reset.sh | 6 +- docker/bin/e2e/sqlite/e2e-env-restart.sh | 4 ++ docker/bin/{ => e2e/sqlite}/e2e-env-up.sh | 2 +- docker/bin/run-e2e-tests.sh | 60 ------------------- 12 files changed, 43 insertions(+), 83 deletions(-) rename config-idx-back.local.toml => config-idx-back.sqlite.local.toml (100%) delete mode 100755 docker/bin/e2e-env-down.sh delete mode 100755 docker/bin/e2e-env-restart.sh delete mode 100755 docker/bin/e2e/mysql/e2e-env-down.sh rename docker/bin/e2e/{mysql => }/run-e2e-tests.sh (69%) rename docker/bin/{ => e2e/sqlite}/e2e-env-reset.sh (89%) create mode 100755 docker/bin/e2e/sqlite/e2e-env-restart.sh rename docker/bin/{ => e2e/sqlite}/e2e-env-up.sh (82%) delete mode 100755 docker/bin/run-e2e-tests.sh diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 77a621ea..1558176f 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -29,7 +29,6 @@ jobs: - uses: taiki-e/install-action@nextest - name: Test Coverage run: cargo llvm-cov nextest - - name: E2E Tests with SQLite - run: ./docker/bin/run-e2e-tests.sh - - name: E2E Tests with MySQL - run: ./docker/bin/e2e/mysql/run-e2e-tests.sh + - name: E2E Tests + run: ./docker/bin/e2e/run-e2e-tests.sh + diff --git a/config-idx-back.local.toml b/config-idx-back.sqlite.local.toml similarity index 100% rename from config-idx-back.local.toml rename to config-idx-back.sqlite.local.toml diff --git a/docker/bin/e2e-env-down.sh b/docker/bin/e2e-env-down.sh deleted file mode 100755 index 5e50d101..00000000 --- a/docker/bin/e2e-env-down.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker compose down diff --git a/docker/bin/e2e-env-restart.sh b/docker/bin/e2e-env-restart.sh deleted file mode 100755 index 84731380..00000000 --- a/docker/bin/e2e-env-restart.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -./docker/bin/e2e-env-down.sh -./docker/bin/e2e-env-up.sh diff --git a/docker/bin/e2e/mysql/e2e-env-down.sh b/docker/bin/e2e/mysql/e2e-env-down.sh deleted file mode 100755 index 5e50d101..00000000 --- a/docker/bin/e2e/mysql/e2e-env-down.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker compose down diff --git a/docker/bin/e2e/mysql/e2e-env-reset.sh b/docker/bin/e2e/mysql/e2e-env-reset.sh index 46e690f3..d8dc0764 100755 --- a/docker/bin/e2e/mysql/e2e-env-reset.sh +++ b/docker/bin/e2e/mysql/e2e-env-reset.sh @@ -1,9 +1,9 @@ #!/bin/bash -./docker/bin/e2e/mysql/e2e-env-down.sh - # Delete the databases and recreate them. +docker compose down + # Index Backend # Database credentials diff --git a/docker/bin/e2e/mysql/e2e-env-restart.sh b/docker/bin/e2e/mysql/e2e-env-restart.sh index 04f9f7ee..92088547 100755 --- a/docker/bin/e2e/mysql/e2e-env-restart.sh +++ b/docker/bin/e2e/mysql/e2e-env-restart.sh @@ -1,4 +1,4 @@ #!/bin/bash -./docker/bin/e2e/mysql/e2e-env-down.sh +docker compose down ./docker/bin/e2e/mysql/e2e-env-up.sh diff --git a/docker/bin/e2e/mysql/run-e2e-tests.sh b/docker/bin/e2e/run-e2e-tests.sh similarity index 69% rename from docker/bin/e2e/mysql/run-e2e-tests.sh rename to docker/bin/e2e/run-e2e-tests.sh index 14eada4a..a55b6315 100755 --- a/docker/bin/e2e/mysql/run-e2e-tests.sh +++ b/docker/bin/e2e/run-e2e-tests.sh @@ -35,12 +35,39 @@ wait_for_container_to_be_healthy() { return 1 } -# Install tool to create torrent files +# Install tool to create torrent files. +# It's needed by some tests to generate and parse test torrent files. cargo install imdl || exit 1 +# Install app (no docker) that will run the test suite against the E2E testing +# environment (in docker). cp .env.local .env || exit 1 ./bin/install.sh || exit 1 +# TEST USING SQLITE +echo "Running E2E tests using SQLite ..." + +# Start E2E testing environment +./docker/bin/e2e/sqlite/e2e-env-up.sh || exit 1 + +wait_for_container_to_be_healthy torrust-mysql-1 10 3 +# todo: implement healthchecks for tracker and backend and wait until they are healthy +#wait_for_container torrust-tracker-1 10 3 +#wait_for_container torrust-idx-back-1 10 3 +sleep 20s + +# Just to make sure that everything is up and running +docker ps + +# Run E2E tests with shared app instance +TORRUST_IDX_BACK_E2E_SHARED=true TORRUST_IDX_BACK_E2E_CONFIG_PATH="./config-idx-back.sqlite.local.toml" cargo test || exit 1 + +# Stop E2E testing environment +docker compose down + +# TEST USING MYSQL +echo "Running E2E tests using MySQL ..." + # Start E2E testing environment ./docker/bin/e2e/mysql/e2e-env-up.sh || exit 1 @@ -67,4 +94,4 @@ mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "CREATE DATABASE IF NOT TORRUST_IDX_BACK_E2E_SHARED=true TORRUST_IDX_BACK_E2E_CONFIG_PATH="./config-idx-back.mysql.local.toml" cargo test || exit 1 # Stop E2E testing environment -./docker/bin/e2e/mysql/e2e-env-down.sh +docker compose down diff --git a/docker/bin/e2e-env-reset.sh b/docker/bin/e2e/sqlite/e2e-env-reset.sh similarity index 89% rename from docker/bin/e2e-env-reset.sh rename to docker/bin/e2e/sqlite/e2e-env-reset.sh index ae7e3aff..8eeefd6f 100755 --- a/docker/bin/e2e-env-reset.sh +++ b/docker/bin/e2e/sqlite/e2e-env-reset.sh @@ -1,8 +1,8 @@ #!/bin/bash -# Delete the SQLite databases and recreate them. +# Delete the databases and recreate them. -./docker/bin/e2e-env-down.sh +docker compose down rm -f ./storage/database/torrust_index_backend_e2e_testing.db rm -f ./storage/database/torrust_tracker_e2e_testing.db @@ -23,4 +23,4 @@ if ! [ -f "./storage/database/torrust_tracker_e2e_testing.db" ]; then echo ";" | sqlite3 ./storage/database/torrust_tracker_e2e_testing.db fi -./docker/bin/e2e-env-up.sh +./docker/bin/e2e/sqlite/e2e-env-up.sh diff --git a/docker/bin/e2e/sqlite/e2e-env-restart.sh b/docker/bin/e2e/sqlite/e2e-env-restart.sh new file mode 100755 index 00000000..768f50cb --- /dev/null +++ b/docker/bin/e2e/sqlite/e2e-env-restart.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +docker compose down +./docker/bin/e2e/sqlite/e2e-env-up.sh diff --git a/docker/bin/e2e-env-up.sh b/docker/bin/e2e/sqlite/e2e-env-up.sh similarity index 82% rename from docker/bin/e2e-env-up.sh rename to docker/bin/e2e/sqlite/e2e-env-up.sh index fd7cd427..25911757 100755 --- a/docker/bin/e2e-env-up.sh +++ b/docker/bin/e2e/sqlite/e2e-env-up.sh @@ -4,7 +4,7 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ docker compose build TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ - TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.local.toml) \ + TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.sqlite.local.toml) \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ docker compose up -d diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh deleted file mode 100755 index 2b0c0812..00000000 --- a/docker/bin/run-e2e-tests.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash - -CURRENT_USER_NAME=$(whoami) -CURRENT_USER_ID=$(id -u) -echo "User name: $CURRENT_USER_NAME" -echo "User id: $CURRENT_USER_ID" - -TORRUST_IDX_BACK_USER_UID=$CURRENT_USER_ID -TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID -export TORRUST_IDX_BACK_USER_UID -export TORRUST_TRACKER_USER_UID - -wait_for_container_to_be_healthy() { - local container_name="$1" - local max_retries="$2" - local retry_interval="$3" - local retry_count=0 - - while [ $retry_count -lt "$max_retries" ]; do - container_health="$(docker inspect --format='{{json .State.Health}}' "$container_name")" - if [ "$container_health" != "{}" ]; then - container_status="$(echo "$container_health" | jq -r '.Status')" - if [ "$container_status" == "healthy" ]; then - echo "Container $container_name is healthy" - return 0 - fi - fi - - retry_count=$((retry_count + 1)) - echo "Waiting for container $container_name to become healthy (attempt $retry_count of $max_retries)..." - sleep "$retry_interval" - done - - echo "Timeout reached, container $container_name is not healthy" - return 1 -} - -# Install tool to create torrent files -cargo install imdl || exit 1 - -cp .env.local .env || exit 1 -./bin/install.sh || exit 1 - -# Start E2E testing environment -./docker/bin/e2e-env-up.sh || exit 1 - -wait_for_container_to_be_healthy torrust-mysql-1 10 3 -# todo: implement healthchecks for tracker and backend and wait until they are healthy -#wait_for_container torrust-tracker-1 10 3 -#wait_for_container torrust-idx-back-1 10 3 -sleep 20s - -# Just to make sure that everything is up and running -docker ps - -# Run E2E tests with shared app instance -TORRUST_IDX_BACK_E2E_SHARED=true cargo test || exit 1 - -# Stop E2E testing environment -./docker/bin/e2e-env-down.sh From 35a5430f2a5203390a67c575d9b7a5bbdd43f8f0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 21 Aug 2023 13:16:38 +0100 Subject: [PATCH 286/357] feat: increase max request body size to 10MB Torrent files containing a lot of files (for example datasets) are big. In the future, this should be a config option. --- src/web/api/v1/routes.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 996d3a6c..099a1921 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -2,6 +2,7 @@ use std::env; use std::sync::Arc; +use axum::extract::DefaultBodyLimit; use axum::routing::get; use axum::Router; use tower_http::cors::CorsLayer; @@ -35,9 +36,11 @@ pub fn router(app_data: Arc) -> Router { .route("/", get(about_page_handler).with_state(app_data)) .nest(&format!("/{API_VERSION_URL_PREFIX}"), v1_api_routes); - if env::var(ENV_VAR_CORS_PERMISSIVE).is_ok() { + let router = if env::var(ENV_VAR_CORS_PERMISSIVE).is_ok() { router.layer(CorsLayer::permissive()) } else { router - } + }; + + router.layer(DefaultBodyLimit::max(10_485_760)) } From a8aad7a482fb6fd681c14cd3ee06b566ad60d0ff Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 21 Aug 2023 14:29:26 +0100 Subject: [PATCH 287/357] fix: clippy errors --- src/config.rs | 2 +- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/services/category.rs | 2 +- src/services/tag.rs | 4 ++-- src/web/api/v1/contexts/category/handlers.rs | 2 +- src/web/api/v1/contexts/tag/handlers.rs | 4 ++-- src/web/api/v1/contexts/user/handlers.rs | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/config.rs b/src/config.rs index 5a9f1713..abf596d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -318,7 +318,7 @@ impl Configuration { warn!("No config file found. Creating default config file ..."); let config = Configuration::default(); - let _ = config.save_to_file(config_path).await; + let () = config.save_to_file(config_path).await; return Err(ConfigError::Message(format!( "No config file found. Created default config file in {config_path}. Edit the file and start the application." diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index b35fede6..5b9f1ca4 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -510,7 +510,7 @@ impl Database for Mysql { let announce_urls = announce_urls.iter().flatten().collect::>(); for tracker_url in &announce_urls { - let _ = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") + let () = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") .bind(torrent_id) .bind(tracker_url) .execute(&mut tx) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 98cb836f..5e8d5a7d 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -498,7 +498,7 @@ impl Database for Sqlite { let announce_urls = announce_urls.iter().flatten().collect::>(); for tracker_url in &announce_urls { - let _ = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") + let () = query("INSERT INTO torrust_torrent_announce_urls (torrent_id, tracker_url) VALUES (?, ?)") .bind(torrent_id) .bind(tracker_url) .execute(&mut tx) diff --git a/src/services/category.rs b/src/services/category.rs index dbce9023..548a2374 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -65,7 +65,7 @@ impl Service { } match self.category_repository.delete(category_name).await { - Ok(_) => Ok(()), + Ok(()) => Ok(()), Err(e) => match e { DatabaseError::CategoryNotFound => Err(ServiceError::CategoryNotFound), _ => Err(ServiceError::DatabaseError), diff --git a/src/services/tag.rs b/src/services/tag.rs index b766a14b..9bac69b4 100644 --- a/src/services/tag.rs +++ b/src/services/tag.rs @@ -39,7 +39,7 @@ impl Service { } match self.tag_repository.add(tag_name).await { - Ok(_) => Ok(()), + Ok(()) => Ok(()), Err(e) => match e { DatabaseError::TagAlreadyExists => Err(ServiceError::TagAlreadyExists), _ => Err(ServiceError::DatabaseError), @@ -65,7 +65,7 @@ impl Service { } match self.tag_repository.delete(tag_id).await { - Ok(_) => Ok(()), + Ok(()) => Ok(()), Err(e) => match e { DatabaseError::TagNotFound => Err(ServiceError::TagNotFound), _ => Err(ServiceError::DatabaseError), diff --git a/src/web/api/v1/contexts/category/handlers.rs b/src/web/api/v1/contexts/category/handlers.rs index bd66f53a..da0c1209 100644 --- a/src/web/api/v1/contexts/category/handlers.rs +++ b/src/web/api/v1/contexts/category/handlers.rs @@ -81,7 +81,7 @@ pub async fn delete_handler( }; match app_data.category_service.delete_category(&category_form.name, &user_id).await { - Ok(_) => deleted_category(&category_form.name).into_response(), + Ok(()) => deleted_category(&category_form.name).into_response(), Err(error) => error.into_response(), } } diff --git a/src/web/api/v1/contexts/tag/handlers.rs b/src/web/api/v1/contexts/tag/handlers.rs index feb0a745..04293bad 100644 --- a/src/web/api/v1/contexts/tag/handlers.rs +++ b/src/web/api/v1/contexts/tag/handlers.rs @@ -52,7 +52,7 @@ pub async fn add_handler( }; match app_data.tag_service.add_tag(&add_tag_form.name, &user_id).await { - Ok(_) => added_tag(&add_tag_form.name).into_response(), + Ok(()) => added_tag(&add_tag_form.name).into_response(), Err(error) => error.into_response(), } } @@ -77,7 +77,7 @@ pub async fn delete_handler( }; match app_data.tag_service.delete_tag(&delete_tag_form.tag_id, &user_id).await { - Ok(_) => deleted_tag(delete_tag_form.tag_id).into_response(), + Ok(()) => deleted_tag(delete_tag_form.tag_id).into_response(), Err(error) => error.into_response(), } } diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index 51fb041f..c6d69224 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -145,7 +145,7 @@ pub async fn ban_handler( }; match app_data.ban_service.ban_user(&to_be_banned_username.0, &user_id).await { - Ok(_) => Json(OkResponseData { + Ok(()) => Json(OkResponseData { data: format!("Banned user: {}", to_be_banned_username.0), }) .into_response(), From ce50f26d4ea18de7b210dc1a673bca81dfa80564 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 21 Aug 2023 17:25:53 +0100 Subject: [PATCH 288/357] feat: [#257] improve bad request error message for uploading Add a custom error for each case so that the Bad Request response constains a descriptive error message. --- src/errors.rs | 6 +- src/models/torrent.rs | 12 +--- src/models/torrent_file.rs | 1 + src/services/torrent.rs | 77 +++++++++++++++------ src/web/api/v1/contexts/torrent/errors.rs | 61 ++++++++++++++++ src/web/api/v1/contexts/torrent/handlers.rs | 73 ++++++++----------- src/web/api/v1/contexts/torrent/mod.rs | 1 + src/web/api/v1/responses.rs | 3 +- 8 files changed, 154 insertions(+), 80 deletions(-) create mode 100644 src/web/api/v1/contexts/torrent/errors.rs diff --git a/src/errors.rs b/src/errors.rs index 01b64d2d..c3cd08ea 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -97,8 +97,8 @@ pub enum ServiceError { #[display(fmt = "Torrent title is too short.")] InvalidTorrentTitleLength, - #[display(fmt = "Bad request.")] - BadRequest, + #[display(fmt = "Some mandatory metadata fields are missing.")] + MissingMandatoryMetadataFields, #[display(fmt = "Selected category does not exist.")] InvalidCategory, @@ -223,7 +223,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST, ServiceError::InvalidFileType => StatusCode::BAD_REQUEST, ServiceError::InvalidTorrentTitleLength => StatusCode::BAD_REQUEST, - ServiceError::BadRequest => StatusCode::BAD_REQUEST, + ServiceError::MissingMandatoryMetadataFields => StatusCode::BAD_REQUEST, ServiceError::InvalidCategory => StatusCode::BAD_REQUEST, ServiceError::InvalidTag => StatusCode::BAD_REQUEST, ServiceError::Unauthorized => StatusCode::FORBIDDEN, diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 194b0c10..66a9616d 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize}; use super::torrent_tag::TagId; use crate::errors::ServiceError; -use crate::models::torrent_file::Torrent; #[allow(clippy::module_name_repetitions)] pub type TorrentId = i64; @@ -23,13 +22,6 @@ pub struct TorrentListing { pub leechers: i64, } -#[allow(clippy::module_name_repetitions)] -#[derive(Debug)] -pub struct AddTorrentRequest { - pub metadata: Metadata, - pub torrent: Torrent, -} - #[derive(Debug, Deserialize)] pub struct Metadata { pub title: String, @@ -43,10 +35,10 @@ impl Metadata { /// /// # Errors /// - /// This function will return an `BadRequest` error if the `title` or the `category` is empty. + /// This function will return an error if the any of the mandatory metadata fields are missing. pub fn verify(&self) -> Result<(), ServiceError> { if self.title.is_empty() || self.category.is_empty() { - Err(ServiceError::BadRequest) + Err(ServiceError::MissingMandatoryMetadataFields) } else { Ok(()) } diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 16080481..125a457e 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -229,6 +229,7 @@ impl Torrent { #[must_use] pub fn info_hash(&self) -> String { + // todo: return an InfoHash struct from_bytes(&self.calculate_info_hash_as_bytes()).to_lowercase() } diff --git a/src/services/torrent.rs b/src/services/torrent.rs index e8e6cef9..ec3d63a8 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -11,11 +11,12 @@ use crate::errors::ServiceError; use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::{DeletedTorrentResponse, TorrentResponse, TorrentsResponse}; -use crate::models::torrent::{AddTorrentRequest, TorrentId, TorrentListing}; +use crate::models::torrent::{Metadata, TorrentId, TorrentListing}; use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::user::UserId; use crate::tracker::statistics_importer::StatisticsImporter; +use crate::utils::parse_torrent; use crate::{tracker, AsCSV}; const MIN_TORRENT_TITLE_LENGTH: usize = 3; @@ -34,6 +35,14 @@ pub struct Index { torrent_listing_generator: Arc, } +pub struct AddTorrentRequest { + pub title: String, + pub description: String, + pub category: String, + pub tags: Vec, + pub torrent_buffer: Vec, +} + /// User request to generate a torrent listing. #[derive(Debug, Deserialize)] pub struct ListingRequest { @@ -101,46 +110,75 @@ impl Index { /// * Unable to insert the torrent into the database. /// * Unable to add the torrent to the whitelist. /// * Torrent title is too short. - pub async fn add_torrent(&self, mut torrent_request: AddTorrentRequest, user_id: UserId) -> Result { + /// + /// # Panics + /// + /// This function will panic if: + /// + /// * Unable to parse the torrent info-hash. + pub async fn add_torrent( + &self, + add_torrent_form: AddTorrentRequest, + user_id: UserId, + ) -> Result<(TorrentId, InfoHash), ServiceError> { + let metadata = Metadata { + title: add_torrent_form.title, + description: add_torrent_form.description, + category: add_torrent_form.category, + tags: add_torrent_form.tags, + }; + + metadata.verify()?; + + let mut torrent = + parse_torrent::decode_torrent(&add_torrent_form.torrent_buffer).map_err(|_| ServiceError::InvalidTorrentFile)?; + + // Make sure that the pieces key has a length that is a multiple of 20 + if let Some(pieces) = torrent.info.pieces.as_ref() { + if pieces.as_ref().len() % 20 != 0 { + return Err(ServiceError::InvalidTorrentPiecesLength); + } + } + let _user = self.user_repository.get_compact(&user_id).await?; - torrent_request.torrent.set_announce_urls(&self.configuration).await; + torrent.set_announce_urls(&self.configuration).await; - if torrent_request.metadata.title.len() < MIN_TORRENT_TITLE_LENGTH { + if metadata.title.len() < MIN_TORRENT_TITLE_LENGTH { return Err(ServiceError::InvalidTorrentTitleLength); } let category = self .category_repository - .get_by_name(&torrent_request.metadata.category) + .get_by_name(&metadata.category) .await .map_err(|_| ServiceError::InvalidCategory)?; - let torrent_id = self.torrent_repository.add(&torrent_request, user_id, category).await?; + let torrent_id = self.torrent_repository.add(&torrent, &metadata, user_id, category).await?; + let info_hash: InfoHash = torrent + .info_hash() + .parse() + .expect("the parsed torrent should have a valid info hash"); drop( self.tracker_statistics_importer - .import_torrent_statistics(torrent_id, &torrent_request.torrent.info_hash()) + .import_torrent_statistics(torrent_id, &torrent.info_hash()) .await, ); // We always whitelist the torrent on the tracker because even if the tracker mode is `public` // it could be changed to `private` later on. - if let Err(e) = self - .tracker_service - .whitelist_info_hash(torrent_request.torrent.info_hash()) - .await - { + if let Err(e) = self.tracker_service.whitelist_info_hash(torrent.info_hash()).await { // If the torrent can't be whitelisted somehow, remove the torrent from database drop(self.torrent_repository.delete(&torrent_id).await); return Err(e); } self.torrent_tag_repository - .link_torrent_to_tags(&torrent_id, &torrent_request.metadata.tags) + .link_torrent_to_tags(&torrent_id, &metadata.tags) .await?; - Ok(torrent_id) + Ok((torrent_id, info_hash)) } /// Gets a torrent from the Index. @@ -436,18 +474,13 @@ impl DbTorrentRepository { /// This function will return an error there is a database error. pub async fn add( &self, - torrent_request: &AddTorrentRequest, + torrent: &Torrent, + metadata: &Metadata, user_id: UserId, category: Category, ) -> Result { self.database - .insert_torrent_and_get_id( - &torrent_request.torrent, - user_id, - category.category_id, - &torrent_request.metadata.title, - &torrent_request.metadata.description, - ) + .insert_torrent_and_get_id(torrent, user_id, category.category_id, &metadata.title, &metadata.description) .await } diff --git a/src/web/api/v1/contexts/torrent/errors.rs b/src/web/api/v1/contexts/torrent/errors.rs new file mode 100644 index 00000000..9bf24d48 --- /dev/null +++ b/src/web/api/v1/contexts/torrent/errors.rs @@ -0,0 +1,61 @@ +use axum::response::{IntoResponse, Response}; +use derive_more::{Display, Error}; +use hyper::StatusCode; + +use crate::web::api::v1::responses::{json_error_response, ErrorResponseData}; + +#[derive(Debug, Display, PartialEq, Eq, Error)] +pub enum Request { + #[display(fmt = "torrent title bytes are nota valid UTF8 string.")] + TitleIsNotValidUtf8, + + #[display(fmt = "torrent description bytes are nota valid UTF8 string.")] + DescriptionIsNotValidUtf8, + + #[display(fmt = "torrent category bytes are nota valid UTF8 string.")] + CategoryIsNotValidUtf8, + + #[display(fmt = "torrent tags arrays bytes are nota valid UTF8 string array.")] + TagsArrayIsNotValidUtf8, + + #[display(fmt = "torrent tags string is not a valid JSON.")] + TagsArrayIsNotValidJson, + + #[display(fmt = "upload torrent request header `content-type` should be `application/x-bittorrent`.")] + InvalidFileType, + + #[display(fmt = "cannot write uploaded torrent bytes (binary file) into memory.")] + CannotWriteChunkFromUploadedBinary, + + #[display(fmt = "cannot read a chunk of bytes from the uploaded torrent file. Review the request body size limit.")] + CannotReadChunkFromUploadedBinary, + + #[display(fmt = "provided path param for Info-hash is not valid.")] + InvalidInfoHashParam, +} + +impl IntoResponse for Request { + fn into_response(self) -> Response { + json_error_response( + http_status_code_for_handler_error(&self), + &ErrorResponseData { error: self.to_string() }, + ) + } +} + +#[must_use] +pub fn http_status_code_for_handler_error(error: &Request) -> StatusCode { + #[allow(clippy::match_same_arms)] + match error { + Request::TitleIsNotValidUtf8 => StatusCode::BAD_REQUEST, + Request::DescriptionIsNotValidUtf8 => StatusCode::BAD_REQUEST, + Request::CategoryIsNotValidUtf8 => StatusCode::BAD_REQUEST, + Request::TagsArrayIsNotValidUtf8 => StatusCode::BAD_REQUEST, + Request::TagsArrayIsNotValidJson => StatusCode::BAD_REQUEST, + Request::InvalidFileType => StatusCode::BAD_REQUEST, + Request::InvalidInfoHashParam => StatusCode::BAD_REQUEST, + // Internal errors processing the request + Request::CannotWriteChunkFromUploadedBinary => StatusCode::INTERNAL_SERVER_ERROR, + Request::CannotReadChunkFromUploadedBinary => StatusCode::INTERNAL_SERVER_ERROR, + } +} diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 2621ff9c..31cd29ca 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -10,14 +10,14 @@ use axum::Json; use serde::Deserialize; use uuid::Uuid; +use super::errors; use super::forms::UpdateTorrentInfoForm; use super::responses::{new_torrent_response, torrent_file_response}; use crate::common::AppData; use crate::errors::ServiceError; use crate::models::info_hash::InfoHash; -use crate::models::torrent::{AddTorrentRequest, Metadata}; use crate::models::torrent_tag::TagId; -use crate::services::torrent::ListingRequest; +use crate::services::torrent::{AddTorrentRequest, ListingRequest}; use crate::services::torrent_file::generate_random_torrent; use crate::utils::parse_torrent; use crate::web::api::v1::auth::get_optional_logged_in_user; @@ -43,15 +43,13 @@ pub async fn upload_torrent_handler( Err(error) => return error.into_response(), }; - let torrent_request = match get_torrent_request_from_payload(multipart).await { + let add_torrent_form = match build_add_torrent_request_from_payload(multipart).await { Ok(torrent_request) => torrent_request, Err(error) => return error.into_response(), }; - let info_hash = torrent_request.torrent.info_hash().clone(); - - match app_data.torrent_service.add_torrent(torrent_request, user_id).await { - Ok(torrent_id) => new_torrent_response(torrent_id, &info_hash).into_response(), + match app_data.torrent_service.add_torrent(add_torrent_form, user_id).await { + Ok(torrent_ids) => new_torrent_response(torrent_ids.0, &torrent_ids.1.to_hex_string()).into_response(), Err(error) => error.into_response(), } } @@ -69,7 +67,7 @@ impl InfoHashParam { /// /// # Errors /// -/// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. +/// Returns an error if the torrent info-hash is invalid. #[allow(clippy::unused_async)] pub async fn download_torrent_handler( State(app_data): State>, @@ -77,7 +75,7 @@ pub async fn download_torrent_handler( Path(info_hash): Path, ) -> Response { let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { - return ServiceError::BadRequest.into_response(); + return errors::Request::InvalidInfoHashParam.into_response(); }; let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { @@ -127,7 +125,7 @@ pub async fn get_torrent_info_handler( Path(info_hash): Path, ) -> Response { let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { - return ServiceError::BadRequest.into_response(); + return errors::Request::InvalidInfoHashParam.into_response(); }; let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { @@ -158,7 +156,7 @@ pub async fn update_torrent_info_handler( extract::Json(update_torrent_info_form): extract::Json, ) -> Response { let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { - return ServiceError::BadRequest.into_response(); + return errors::Request::InvalidInfoHashParam.into_response(); }; let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { @@ -199,7 +197,7 @@ pub async fn delete_torrent_handler( Path(info_hash): Path, ) -> Response { let Ok(info_hash) = InfoHash::from_str(&info_hash.lowercase()) else { - return ServiceError::BadRequest.into_response(); + return errors::Request::InvalidInfoHashParam.into_response(); }; let user_id = match app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await { @@ -231,11 +229,11 @@ impl UuidParam { /// /// # Errors /// -/// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. +/// Returns an error if the torrent info-hash is invalid. #[allow(clippy::unused_async)] pub async fn create_random_torrent_handler(State(_app_data): State>, Path(uuid): Path) -> Response { let Ok(uuid) = Uuid::parse_str(&uuid.value()) else { - return ServiceError::BadRequest.into_response(); + return errors::Request::InvalidInfoHashParam.into_response(); }; let torrent = generate_random_torrent(uuid); @@ -259,7 +257,7 @@ pub async fn create_random_torrent_handler(State(_app_data): State> /// - The multipart content is invalid. /// - The torrent file pieces key has a length that is not a multiple of 20. /// - The binary data cannot be decoded as a torrent file. -async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { +async fn build_add_torrent_request_from_payload(mut payload: Multipart) -> Result { let torrent_buffer = vec![0u8]; let mut torrent_cursor = Cursor::new(torrent_buffer); @@ -277,69 +275,56 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let data = field.bytes().await.unwrap(); if data.is_empty() { continue; }; - description = String::from_utf8(data.to_vec()).map_err(|_| ServiceError::BadRequest)?; + description = String::from_utf8(data.to_vec()).map_err(|_| errors::Request::DescriptionIsNotValidUtf8)?; } "category" => { let data = field.bytes().await.unwrap(); if data.is_empty() { continue; }; - category = String::from_utf8(data.to_vec()).map_err(|_| ServiceError::BadRequest)?; + category = String::from_utf8(data.to_vec()).map_err(|_| errors::Request::CategoryIsNotValidUtf8)?; } "tags" => { let data = field.bytes().await.unwrap(); if data.is_empty() { continue; }; - let string_data = String::from_utf8(data.to_vec()).map_err(|_| ServiceError::BadRequest)?; - tags = serde_json::from_str(&string_data).map_err(|_| ServiceError::BadRequest)?; + let string_data = String::from_utf8(data.to_vec()).map_err(|_| errors::Request::TagsArrayIsNotValidUtf8)?; + tags = serde_json::from_str(&string_data).map_err(|_| errors::Request::TagsArrayIsNotValidJson)?; } "torrent" => { let content_type = field.content_type().unwrap(); if content_type != "application/x-bittorrent" { - return Err(ServiceError::InvalidFileType); + return Err(errors::Request::InvalidFileType); } - while let Some(chunk) = field.chunk().await.map_err(|_| (ServiceError::BadRequest))? { - torrent_cursor.write_all(&chunk)?; + while let Some(chunk) = field + .chunk() + .await + .map_err(|_| (errors::Request::CannotReadChunkFromUploadedBinary))? + { + torrent_cursor + .write_all(&chunk) + .map_err(|_| (errors::Request::CannotWriteChunkFromUploadedBinary))?; } } _ => {} } } - let fields = Metadata { + Ok(AddTorrentRequest { title, description, category, tags, - }; - - fields.verify()?; - - let position = usize::try_from(torrent_cursor.position()).map_err(|_| ServiceError::InvalidTorrentFile)?; - let inner = torrent_cursor.get_ref(); - - let torrent = parse_torrent::decode_torrent(&inner[..position]).map_err(|_| ServiceError::InvalidTorrentFile)?; - - // Make sure that the pieces key has a length that is a multiple of 20 - // code-review: I think we could put this inside the service. - if let Some(pieces) = torrent.info.pieces.as_ref() { - if pieces.as_ref().len() % 20 != 0 { - return Err(ServiceError::InvalidTorrentPiecesLength); - } - } - - Ok(AddTorrentRequest { - metadata: fields, - torrent, + torrent_buffer: torrent_cursor.into_inner(), }) } diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs index 6553ba7f..82536cb8 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -330,6 +330,7 @@ //! //! Refer to the [`DeletedTorrentResponse`](crate::models::response::DeletedTorrentResponse) //! struct for more information about the response attributes. +pub mod errors; pub mod forms; pub mod handlers; pub mod responses; diff --git a/src/web/api/v1/responses.rs b/src/web/api/v1/responses.rs index 3adb7442..397862df 100644 --- a/src/web/api/v1/responses.rs +++ b/src/web/api/v1/responses.rs @@ -39,7 +39,8 @@ impl IntoResponse for database::Error { } } -fn json_error_response(status_code: StatusCode, error_response_data: &ErrorResponseData) -> Response { +#[must_use] +pub fn json_error_response(status_code: StatusCode, error_response_data: &ErrorResponseData) -> Response { ( status_code, [(header::CONTENT_TYPE, "application/json")], From 354cb7d8c5748130afed20ca8dce833c5e9e9934 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 3 Aug 2023 10:33:03 +0100 Subject: [PATCH 289/357] ci: overhaul testing workflow --- .github/workflows/develop.yml | 34 --------- .github/workflows/testing.yaml | 126 +++++++++++++++++++++++++++++++++ README.md | 2 +- project-words.txt | 3 + 4 files changed, 130 insertions(+), 35 deletions(-) delete mode 100644 .github/workflows/develop.yml create mode 100644 .github/workflows/testing.yaml diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml deleted file mode 100644 index 1558176f..00000000 --- a/.github/workflows/develop.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Development Checks - -on: [push, pull_request] - -jobs: - run: - runs-on: ubuntu-latest - env: - CARGO_TERM_COLOR: always - steps: - - uses: actions/checkout@v3 - - uses: dtolnay/rust-toolchain@nightly - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: Format - run: cargo fmt --all --check - - name: Check - run: cargo check --all-targets - - name: Clippy - run: cargo clippy --version && cargo clippy --all-targets -- -D clippy::pedantic - env: - CARGO_INCREMENTAL: 0 - - name: Install torrent edition tool (needed for testing) - run: cargo install imdl - - name: Unit and integration tests - run: cargo test --all-targets - - uses: taiki-e/install-action@cargo-llvm-cov - - uses: taiki-e/install-action@nextest - - name: Test Coverage - run: cargo llvm-cov nextest - - name: E2E Tests - run: ./docker/bin/e2e/run-e2e-tests.sh - diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 00000000..beaf0754 --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,126 @@ +name: Testing + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + format: + name: Formatting + runs-on: ubuntu-latest + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v3 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + components: rustfmt + + - id: cache + name: Enable Workflow Cache + uses: Swatinem/rust-cache@v2 + + - id: format + name: Run Formatting-Checks + run: cargo fmt --check + + check: + name: Static Analysis + runs-on: ubuntu-latest + needs: format + + strategy: + matrix: + toolchain: [stable, nightly] + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v3 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + components: clippy + + - id: cache + name: Enable Workflow Cache + uses: Swatinem/rust-cache@v2 + + - id: check + name: Run Build Checks + run: cargo check --tests --benches --examples --workspace --all-targets --all-features + + - id: lint + name: Run Lint Checks + run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic + + - id: doc + name: Run Documentation Checks + run: cargo test --doc + + unit: + name: Units + runs-on: ubuntu-latest + needs: check + + strategy: + matrix: + toolchain: [stable, nightly] + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v3 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.toolchain }} + components: llvm-tools-preview + + - id: cache + name: Enable Job Cache + uses: Swatinem/rust-cache@v2 + + - id: tools + name: Install Tools + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov, cargo-nextest + + - id: imdl + name: Install Intermodal + run: cargo install imdl + + - id: test + name: Run Unit Tests + run: cargo test --tests --benches --examples --workspace --all-targets --all-features + + - id: coverage + name: Generate Coverage Report + run: cargo llvm-cov nextest --tests --benches --examples --workspace --all-targets --all-features + + integration: + name: Integrations + runs-on: ubuntu-latest + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v3 + + - id: test + name: Run Integration Tests + run: ./docker/bin/e2e/run-e2e-tests.sh diff --git a/README.md b/README.md index 7067293c..f2a878d5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Torrust Index Backend -[![Development Checks](https://github.com/torrust/torrust-index-backend/actions/workflows/develop.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/develop.yml) [![Publish crate](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml) [![Publish Docker Image](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml) [![Publish Github Release](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml) [![Test Docker build](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml) +[![Testing](https://github.com/torrust/torrust-index-backend/actions/workflows/testing.yaml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/testing.yaml) [![Publish crate](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml) [![Publish Docker Image](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml) [![Publish Github Release](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml) [![Test Docker build](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml) This repository serves as the backend for the [Torrust Index](https://github.com/torrust/torrust-index) project, which implements the [Torrust Index Application Interface](https://github.com/torrust/torrust-index-api-lib). diff --git a/project-words.txt b/project-words.txt index 31530f59..a890960f 100644 --- a/project-words.txt +++ b/project-words.txt @@ -8,6 +8,7 @@ Benoit binascii btih chrono +clippy codecov codegen compatiblelicenses @@ -45,6 +46,7 @@ mandelbrotset metainfo nanos NCCA +nextest nilm nocapture Oberhachingerstr @@ -57,6 +59,7 @@ ROADMAP rowid RUSTDOCFLAGS RUSTFLAGS +rustfmt sgxj singlepart sqlx From 05141fd3a056f1395e7e9c6c0efce52414bada80 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 24 Aug 2023 14:18:24 +0100 Subject: [PATCH 290/357] fix: type for parsed torrent metadata using intermodal console app The announce_list is an array of arrays in the intermodal JSON output: ``` "announce-list": [ [ "https://academictorrents.com/announce.php" ], [ "https://ipv6.academictorrents.com/announce.php" ], [ "udp://tracker.opentrackr.org:1337/announce" ], [ "udp://tracker.openbittorrent.com:80/announce" ], [ "http://bt1.archive.org:6969/announce" ], [ "http://bt2.archive.org:6969/announce" ] ], ``` Not an array of strings. --- tests/common/contexts/torrent/file.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common/contexts/torrent/file.rs b/tests/common/contexts/torrent/file.rs index e06b1385..ce3fbf95 100644 --- a/tests/common/contexts/torrent/file.rs +++ b/tests/common/contexts/torrent/file.rs @@ -20,7 +20,7 @@ pub struct TorrentFileInfo { pub content_size: u64, pub private: bool, pub tracker: Option, - pub announce_list: Vec, + pub announce_list: Vec>, pub update_url: Option, pub dht_nodes: Vec, pub piece_size: u64, From 77a7f8ab0a5138497f87c381a8d9173e9f48ddba Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 24 Aug 2023 14:22:40 +0100 Subject: [PATCH 291/357] test: [#256] uploaded torrent inhohash changes when contains custom fields in the `info` key dictionary. For example: ```json { "announce": "https://academictorrents.com/announce.php", "announce-list": [ [ "https://academictorrents.com/announce.php" ], [ "https://ipv6.academictorrents.com/announce.php" ], [ "udp://tracker.opentrackr.org:1337/announce" ], [ "udp://tracker.openbittorrent.com:80/announce" ], [ "http://bt1.archive.org:6969/announce" ], [ "http://bt2.archive.org:6969/announce" ] ], "comment": "This content hosted at the Internet Archive at https://archive.org/details/rapppid-weights.tar\nFiles may have changed, which prevents torrents from downloading correctly or completely; please check for an updated torrent at https://archive.org/download/rapppid-weights.tar/rapppid-weights.tar_archive.torrent\nNote: retrieval usually requires a client that supports webseeding (GetRight style).\nNote: many Internet Archive torrents contain a 'pad file' directory. This directory and the files within it may be erased once retrieval completes.\nNote: the file rapppid-weights.tar_meta.xml contains metadata about this torrent's contents.", "created by": "ia_make_torrent", "creation date": 1689273787, "info": { "collections": [ "org.archive.rapppid-weights.tar" ], "files": [ { "crc32": "57d33fcc", "length": 11528324, "md5": "e91bb4ba82695161be68f8b33ae76142", "mtime": "1689273730", "path": [ "RAPPPID Weights.tar.gz" ], "sha1": "45970ef33cb3049a7a8629e40c8f5e5268d1dc53" }, { "crc32": "c658fd4f", "length": 20480, "md5": "a782b2a53ba49f0d45f3dd6e35e0d593", "mtime": "1689273783", "path": [ "rapppid-weights.tar_meta.sqlite" ], "sha1": "bcb06b3164f1d2aba22ef6046eb80f65264e9fba" }, { "crc32": "8140a5c7", "length": 1044, "md5": "1bab21e50e06ab42d3a77d872bf252e5", "mtime": "1689273763", "path": [ "rapppid-weights.tar_meta.xml" ], "sha1": "b2f0f2bbec34aa9140fb9ac3fcb190588a496aa3" } ], "name": "rapppid-weights.tar", "piece length": 524288, "pieces": "AB EC 55 6E 0F 7B E7 D3 30 0C F6 68 8C 90 6D 99 0C 3E 32 B5 2C F2 B6 7C 0C 32 52 BC 72 6F 07 1E 73 AB 76 F1 BC 32 2B FC 21 D4 7F 1A E9 72 35 40 7E C3 B4 89 09 2B ED 4B D8 B0 6C 65 8C 27 58 AE FB 72 75 73 44 37 88 28 20 D2 94 BD A4 6A F8 D2 A6 FD 02 65 1C 1C DF B8 56 6D 3A D2 7E A7 3D CA E2 49 F7 36 8D 17 77 6E 32 AD EF A5 44 C2 8F B6 9C 24 56 AD E8 FB 7B A6 71 C0 81 E5 43 03 91 D4 4F B0 A6 64 CA 29 1B 0D 1D 40 7D 39 4E 76 96 EB 01 18 F3 F5 50 8E 2F FA 54 FC 49 66 85 D8 38 87 78 9B 0A 8F 7A A3 2C 8F 72 36 AD 6D 74 0B FC C5 57 71 86 FB F3 CF CA C9 DA EC 61 62 A2 2A 1B A7 85 89 91 8F AA C0 C0 CB 0D 57 D8 B7 E7 64 4D F2 84 73 76 98 FB 3A 17 48 D7 9C 01 FE CA 6D 1F C5 97 34 05 54 39 DA C2 6E 17 41 11 69 F3 46 D1 7D 16 D3 C0 87 3B C3 B2 0C 1D E0 E2 49 C3 05 D2 4C 00 5A 5B 78 01 12 3E BF 52 43 49 6D 1A EE 23 79 D2 0E 28 B6 84 7E C5 ED 79 DE 64 02 ED 47 71 3D 93 16 C4 A2 76 18 77 54 C5 31 48 96 3A 51 C1 4A 92 90 91 F3 CF 48 5B 24 86 55 A8 EB 0C C6 2D 86 E2 29 56 09 2C 38 0B CD C1 CA 45 E6 64 6A 47 FE BB 2E 47 9A 77 45 29 E9 72 19 20 6F 42 79 2B 37 B9 53 25 ED 0F 29 04 D5 E2 96 F1 DE CF 99 BE 32 AA B8 0A 1D 0B 9F B9 D6 AB 5C 50 43 78 85 41 09 01 24 CF E0 89 76 5B 4D A9 CA 72 C0 DF 92 47 0F 0D CE CA 96 C6 7E A5 41 5F 2B A7 BB 04 CC F7 44 7F 94 1E 24 D2 1B 17 CA 18 79 90 A3 D6 20 75 A2 96 68 06 58 5A DE F5 2C 1A 90 22 72 33 8E D5 B2 A8 FA E5 E9 E7 69 62 02 7C 09 B3 4C" }, "locale": "en", "title": "rapppid-weights.tar", "url-list": [ "https://archive.org/download/", "http://ia902702.us.archive.org/22/items/", "http://ia802702.us.archive.org/22/items/" ] } ``` Notice the `collections` array inside `info` key. --- src/utils/parse_torrent.rs | 19 +++++ ...9296df97_with_custom_info_dict_key.torrent | Bin 0 -> 2361 bytes ...f97_with_custom_info_dict_key.torrent.json | 73 ++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent create mode 100644 tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent.json diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 9ac4b44f..7f504599 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -33,3 +33,22 @@ pub fn encode_torrent(torrent: &Torrent) -> Result, Error> { } } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + #[test] + fn it_should_ignore_non_standard_fields_in_info_dictionary() { + let torrent_path = Path::new( + // cspell:disable-next-line + "tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent", + ); + + let torrent = super::decode_torrent(&std::fs::read(torrent_path).unwrap()).unwrap(); + + // The infohash is not the original infohash of the torrent file, but the infohash of the + // info dictionary without the custom keys. + assert_eq!(torrent.info_hash(), "8aa01a4c816332045ffec83247ccbc654547fedf".to_string()); + } +} diff --git a/tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent b/tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent new file mode 100644 index 0000000000000000000000000000000000000000..8be3da336a45e7497930f9bec4815a34b0fea2e9 GIT binary patch literal 2361 zcmb7GX>1i$6t)POmLky~6xqCLO{p#K&D`0CK&TW;6|mN_1WcMcGj}=zvvp?PYadIA z35z5M1xXbpF#%bGUVu<1d%{B&>)5)@xIr-*BV-k|7Px-d(O9< zbMI)Pj2y>}IVxsp*$6`~kfTwgA`RQ78oIuZoiK=~t{qLsMLff!nas9HmKlVW^?!C_ zi{&%5OfSwy{<9$20_B+I5pbc8)Fs%DxE_I)d?t^6jZ8C)kikg8*#W{8Gwh|5WF(7J zzYRL=?L;w!;IrpW?tf@K?31J0IA~pfZGpt-?m;q2-CnWE{aDxzQ0EHkl zFeoRBzJo(h(o;0Ko^hfNM^wWhGOZx$BhT|pZBiXJYYbvei2R(FO$!IWMhRe`I0mYL zoEoeZgF3@h4dD4WPU;3AZ79%v*9Mwf=U6V%Os9rGBUY=Sl>n|!UTn|8A+{2e0eL_{ zG9y-(0G)h94v2XgN)qTzHz?rV`~x8#XstS}dT-vGIc|t$;N#FYaU5A77Q~Q6Ao{pA zX8MFM0;**u6%7p{S`hO**C#aVuo7UL;x(}hhm}d4KoBM@To~z1Y9lA{pz;}gl1?Gh zAq5INqye4CTL3gtGdUB92uRtPc_*3>F^HAPI8xpW4YJn^Q-&xQV4p~+0hdf!KQ?KV zgEXde1~7ob)i!ZIQg2(SCL+*?+hm3KfB;h5SW*JgVZ8(jGQAc=Xi8RnobpRa(41_d zY8x%V)xDu)lvAHg*8xdaO_~=aC@_L3U{*FA-PLHCbh2d;?j$I%7+NL+m+AZgz+z5L zDPd_GNv<+blsQ3T7+qC)*}_f@!AjE{6dA~3Mz%GMfijk8MPU^rLSEu%o>nj~>Y~Ch z2n#&TLQb|r)5e5O8X-fmvWG&$f>5q3si>$ZF9q}ah9Xk4lrRVkM6;C4a*{w{oncgk zY?F|HL>@|*rBqSpFb8>2qcxRda35rq=R{p&^$cW4u_Behiv$rW5OIuxSV^ZemeUzc z<1xcwO5-HvLA)Y42U2*6f&-}5V{uSxnIY~2uBZydD-6xEI;}yZAPBL}Q!I}ak5(G^YprS(#V(vlQ$@8m6XK`@c zq&542hB~D~MZqe=A|w$!x*{Q!G=M@&6eo&AG9Do&$__W)P1*O8<6G8iyuRdP5pzY^V)%3T5I9h5#y(=kmkgj zu4j$zx?Qm$dbjFcxxV_mxVHYooQ+F&6mRtT{dPF~Ud#O2HQilpt>0a^ft2kpj@`X_ z-KLFuo14EMG5`FRSGC!^O1f~u$t!u%?0Wg<6{Ai! zubq7IVD9+K9pxv7wa1^WIlbH-^ZWCO_9rGDTGi0fnYgG8=`5?A^6sc_ zx5r1VkXr%0e zx*3Jn{HKBYQldyW^2Q6DBMXO~?P$7rv2E+uaPQ%q@xwnla&F(771Qfimt<$AB EC 55 6E 0F 7B E7 D3 30 0C F6 68 8C 90 6D 99 0C 3E 32 B5 2C F2 B6 7C 0C 32 52 BC 72 6F 07 1E 73 AB 76 F1 BC 32 2B FC 21 D4 7F 1A E9 72 35 40 7E C3 B4 89 09 2B ED 4B D8 B0 6C 65 8C 27 58 AE FB 72 75 73 44 37 88 28 20 D2 94 BD A4 6A F8 D2 A6 FD 02 65 1C 1C DF B8 56 6D 3A D2 7E A7 3D CA E2 49 F7 36 8D 17 77 6E 32 AD EF A5 44 C2 8F B6 9C 24 56 AD E8 FB 7B A6 71 C0 81 E5 43 03 91 D4 4F B0 A6 64 CA 29 1B 0D 1D 40 7D 39 4E 76 96 EB 01 18 F3 F5 50 8E 2F FA 54 FC 49 66 85 D8 38 87 78 9B 0A 8F 7A A3 2C 8F 72 36 AD 6D 74 0B FC C5 57 71 86 FB F3 CF CA C9 DA EC 61 62 A2 2A 1B A7 85 89 91 8F AA C0 C0 CB 0D 57 D8 B7 E7 64 4D F2 84 73 76 98 FB 3A 17 48 D7 9C 01 FE CA 6D 1F C5 97 34 05 54 39 DA C2 6E 17 41 11 69 F3 46 D1 7D 16 D3 C0 87 3B C3 B2 0C 1D E0 E2 49 C3 05 D2 4C 00 5A 5B 78 01 12 3E BF 52 43 49 6D 1A EE 23 79 D2 0E 28 B6 84 7E C5 ED 79 DE 64 02 ED 47 71 3D 93 16 C4 A2 76 18 77 54 C5 31 48 96 3A 51 C1 4A 92 90 91 F3 CF 48 5B 24 86 55 A8 EB 0C C6 2D 86 E2 29 56 09 2C 38 0B CD C1 CA 45 E6 64 6A 47 FE BB 2E 47 9A 77 45 29 E9 72 19 20 6F 42 79 2B 37 B9 53 25 ED 0F 29 04 D5 E2 96 F1 DE CF 99 BE 32 AA B8 0A 1D 0B 9F B9 D6 AB 5C 50 43 78 85 41 09 01 24 CF E0 89 76 5B 4D A9 CA 72 C0 DF 92 47 0F 0D CE CA 96 C6 7E A5 41 5F 2B A7 BB 04 CC F7 44 7F 94 1E 24 D2 1B 17 CA 18 79 90 A3 D6 20 75 A2 96 68 06 58 5A DE F5 2C 1A 90 22 72 33 8E D5 B2 A8 FA E5 E9 E7 69 62 02 7C 09 B3 4C" + }, + "locale": "en", + "title": "rapppid-weights.tar", + "url-list": [ + "https://archive.org/download/", + "http://ia902702.us.archive.org/22/items/", + "http://ia802702.us.archive.org/22/items/" + ] +} \ No newline at end of file From 941694e311d84a9c3d9003d7bd1ff3c5a2d9d3f9 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 24 Aug 2023 15:09:21 +0100 Subject: [PATCH 292/357] docs: [#256] add ADR for custom info dcit fields --- ..._non_standard_fields_in_info_dictionary.md | 138 ++++++++++++++++++ project-words.txt | 7 + 2 files changed, 145 insertions(+) create mode 100644 adrs/20230824135449_ignore_non_standard_fields_in_info_dictionary.md diff --git a/adrs/20230824135449_ignore_non_standard_fields_in_info_dictionary.md b/adrs/20230824135449_ignore_non_standard_fields_in_info_dictionary.md new file mode 100644 index 00000000..870656db --- /dev/null +++ b/adrs/20230824135449_ignore_non_standard_fields_in_info_dictionary.md @@ -0,0 +1,138 @@ +# Ignore non-standard fields in info dictionary + +This is a temporary solution to avoid problems with non-standard fields in the +info dictionary. In the future, we could add support for them. + +## Context + +In torrents, custom fields in the info dictionary can lead to mismatches in our system. + +## Problem + +Some torrents might include custom fields in the info dictionary. **Parsing non-standard fields generates a different info-hash for the indexed torrent**, leading to potential issues and misrepresentations. + +A sample JSON version of a torrent with a `collections` custom field int the `info` dictionary: + +```json +{ + "announce": "https://academictorrents.com/announce.php", + "announce-list": [ + [ + "https://academictorrents.com/announce.php" + ], + [ + "https://ipv6.academictorrents.com/announce.php" + ], + [ + "udp://tracker.opentrackr.org:1337/announce" + ], + [ + "udp://tracker.openbittorrent.com:80/announce" + ], + [ + "http://bt1.archive.org:6969/announce" + ], + [ + "http://bt2.archive.org:6969/announce" + ] + ], + "comment": "This content hosted at the Internet Archive at https://archive.org/details/rapppid-weights.tar\nFiles may have changed, which prevents torrents from downloading correctly or completely; please check for an updated torrent at https://archive.org/download/rapppid-weights.tar/rapppid-weights.tar_archive.torrent\nNote: retrieval usually requires a client that supports webseeding (GetRight style).\nNote: many Internet Archive torrents contain a 'pad file' directory. This directory and the files within it may be erased once retrieval completes.\nNote: the file rapppid-weights.tar_meta.xml contains metadata about this torrent's contents.", + "created by": "ia_make_torrent", + "creation date": 1689273787, + "info": { + "collections": [ + "org.archive.rapppid-weights.tar" + ], + "files": [ + { + "crc32": "57d33fcc", + "length": 11528324, + "md5": "e91bb4ba82695161be68f8b33ae76142", + "mtime": "1689273730", + "path": [ + "RAPPPID Weights.tar.gz" + ], + "sha1": "45970ef33cb3049a7a8629e40c8f5e5268d1dc53" + }, + { + "crc32": "c658fd4f", + "length": 20480, + "md5": "a782b2a53ba49f0d45f3dd6e35e0d593", + "mtime": "1689273783", + "path": [ + "rapppid-weights.tar_meta.sqlite" + ], + "sha1": "bcb06b3164f1d2aba22ef6046eb80f65264e9fba" + }, + { + "crc32": "8140a5c7", + "length": 1044, + "md5": "1bab21e50e06ab42d3a77d872bf252e5", + "mtime": "1689273763", + "path": [ + "rapppid-weights.tar_meta.xml" + ], + "sha1": "b2f0f2bbec34aa9140fb9ac3fcb190588a496aa3" + } + ], + "name": "rapppid-weights.tar", + "piece length": 524288, + "pieces": "AB EC 55 6E 0F 7B E7 D3 30 0C F6 68 8C 90 6D 99 0C 3E 32 B5 2C F2 B6 7C 0C 32 52 BC 72 6F 07 1E 73 AB 76 F1 BC 32 2B FC 21 D4 7F 1A E9 72 35 40 7E C3 B4 89 09 2B ED 4B D8 B0 6C 65 8C 27 58 AE FB 72 75 73 44 37 88 28 20 D2 94 BD A4 6A F8 D2 A6 FD 02 65 1C 1C DF B8 56 6D 3A D2 7E A7 3D CA E2 49 F7 36 8D 17 77 6E 32 AD EF A5 44 C2 8F B6 9C 24 56 AD E8 FB 7B A6 71 C0 81 E5 43 03 91 D4 4F B0 A6 64 CA 29 1B 0D 1D 40 7D 39 4E 76 96 EB 01 18 F3 F5 50 8E 2F FA 54 FC 49 66 85 D8 38 87 78 9B 0A 8F 7A A3 2C 8F 72 36 AD 6D 74 0B FC C5 57 71 86 FB F3 CF CA C9 DA EC 61 62 A2 2A 1B A7 85 89 91 8F AA C0 C0 CB 0D 57 D8 B7 E7 64 4D F2 84 73 76 98 FB 3A 17 48 D7 9C 01 FE CA 6D 1F C5 97 34 05 54 39 DA C2 6E 17 41 11 69 F3 46 D1 7D 16 D3 C0 87 3B C3 B2 0C 1D E0 E2 49 C3 05 D2 4C 00 5A 5B 78 01 12 3E BF 52 43 49 6D 1A EE 23 79 D2 0E 28 B6 84 7E C5 ED 79 DE 64 02 ED 47 71 3D 93 16 C4 A2 76 18 77 54 C5 31 48 96 3A 51 C1 4A 92 90 91 F3 CF 48 5B 24 86 55 A8 EB 0C C6 2D 86 E2 29 56 09 2C 38 0B CD C1 CA 45 E6 64 6A 47 FE BB 2E 47 9A 77 45 29 E9 72 19 20 6F 42 79 2B 37 B9 53 25 ED 0F 29 04 D5 E2 96 F1 DE CF 99 BE 32 AA B8 0A 1D 0B 9F B9 D6 AB 5C 50 43 78 85 41 09 01 24 CF E0 89 76 5B 4D A9 CA 72 C0 DF 92 47 0F 0D CE CA 96 C6 7E A5 41 5F 2B A7 BB 04 CC F7 44 7F 94 1E 24 D2 1B 17 CA 18 79 90 A3 D6 20 75 A2 96 68 06 58 5A DE F5 2C 1A 90 22 72 33 8E D5 B2 A8 FA E5 E9 E7 69 62 02 7C 09 B3 4C" + }, + "locale": "en", + "title": "rapppid-weights.tar", + "url-list": [ + "https://archive.org/download/", + "http://ia902702.us.archive.org/22/items/", + "http://ia802702.us.archive.org/22/items/" + ] +} +``` + +> NOTICE: The `collections` field. + +At the moment we are only parsing these fields from the `info` dictionary: + +```rust +pub struct TorrentInfo { + pub name: String, + #[serde(default)] + pub pieces: Option, + #[serde(rename = "piece length")] + pub piece_length: i64, + #[serde(default)] + pub md5sum: Option, + #[serde(default)] + pub length: Option, + #[serde(default)] + pub files: Option>, + #[serde(default)] + pub private: Option, + #[serde(default)] + pub path: Option>, + #[serde(default)] + #[serde(rename = "root hash")] + pub root_hash: Option, + #[serde(default)] + pub source: Option, +} +``` + +> WARNING!: If the uploaded torrent has a non-standard field in the info dictionary, +> it will not only be ignore but it will produce a different info-hash for the indexed torrent. + +## Agreement + +1. Temporary Solution: Ignore all non-standard fields in the info dictionary. +2. Communication: Users will be alerted about this decision through UI warnings and documentation. +3. Future Consideration: There is a potential to support these fields in future iterations. + +## Rationale + +- Prioritizing standard fields ensures uniformity in the representation of torrents. +- Warnings and documentation provide transparency to users. +- A future-proof approach leaves room for possible expansion or reconsideration. + +## Other considerations + +The source field migth be considered a non-standard field, because it's not included in any BEP, but this field is being parsed and stored in the database since it seems to be widely used by private trackers. diff --git a/project-words.txt b/project-words.txt index a890960f..5a52dc61 100644 --- a/project-words.txt +++ b/project-words.txt @@ -44,6 +44,7 @@ luckythelab mailcatcher mandelbrotset metainfo +migth nanos NCCA nextest @@ -51,8 +52,11 @@ nilm nocapture Oberhachingerstr oneshot +openbittorrent +opentrackr ppassword proxied +rapppid reqwest Roadmap ROADMAP @@ -60,10 +64,12 @@ rowid RUSTDOCFLAGS RUSTFLAGS rustfmt +serde sgxj singlepart sqlx strftime +struct sublicensable sublist subpoints @@ -82,4 +88,5 @@ urlencoding uroot Verstappen waivable +webseeding Xoauth From 0cb63dc676f25eec4babac8aaab8a2ae261402a4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 24 Aug 2023 17:32:23 +0100 Subject: [PATCH 293/357] feat: [#2560] new function to calculate the original torrent infohash We can change the final torrent infohash becuase we do not extract/parse non standard fields in the info dictionary. We want to keep a copy of the original uploaded torrent's infohash. Si we need to calcuylate the infohash with all the fields in the info dictionary. We will sotre it in the database and warn users in the UI when the infohash changes. --- src/utils/parse_torrent.rs | 59 ++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 7f504599..0a0999ac 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -1,10 +1,14 @@ use std::error; +use serde::{self, Deserialize, Serialize}; +use serde_bencode::value::Value; use serde_bencode::{de, Error}; +use sha1::{Digest, Sha1}; +use crate::models::info_hash::InfoHash; use crate::models::torrent_file::Torrent; -/// Decode a Torrent from Bencoded Bytes +/// Decode a Torrent from Bencoded Bytes. /// /// # Errors /// @@ -19,7 +23,7 @@ pub fn decode_torrent(bytes: &[u8]) -> Result> { } } -/// Encode a Torrent into Bencoded Bytes +/// Encode a Torrent into Bencoded Bytes. /// /// # Errors /// @@ -34,12 +38,57 @@ pub fn encode_torrent(torrent: &Torrent) -> Result, Error> { } } +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct MetainfoFile { + pub info: Value, +} + +/// Calculates the `InfoHash` from a the torrent file binary data. +/// +/// # Panics +/// +/// This function will panic if the torrent file is not a valid bencoded file +/// or if the info dictionary cannot be bencoded. +#[must_use] +pub fn calculate_info_hash(bytes: &[u8]) -> InfoHash { + // Extract the info dictionary + let metainfo: MetainfoFile = serde_bencode::from_bytes(bytes).expect("Torrent file cannot be parsed from bencoded format"); + + // Bencode the info dictionary + let info_dict_bytes = serde_bencode::to_bytes(&metainfo.info).expect("Info dictionary cannot by bencoded"); + + // Calculate the SHA-1 hash of the bencoded info dictionary + let mut hasher = Sha1::new(); + hasher.update(&info_dict_bytes); + let result = hasher.finalize(); + + InfoHash::from_bytes(&result) +} + #[cfg(test)] mod tests { use std::path::Path; + use std::str::FromStr; + + use crate::models::info_hash::InfoHash; + + #[test] + fn it_should_calculate_the_original_info_hash_using_all_fields_in_the_info_key_dictionary() { + let torrent_path = Path::new( + // cspell:disable-next-line + "tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent", + ); + + let original_info_hash = super::calculate_info_hash(&std::fs::read(torrent_path).unwrap()); + + assert_eq!( + original_info_hash, + InfoHash::from_str("6c690018c5786dbbb00161f62b0712d69296df97").unwrap() + ); + } #[test] - fn it_should_ignore_non_standard_fields_in_info_dictionary() { + fn it_should_calculate_the_new_info_hash_ignoring_non_standard_fields_in_the_info_key_dictionary() { let torrent_path = Path::new( // cspell:disable-next-line "tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent", @@ -47,8 +96,8 @@ mod tests { let torrent = super::decode_torrent(&std::fs::read(torrent_path).unwrap()).unwrap(); - // The infohash is not the original infohash of the torrent file, but the infohash of the - // info dictionary without the custom keys. + // The infohash is not the original infohash of the torrent file, + // but the infohash of the info dictionary without the custom keys. assert_eq!(torrent.info_hash(), "8aa01a4c816332045ffec83247ccbc654547fedf".to_string()); } } From d7c9d931d163dbec9dc9e82ae7f180e80eed17f9 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 24 Aug 2023 17:58:54 +0100 Subject: [PATCH 294/357] feat: [#256] store the original infohash in the database Torrent InfoHash migth change after uploading it becuase we do not extract/store all the fields in the info dictionary. We store a copy of the original infohash in the database field `torrust_torrents::original_info_hash`. --- ...torrust_torrents_add_original_info_hash.sql | 1 + ...torrust_torrents_add_original_info_hash.sql | 1 + src/databases/database.rs | 1 + src/databases/mysql.rs | 4 +++- src/databases/sqlite.rs | 4 +++- src/services/torrent.rs | 18 ++++++++++++++++-- 6 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 migrations/mysql/20230824164316_torrust_torrents_add_original_info_hash.sql create mode 100644 migrations/sqlite3/20230824164316_torrust_torrents_add_original_info_hash.sql diff --git a/migrations/mysql/20230824164316_torrust_torrents_add_original_info_hash.sql b/migrations/mysql/20230824164316_torrust_torrents_add_original_info_hash.sql new file mode 100644 index 00000000..e81cb96c --- /dev/null +++ b/migrations/mysql/20230824164316_torrust_torrents_add_original_info_hash.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN original_info_hash TEXT DEFAULT NULL \ No newline at end of file diff --git a/migrations/sqlite3/20230824164316_torrust_torrents_add_original_info_hash.sql b/migrations/sqlite3/20230824164316_torrust_torrents_add_original_info_hash.sql new file mode 100644 index 00000000..e81cb96c --- /dev/null +++ b/migrations/sqlite3/20230824164316_torrust_torrents_add_original_info_hash.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN original_info_hash TEXT DEFAULT NULL \ No newline at end of file diff --git a/src/databases/database.rs b/src/databases/database.rs index 0ba2a5a1..72d15c18 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -191,6 +191,7 @@ pub trait Database: Sync + Send { /// Add new torrent and return the newly inserted `torrent_id` with `torrent`, `uploader_id`, `category_id`, `title` and `description`. async fn insert_torrent_and_get_id( &self, + original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, category_id: i64, diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 5b9f1ca4..e7d0babb 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -418,6 +418,7 @@ impl Database for Mysql { #[allow(clippy::too_many_lines)] async fn insert_torrent_and_get_id( &self, + original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, category_id: i64, @@ -443,7 +444,7 @@ impl Database for Mysql { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, original_info_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -454,6 +455,7 @@ impl Database for Mysql { .bind(private) .bind(root_hash) .bind(torrent.info.source.clone()) + .bind(original_info_hash.to_hex_string()) .execute(&self.pool) .await .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 5e8d5a7d..c300c188 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -406,6 +406,7 @@ impl Database for Sqlite { #[allow(clippy::too_many_lines)] async fn insert_torrent_and_get_id( &self, + original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, category_id: i64, @@ -431,7 +432,7 @@ impl Database for Sqlite { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, original_info_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -442,6 +443,7 @@ impl Database for Sqlite { .bind(private) .bind(root_hash) .bind(torrent.info.source.clone()) + .bind(original_info_hash.to_hex_string()) .execute(&self.pool) .await .map(|v| v.last_insert_rowid()) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index ec3d63a8..88e5dfe5 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -130,6 +130,8 @@ impl Index { metadata.verify()?; + let original_info_hash = parse_torrent::calculate_info_hash(&add_torrent_form.torrent_buffer); + let mut torrent = parse_torrent::decode_torrent(&add_torrent_form.torrent_buffer).map_err(|_| ServiceError::InvalidTorrentFile)?; @@ -154,7 +156,11 @@ impl Index { .await .map_err(|_| ServiceError::InvalidCategory)?; - let torrent_id = self.torrent_repository.add(&torrent, &metadata, user_id, category).await?; + let torrent_id = self + .torrent_repository + .add(&original_info_hash, &torrent, &metadata, user_id, category) + .await?; + let info_hash: InfoHash = torrent .info_hash() .parse() @@ -474,13 +480,21 @@ impl DbTorrentRepository { /// This function will return an error there is a database error. pub async fn add( &self, + original_info_hash: &InfoHash, torrent: &Torrent, metadata: &Metadata, user_id: UserId, category: Category, ) -> Result { self.database - .insert_torrent_and_get_id(torrent, user_id, category.category_id, &metadata.title, &metadata.description) + .insert_torrent_and_get_id( + original_info_hash, + torrent, + user_id, + category.category_id, + &metadata.title, + &metadata.description, + ) .await } From 7c047e2619b38df39310968d489fc81c85b6ff62 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 24 Aug 2023 18:18:53 +0100 Subject: [PATCH 295/357] refactor: [#256] extract struct AddTorrentResponse --- src/services/torrent.rs | 14 ++++++++++++-- src/web/api/v1/contexts/torrent/handlers.rs | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 88e5dfe5..aa6b8b0b 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -43,6 +43,12 @@ pub struct AddTorrentRequest { pub torrent_buffer: Vec, } +pub struct AddTorrentResponse { + pub torrent_id: TorrentId, + pub info_hash: String, + pub original_info_hash: String, +} + /// User request to generate a torrent listing. #[derive(Debug, Deserialize)] pub struct ListingRequest { @@ -120,7 +126,7 @@ impl Index { &self, add_torrent_form: AddTorrentRequest, user_id: UserId, - ) -> Result<(TorrentId, InfoHash), ServiceError> { + ) -> Result { let metadata = Metadata { title: add_torrent_form.title, description: add_torrent_form.description, @@ -184,7 +190,11 @@ impl Index { .link_torrent_to_tags(&torrent_id, &metadata.tags) .await?; - Ok((torrent_id, info_hash)) + Ok(AddTorrentResponse { + torrent_id, + info_hash: info_hash.to_string(), + original_info_hash: original_info_hash.to_string(), + }) } /// Gets a torrent from the Index. diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 31cd29ca..1c902522 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -49,7 +49,7 @@ pub async fn upload_torrent_handler( }; match app_data.torrent_service.add_torrent(add_torrent_form, user_id).await { - Ok(torrent_ids) => new_torrent_response(torrent_ids.0, &torrent_ids.1.to_hex_string()).into_response(), + Ok(response) => new_torrent_response(response.torrent_id, &response.info_hash).into_response(), Err(error) => error.into_response(), } } From 9bb85780f6ee3a978ac1a4c6d54bd88ab623f3fc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 24 Aug 2023 18:23:50 +0100 Subject: [PATCH 296/357] feat: [#256] add original infohash to upload torrent response ```json { "data": { "torrent_id": 174, "info_hash": "8aa01a4c816332045ffec83247ccbc654547fedf", "original_info_hash": "6c690018c5786dbbb00161f62b0712d69296df97" } } ``` `original_info_hash` contains the infohash of the original uploaded torrent file. It migth change if the torrent contained custom fields in the info dictionary. --- src/web/api/v1/contexts/torrent/handlers.rs | 2 +- src/web/api/v1/contexts/torrent/responses.rs | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 1c902522..2165256c 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -49,7 +49,7 @@ pub async fn upload_torrent_handler( }; match app_data.torrent_service.add_torrent(add_torrent_form, user_id).await { - Ok(response) => new_torrent_response(response.torrent_id, &response.info_hash).into_response(), + Ok(response) => new_torrent_response(&response).into_response(), Err(error) => error.into_response(), } } diff --git a/src/web/api/v1/contexts/torrent/responses.rs b/src/web/api/v1/contexts/torrent/responses.rs index 33ec2a19..9873b420 100644 --- a/src/web/api/v1/contexts/torrent/responses.rs +++ b/src/web/api/v1/contexts/torrent/responses.rs @@ -4,6 +4,7 @@ use hyper::{header, HeaderMap, StatusCode}; use serde::{Deserialize, Serialize}; use crate::models::torrent::TorrentId; +use crate::services::torrent::AddTorrentResponse; use crate::web::api::v1::responses::OkResponseData; #[allow(clippy::module_name_repetitions)] @@ -11,14 +12,16 @@ use crate::web::api::v1::responses::OkResponseData; pub struct NewTorrentResponseData { pub torrent_id: TorrentId, pub info_hash: String, + pub original_info_hash: String, } /// Response after successfully uploading a new torrent. -pub fn new_torrent_response(torrent_id: TorrentId, info_hash: &str) -> Json> { +pub fn new_torrent_response(add_torrent_response: &AddTorrentResponse) -> Json> { Json(OkResponseData { data: NewTorrentResponseData { - torrent_id, - info_hash: info_hash.to_owned(), + torrent_id: add_torrent_response.torrent_id, + info_hash: add_torrent_response.info_hash.clone(), + original_info_hash: add_torrent_response.original_info_hash.clone(), }, }) } From bf3f66f6d2c920bf2f47e03d2a6bee96ef124726 Mon Sep 17 00:00:00 2001 From: Alex Wellnitz Date: Sun, 27 Aug 2023 01:05:11 +0200 Subject: [PATCH 297/357] #262: Add tower-http compression middleware --- Cargo.lock | 42 ++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 +- src/web/api/v1/routes.rs | 3 ++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b1bca63..3f61892a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,7 +70,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "zstd", + "zstd 0.12.3+zstd.1.5.2", ] [[package]] @@ -348,6 +348,22 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "async-compression" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "zstd 0.11.2+zstd.1.5.2", + "zstd-safe 5.0.2+zstd.1.5.2", +] + [[package]] name = "async-trait" version = "0.1.69" @@ -3198,6 +3214,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8bd22a874a2d0b70452d5597b12c537331d49060824a95f49f108994f94aa4c" dependencies = [ + "async-compression", "bitflags 2.3.3", "bytes", "futures-core", @@ -3206,6 +3223,8 @@ dependencies = [ "http-body", "http-range-header", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", ] @@ -3726,13 +3745,32 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe 5.0.2+zstd.1.5.2", +] + [[package]] name = "zstd" version = "0.12.3+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76eea132fb024e0e13fd9c2f5d5d595d8a967aa72382ac2f9d39fcc95afd0806" dependencies = [ - "zstd-safe", + "zstd-safe 6.0.5+zstd.1.5.4", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4684a1e4..e2214518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ thiserror = "1.0" binascii = "0.1" axum = { version = "0.6.18", features = ["multipart"] } hyper = "0.14.26" -tower-http = { version = "0.4.0", features = ["cors"] } +tower-http = { version = "0.4.0", features = ["cors", "compression-full"] } email_address = "0.2.4" hex = "0.4.3" uuid = { version = "1.3", features = ["v4"] } diff --git a/src/web/api/v1/routes.rs b/src/web/api/v1/routes.rs index 099a1921..44098f4c 100644 --- a/src/web/api/v1/routes.rs +++ b/src/web/api/v1/routes.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use axum::extract::DefaultBodyLimit; use axum::routing::get; use axum::Router; +use tower_http::compression::CompressionLayer; use tower_http::cors::CorsLayer; use super::contexts::about::handlers::about_page_handler; @@ -42,5 +43,5 @@ pub fn router(app_data: Arc) -> Router { router }; - router.layer(DefaultBodyLimit::max(10_485_760)) + router.layer(DefaultBodyLimit::max(10_485_760)).layer(CompressionLayer::new()) } From f2369b417c6a294976328fb158e4274335023e6b Mon Sep 17 00:00:00 2001 From: MMelchor Date: Sat, 2 Sep 2023 17:48:01 +0200 Subject: [PATCH 298/357] feat: [#264] Added torrent name to list and detail endpoints The list and detail torrent endpoints now contain the torrent name: For example: { "data": { "torrent_id": 175, "uploader": "admin", "info_hash": "124f00ff15f00ae19d7b939950090cb42ab6e852", "title": "www", "description": "www", "category": { "category_id": 63, "name": "Paper", "num_torrents": 4 }, "upload_date": "2023-08-26 20:02:27", "file_size": 639140, "seeders": 0, "leechers": 0, "files": [ { "path": [ "mandelbrot_set_07" ], "length": 639140, "md5sum": null } ], "trackers": [ "udp://localhost:6969", "udp://localhost:6969" ], "magnet_link": "magnet:?xt=urn:btih:124f00ff15f00ae19d7b939950090cb42ab6e852&dn=www&tr=udp%3A%2F%2Flocalhost%3A6969&tr=udp%3A%2F%2Flocalhost%3A6969", "tags": [], "name": "mandelbrot_set_07" } } Notice the last field `name`. That field is in the table column `torrust_torrents::name`. It's going to be used in the frontend to set the name for the downloaded torrent file. --- src/databases/mysql.rs | 6 +++--- src/databases/sqlite.rs | 6 +++--- src/models/response.rs | 2 ++ src/models/torrent.rs | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index e7d0babb..cb7b3317 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -375,7 +375,7 @@ impl Database for Mysql { }; let mut query_string = format!( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, tt.name, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -629,7 +629,7 @@ impl Database for Mysql { async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, tt.name, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -647,7 +647,7 @@ impl Database for Mysql { async fn get_torrent_listing_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, tt.name, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index c300c188..14aa8808 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -363,7 +363,7 @@ impl Database for Sqlite { }; let mut query_string = format!( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, tt.name, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -617,7 +617,7 @@ impl Database for Sqlite { async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, tt.name, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -635,7 +635,7 @@ impl Database for Sqlite { async fn get_torrent_listing_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, + "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, tt.name, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt diff --git a/src/models/response.rs b/src/models/response.rs index cd8b4f59..adb1de07 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -61,6 +61,7 @@ pub struct TorrentResponse { pub trackers: Vec, pub magnet_link: String, pub tags: Vec, + pub name: String, } impl TorrentResponse { @@ -81,6 +82,7 @@ impl TorrentResponse { trackers: vec![], magnet_link: String::new(), tags: vec![], + name: torrent_listing.name, } } } diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 66a9616d..eb2bcde2 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -20,6 +20,7 @@ pub struct TorrentListing { pub file_size: i64, pub seeders: i64, pub leechers: i64, + pub name: String, } #[derive(Debug, Deserialize)] From 7c4b530c5ab39a591ef0a61a2fc1f94dec25e3f0 Mon Sep 17 00:00:00 2001 From: MMelchor Date: Mon, 11 Sep 2023 14:06:21 +0200 Subject: [PATCH 299/357] test: [#264] Added torrent name to list and detail endpoints Added the field name to the TorrentDetails, ListItem structs used in the response from the API calls (list and detail endpoints) in the tests. Some minor refactoring was also applied so the code used to test that the torrents info is retrieve correctly and match the expected result. --- tests/common/contexts/torrent/fixtures.rs | 2 ++ tests/common/contexts/torrent/responses.rs | 2 ++ tests/e2e/web/api/v1/contexts/torrent/contract.rs | 1 + 3 files changed, 5 insertions(+) diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index e4ce70f1..a464651f 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -18,6 +18,7 @@ pub struct TorrentIndexInfo { pub description: String, pub category: String, pub torrent_file: BinaryFile, + pub name: String, } impl From for UploadTorrentMultipartForm { @@ -84,6 +85,7 @@ impl TestTorrent { description: format!("description-{id}"), category: software_predefined_category_name(), torrent_file, + name: format!("name-{id}"), }; TestTorrent { diff --git a/tests/common/contexts/torrent/responses.rs b/tests/common/contexts/torrent/responses.rs index 29da0d45..001784d2 100644 --- a/tests/common/contexts/torrent/responses.rs +++ b/tests/common/contexts/torrent/responses.rs @@ -38,6 +38,7 @@ pub struct ListItem { pub file_size: i64, pub seeders: i64, pub leechers: i64, + pub name: String, } #[derive(Deserialize, PartialEq, Debug)] @@ -60,6 +61,7 @@ pub struct TorrentDetails { pub files: Vec, pub trackers: Vec, pub magnet_link: String, + pub name: String, } #[derive(Deserialize, PartialEq, Debug)] diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 73d47ed8..9ddd5c33 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -208,6 +208,7 @@ mod for_guests { encoded_tracker_url, encoded_tracker_url ), + name: test_torrent.index_info.name.clone(), }; assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); From bf95d854ec5f360de310a53d69f9c5748914a539 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 7 Sep 2023 16:18:38 +0100 Subject: [PATCH 300/357] fix: docker compose with the new tracker container image The Torrust Tracker container image has changed. Upgrade the configuration to use the latest version. --- .env.local | 4 ++-- compose.yaml | 22 +++++++--------------- config-tracker.local.toml | 2 +- docker/README.md | 3 ++- docker/bin/e2e/mysql/e2e-env-reset.sh | 9 ++++----- docker/bin/e2e/mysql/e2e-env-up.sh | 3 ++- docker/bin/e2e/sqlite/e2e-env-reset.sh | 10 ++++------ docker/bin/e2e/sqlite/e2e-env-up.sh | 5 ++++- 8 files changed, 26 insertions(+), 32 deletions(-) diff --git a/.env.local b/.env.local index 90b3e4b3..af213711 100644 --- a/.env.local +++ b/.env.local @@ -2,5 +2,5 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc TORRUST_IDX_BACK_CONFIG= TORRUST_IDX_BACK_USER_UID=1000 TORRUST_TRACKER_CONFIG= -TORRUST_TRACKER_USER_UID=1000 -TORRUST_TRACKER_API_TOKEN=MyAccessToken +TORRUST_TRACKER_DATABASE=sqlite3 +TORRUST_TRACKER_API_ADMIN_TOKEN=MyAccessToken diff --git a/compose.yaml b/compose.yaml index 8bf4741e..8c09ad96 100644 --- a/compose.yaml +++ b/compose.yaml @@ -38,29 +38,21 @@ services: tracker: image: torrust/tracker:develop - user: ${TORRUST_TRACKER_USER_UID:-1000}:${TORRUST_TRACKER_USER_UID:-1000} tty: true environment: - TORRUST_TRACKER_CONFIG=${TORRUST_TRACKER_CONFIG} - - TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} + - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-sqlite3} + - TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} networks: - server_side ports: - 6969:6969/udp - - 1212:1212/tcp - # todo: implement healthcheck - #healthcheck: - # test: - # [ - # "CMD-SHELL", - # "/app/main healthcheck" - # ] - # interval: 10s - # retries: 5 - # start_period: 10s - # timeout: 3s + - 7070:7070 + - 1212:1212 volumes: - - ./storage:/app/storage + - ./storage/tracker/lib:/var/lib/torrust/tracker:Z + - ./storage/tracker/log:/var/log/torrust/tracker:Z + - ./storage/tracker/etc:/etc/torrust/tracker:Z depends_on: - mysql diff --git a/config-tracker.local.toml b/config-tracker.local.toml index 9db1b578..2c7cb704 100644 --- a/config-tracker.local.toml +++ b/config-tracker.local.toml @@ -1,7 +1,7 @@ log_level = "info" mode = "public" db_driver = "Sqlite3" -db_path = "./storage/database/torrust_tracker_e2e_testing.db" +db_path = "/var/lib/torrust/tracker/database/torrust_tracker_e2e_testing.db" announce_interval = 120 min_announce_interval = 120 max_peer_timeout = 900 diff --git a/docker/README.md b/docker/README.md index be47bfad..4e094776 100644 --- a/docker/README.md +++ b/docker/README.md @@ -65,8 +65,9 @@ Build and run it locally: ```s TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.local.toml) \ + TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-mysql} \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ - TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ docker compose up -d --build ``` diff --git a/docker/bin/e2e/mysql/e2e-env-reset.sh b/docker/bin/e2e/mysql/e2e-env-reset.sh index d8dc0764..9fb88f86 100755 --- a/docker/bin/e2e/mysql/e2e-env-reset.sh +++ b/docker/bin/e2e/mysql/e2e-env-reset.sh @@ -19,15 +19,14 @@ mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "DROP DATABASE IF EXIST # Tracker # Delete tracker database -rm -f ./storage/database/torrust_tracker_e2e_testing.db +rm -f ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db # Generate storage directory if it does not exist -mkdir -p "./storage/database" +mkdir -p "./storage/tracker/lib/database" # Generate the sqlite database for the tracker if it does not exist -if ! [ -f "./storage/database/torrust_tracker_e2e_testing.db" ]; then - touch ./storage/database/torrust_tracker_e2e_testing.db - echo ";" | sqlite3 ./storage/database/torrust_tracker_e2e_testing.db +if ! [ -f "./storage/tracker/lib/database/torrust_tracker_e2e_testing.db" ]; then + sqlite3 ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db "VACUUM;" fi ./docker/bin/e2e/mysql/e2e-env-up.sh diff --git a/docker/bin/e2e/mysql/e2e-env-up.sh b/docker/bin/e2e/mysql/e2e-env-up.sh index 195409b1..ddf54d57 100755 --- a/docker/bin/e2e/mysql/e2e-env-up.sh +++ b/docker/bin/e2e/mysql/e2e-env-up.sh @@ -7,6 +7,7 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.mysql.local.toml) \ TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_backend_e2e_testing" \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ - TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-mysql} \ + TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ docker compose up -d diff --git a/docker/bin/e2e/sqlite/e2e-env-reset.sh b/docker/bin/e2e/sqlite/e2e-env-reset.sh index 8eeefd6f..b3ff33da 100755 --- a/docker/bin/e2e/sqlite/e2e-env-reset.sh +++ b/docker/bin/e2e/sqlite/e2e-env-reset.sh @@ -5,7 +5,7 @@ docker compose down rm -f ./storage/database/torrust_index_backend_e2e_testing.db -rm -f ./storage/database/torrust_tracker_e2e_testing.db +rm -f ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db # Generate storage directory if it does not exist mkdir -p "./storage/database" @@ -13,14 +13,12 @@ mkdir -p "./storage/database" # Generate the sqlite database for the index backend if it does not exist if ! [ -f "./storage/database/torrust_index_backend_e2e_testing.db" ]; then # todo: it should get the path from config.toml and only do it when we use sqlite - touch ./storage/database/torrust_index_backend_e2e_testing.db - echo ";" | sqlite3 ./storage/database/torrust_index_backend_e2e_testing.db + sqlite3 ./storage/database/torrust_index_backend_e2e_testing.db "VACUUM;" fi # Generate the sqlite database for the tracker if it does not exist -if ! [ -f "./storage/database/torrust_tracker_e2e_testing.db" ]; then - touch ./storage/database/torrust_tracker_e2e_testing.db - echo ";" | sqlite3 ./storage/database/torrust_tracker_e2e_testing.db +if ! [ -f "./storage/tracker/lib/database/torrust_tracker_e2e_testing.db" ]; then + sqlite3 ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db "VACUUM;" fi ./docker/bin/e2e/sqlite/e2e-env-up.sh diff --git a/docker/bin/e2e/sqlite/e2e-env-up.sh b/docker/bin/e2e/sqlite/e2e-env-up.sh index 25911757..ca3442cf 100755 --- a/docker/bin/e2e/sqlite/e2e-env-up.sh +++ b/docker/bin/e2e/sqlite/e2e-env-up.sh @@ -5,6 +5,9 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.sqlite.local.toml) \ + TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_backend_e2e_testing" \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ - TORRUST_TRACKER_API_TOKEN=${TORRUST_TRACKER_API_TOKEN:-MyAccessToken} \ + TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-sqlite3} \ + TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ docker compose up -d + From 891a744aaa8058a0518f3bffa1b4211f15fa3db1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 11 Sep 2023 16:17:26 +0100 Subject: [PATCH 301/357] fix: clean sqlite bash commands --- bin/install.sh | 12 ++++++------ docker/bin/e2e/sqlite/e2e-env-reset.sh | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/bin/install.sh b/bin/install.sh index 30b8a200..9700d421 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -10,13 +10,13 @@ mkdir -p "./storage/database" # Generate the sqlite database for the index backend if it does not exist if ! [ -f "./storage/database/data.db" ]; then - # todo: it should get the path from config.toml and only do it when we use sqlite - touch ./storage/database/data.db - echo ";" | sqlite3 ./storage/database/data.db + sqlite3 ./storage/database/data.db "VACUUM;" fi +# Generate storage directory if it does not exist +mkdir -p "./storage/tracker/lib/database" + # Generate the sqlite database for the tracker if it does not exist -if ! [ -f "./storage/database/tracker.db" ]; then - touch ./storage/database/tracker.db - echo ";" | sqlite3 ./storage/database/tracker.db +if ! [ -f "./storage/tracker/lib/database/sqlite3.db" ]; then + sqlite3 ./storage/tracker/lib/database/sqlite3.db "VACUUM;" fi diff --git a/docker/bin/e2e/sqlite/e2e-env-reset.sh b/docker/bin/e2e/sqlite/e2e-env-reset.sh index b3ff33da..e6d995a3 100755 --- a/docker/bin/e2e/sqlite/e2e-env-reset.sh +++ b/docker/bin/e2e/sqlite/e2e-env-reset.sh @@ -12,7 +12,6 @@ mkdir -p "./storage/database" # Generate the sqlite database for the index backend if it does not exist if ! [ -f "./storage/database/torrust_index_backend_e2e_testing.db" ]; then - # todo: it should get the path from config.toml and only do it when we use sqlite sqlite3 ./storage/database/torrust_index_backend_e2e_testing.db "VACUUM;" fi From d9cdd6576683e52c9154bb0fe40fb1c8c326e86f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 1 Sep 2023 11:18:50 +0100 Subject: [PATCH 302/357] feat: new binary to par torrent files --- .vscode/launch.json | 35 +++++++++++++++ src/bin/parse_torrent.rs | 41 ++++++++++++++++++ ...f2d3eec881207dcc5ca5a2c3a2a3afe462.torrent | Bin 0 -> 123593 bytes 3 files changed, 76 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 src/bin/parse_torrent.rs create mode 100644 tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..f6f4ed4c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug executable 'parse_torrent'", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/target/debug/parse_torrent", + "args": ["./tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent"], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "externalConsole": false, + "MIMode": "gdb", + "setupCommands": [ + { + "description": "Enable pretty-printing for gdb", + "text": "-enable-pretty-printing", + "ignoreFailures": true + } + ], + "preLaunchTask": "cargo build", + "miDebuggerPath": "/usr/bin/gdb", + "linux": { + "miDebuggerPath": "/usr/bin/gdb" + }, + "windows": { + "miDebuggerPath": "C:\\MinGW\\bin\\gdb.exe" + }, + "osx": { + "miDebuggerPath": "/usr/local/bin/gdb" + } + } + ] +} \ No newline at end of file diff --git a/src/bin/parse_torrent.rs b/src/bin/parse_torrent.rs new file mode 100644 index 00000000..ccef09a9 --- /dev/null +++ b/src/bin/parse_torrent.rs @@ -0,0 +1,41 @@ +//! Command line tool to parse a torrent file and print the decoded torrent. +//! +//! It's only used for debugging purposes. +use std::env; +use std::fs::File; +use std::io::{self, Read}; + +use serde_bencode::de::from_bytes; +use serde_bencode::value::Value as BValue; +use torrust_index_backend::utils::parse_torrent; + +fn main() -> io::Result<()> { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: cargo run --bin parse_torrent "); + eprintln!("Example: cargo run --bin parse_torrent ./tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent"); + std::process::exit(1); + } + + println!("Reading the torrent file ..."); + + let mut file = File::open(&args[1])?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + + println!("Decoding torrent with standard serde implementation ..."); + + match from_bytes::(&bytes) { + Ok(_value) => match parse_torrent::decode_torrent(&bytes) { + Ok(torrent) => { + println!("Parsed torrent: \n{torrent:#?}"); + Ok(()) + } + Err(e) => Err(io::Error::new(io::ErrorKind::Other, format!("Error: invalid torrent!. {e}"))), + }, + Err(e) => Err(io::Error::new( + io::ErrorKind::Other, + format!("Error: invalid bencode data!. {e}"), + )), + } +} diff --git a/tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent b/tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent new file mode 100644 index 0000000000000000000000000000000000000000..38e24e4b5765be08710e1094e903d5e370f3b065 GIT binary patch literal 123593 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 literal 0 HcmV?d00001 From 3b7a762125402d327d51efc41eebf744905a2617 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 5 Sep 2023 18:26:49 +0100 Subject: [PATCH 303/357] feat: [#261] store original infohashes When you upload a torrent, the infohash might change if the `info` dictionary contains non-standard fields because we remove them. That leads to a different infohash. We store the original infohash in a new table so that we can know if the torrent was previously uploaded. If we do not store the original infohash we could reject uploads producing the same canonical infohash. Still, there is no way for the user to ask if a torrent exists with a given original infohash. They only would be able to interact with the API with the canonical infohash. Sometimes it's useful to use the original infohash, for instance, if you are importing torrents from an external source and you want to check if the original torrent (with the original infohash) was already uploaded. --- .github/workflows/coverage.yaml | 1 + ...7_torrust_multiple_original_infohashes.sql | 46 +++++++ ...7_torrust_multiple_original_infohashes.sql | 48 ++++++++ src/app.rs | 7 +- src/common.rs | 7 +- src/databases/database.rs | 13 ++ src/databases/mysql.rs | 82 ++++++++++--- src/databases/sqlite.rs | 80 +++++++++++-- src/errors.rs | 6 + src/models/torrent_file.rs | 19 +-- src/services/torrent.rs | 113 ++++++++++++++++-- src/tracker/service.rs | 7 +- .../databases/sqlite_v2_0_0.rs | 2 +- src/utils/parse_torrent.rs | 5 +- src/web/api/v1/contexts/torrent/handlers.rs | 4 +- 15 files changed, 390 insertions(+), 50 deletions(-) create mode 100644 migrations/mysql/20230905091837_torrust_multiple_original_infohashes.sql create mode 100644 migrations/sqlite3/20230905091837_torrust_multiple_original_infohashes.sql diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 1a0dfeef..828f2fd6 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -58,6 +58,7 @@ jobs: name: Upload Coverage Report uses: codecov/codecov-action@v3 with: + token: ${{ secrets.CODECOV_TOKEN }} files: ${{ steps.coverage.outputs.report }} verbose: true fail_ci_if_error: true diff --git a/migrations/mysql/20230905091837_torrust_multiple_original_infohashes.sql b/migrations/mysql/20230905091837_torrust_multiple_original_infohashes.sql new file mode 100644 index 00000000..e11a1052 --- /dev/null +++ b/migrations/mysql/20230905091837_torrust_multiple_original_infohashes.sql @@ -0,0 +1,46 @@ +-- Step 1: Create a new table with all infohashes +CREATE TABLE torrust_torrent_info_hashes ( + info_hash CHAR(40) NOT NULL, + canonical_info_hash CHAR(40) NOT NULL, + original_is_known BOOLEAN NOT NULL, + PRIMARY KEY(info_hash), + FOREIGN KEY(canonical_info_hash) REFERENCES torrust_torrents(info_hash) ON DELETE CASCADE +); + +-- Step 2: Create one record for each torrent with only the canonical infohash. +-- The original infohash is NULL so we do not know if it was the same. +-- This happens if the uploaded torrent was uploaded before introducing +-- the feature to store the original infohash +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT info_hash, info_hash, FALSE + FROM torrust_torrents + WHERE original_info_hash IS NULL; + +-- Step 3: Create one record for each torrent with the same original and +-- canonical infohashes. +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT info_hash, info_hash, TRUE + FROM torrust_torrents + WHERE original_info_hash IS NOT NULL + AND info_hash = original_info_hash; + +-- Step 4: Create two records for each torrent with a different original and +-- canonical infohashes. One record with the same original and canonical +-- infohashes and one record with the original infohash and the canonical +-- one. +-- Insert the canonical infohash +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT info_hash, info_hash, TRUE + FROM torrust_torrents + WHERE original_info_hash IS NOT NULL + AND info_hash != original_info_hash; +-- Insert the original infohash pointing to the canonical +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT original_info_hash, info_hash, TRUE + FROM torrust_torrents + WHERE original_info_hash IS NOT NULL + AND info_hash != original_info_hash; + +-- Step 5: Delete the `torrust_torrents::original_info_hash` column +ALTER TABLE torrust_torrents DROP COLUMN original_info_hash; + diff --git a/migrations/sqlite3/20230905091837_torrust_multiple_original_infohashes.sql b/migrations/sqlite3/20230905091837_torrust_multiple_original_infohashes.sql new file mode 100644 index 00000000..31585d83 --- /dev/null +++ b/migrations/sqlite3/20230905091837_torrust_multiple_original_infohashes.sql @@ -0,0 +1,48 @@ +-- Step 1: Create a new table with all infohashes +CREATE TABLE IF NOT EXISTS torrust_torrent_info_hashes ( + info_hash TEXT NOT NULL, + canonical_info_hash TEXT NOT NULL, + original_is_known BOOLEAN NOT NULL, + PRIMARY KEY(info_hash), + FOREIGN KEY(canonical_info_hash) REFERENCES torrust_torrents (info_hash) ON DELETE CASCADE +); + +-- Step 2: Create one record for each torrent with only the canonical infohash. +-- The original infohash is NULL so we do not know if it was the same. +-- This happens if the uploaded torrent was uploaded before introducing +-- the feature to store the original infohash +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT info_hash, info_hash, FALSE + FROM torrust_torrents + WHERE original_info_hash is NULL; + +-- Step 3: Create one record for each torrent with the same original and +-- canonical infohashes. +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT info_hash, info_hash, TRUE + FROM torrust_torrents + WHERE original_info_hash is NOT NULL + AND info_hash = original_info_hash; + +-- Step 4: Create two records for each torrent with a different original and +-- canonical infohashes. One record with the same original and canonical +-- infohashes and one record with the original infohash and the canonical +-- one. +-- Insert the canonical infohash +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT info_hash, info_hash, TRUE + FROM torrust_torrents + WHERE original_info_hash is NOT NULL + AND info_hash != original_info_hash; +-- Insert the original infohash pointing to the canonical +INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) +SELECT original_info_hash, info_hash, TRUE + FROM torrust_torrents + WHERE original_info_hash is NOT NULL + AND info_hash != original_info_hash; + +-- Step 5: Delete the `torrust_torrents::original_info_hash` column +-- SQLite 2021-03-12 (3.35.0) supports DROP COLUMN +-- https://www.sqlite.org/lang_altertable.html#alter_table_drop_column +ALTER TABLE torrust_torrents DROP COLUMN original_info_hash; + diff --git a/src/app.rs b/src/app.rs index fce0cfe5..614dda02 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,8 +12,8 @@ use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebTok use crate::services::category::{self, DbCategoryRepository}; use crate::services::tag::{self, DbTagRepository}; use crate::services::torrent::{ - DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, - DbTorrentRepository, DbTorrentTagRepository, + DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoHashRepository, DbTorrentInfoRepository, + DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, }; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; @@ -68,6 +68,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let user_authentication_repository = Arc::new(DbUserAuthenticationRepository::new(database.clone())); let user_profile_repository = Arc::new(DbUserProfileRepository::new(database.clone())); let torrent_repository = Arc::new(DbTorrentRepository::new(database.clone())); + let torrent_info_hash_repository = Arc::new(DbTorrentInfoHashRepository::new(database.clone())); let torrent_info_repository = Arc::new(DbTorrentInfoRepository::new(database.clone())); let torrent_file_repository = Arc::new(DbTorrentFileRepository::new(database.clone())); let torrent_announce_url_repository = Arc::new(DbTorrentAnnounceUrlRepository::new(database.clone())); @@ -92,6 +93,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running user_repository.clone(), category_repository.clone(), torrent_repository.clone(), + torrent_info_hash_repository.clone(), torrent_info_repository.clone(), torrent_file_repository.clone(), torrent_announce_url_repository.clone(), @@ -135,6 +137,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running user_authentication_repository, user_profile_repository, torrent_repository, + torrent_info_hash_repository, torrent_info_repository, torrent_file_repository, torrent_announce_url_repository, diff --git a/src/common.rs b/src/common.rs index 0af991a2..09255678 100644 --- a/src/common.rs +++ b/src/common.rs @@ -7,8 +7,8 @@ use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebTok use crate::services::category::{self, DbCategoryRepository}; use crate::services::tag::{self, DbTagRepository}; use crate::services::torrent::{ - DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, - DbTorrentRepository, DbTorrentTagRepository, + DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoHashRepository, DbTorrentInfoRepository, + DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, }; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; use crate::services::{proxy, settings, torrent}; @@ -34,6 +34,7 @@ pub struct AppData { pub user_authentication_repository: Arc, pub user_profile_repository: Arc, pub torrent_repository: Arc, + pub torrent_info_hash_repository: Arc, pub torrent_info_repository: Arc, pub torrent_file_repository: Arc, pub torrent_announce_url_repository: Arc, @@ -69,6 +70,7 @@ impl AppData { user_authentication_repository: Arc, user_profile_repository: Arc, torrent_repository: Arc, + torrent_info_hash_repository: Arc, torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, @@ -101,6 +103,7 @@ impl AppData { user_authentication_repository, user_profile_repository, torrent_repository, + torrent_info_hash_repository, torrent_info_repository, torrent_file_repository, torrent_announce_url_repository, diff --git a/src/databases/database.rs b/src/databases/database.rs index 72d15c18..6b5e8983 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -12,6 +12,7 @@ use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; +use crate::services::torrent::OriginalInfoHashes; /// Database tables to be truncated when upgrading from v1.0.0 to v2.0.0. /// They must be in the correct order to avoid foreign key errors. @@ -87,6 +88,7 @@ pub enum Error { TorrentNotFound, TorrentAlreadyExists, // when uploading an already uploaded info_hash TorrentTitleAlreadyExists, + TorrentInfoHashNotFound, } /// Get the Driver of the Database from the Connection String @@ -229,6 +231,17 @@ pub trait Database: Sync + Send { )) } + /// Returns the list of original infohashes ofr a canonical infohash. + /// + /// When you upload a torrent the infohash migth change because the Index + /// remove the non-standard fields in the `info` dictionary. That makes the + /// infohash change. The canonical infohash is the resulting infohash. + /// This function returns the original infohashes of a canonical infohash. + /// The relationship is 1 canonical infohash -> N original infohashes. + async fn get_torrent_original_info_hashes(&self, canonical: &InfoHash) -> Result; + + async fn insert_torrent_info_hash(&self, original: &InfoHash, canonical: &InfoHash) -> Result<(), Error>; + /// Get torrent's info as `DbTorrentInfo` from `torrent_id`. async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index cb7b3317..8f342f21 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -17,6 +17,7 @@ use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrent use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; +use crate::services::torrent::{DbTorrentInfoHash, OriginalInfoHashes}; use crate::utils::clock; use crate::utils::hex::from_bytes; @@ -250,9 +251,8 @@ impl Database for Mysql { .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("Duplicate entry") { - // Example error message when you try to insert a duplicate category: - // Error: Duplicate entry 'category name SAMPLE_NAME' for key 'torrust_categories.name' + log::error!("DB error: {:?}", err); + if err.message().contains("Duplicate entry") && err.message().contains("name") { database::Error::CategoryAlreadyExists } else { database::Error::Error @@ -425,7 +425,8 @@ impl Database for Mysql { title: &str, description: &str, ) -> Result { - let info_hash = torrent.info_hash(); + let info_hash = torrent.info_hash_hex(); + let canonical_info_hash = torrent.canonical_info_hash(); // open pool connection let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; @@ -444,7 +445,7 @@ impl Database for Mysql { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, original_info_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -455,16 +456,14 @@ impl Database for Mysql { .bind(private) .bind(root_hash) .bind(torrent.info.source.clone()) - .bind(original_info_hash.to_hex_string()) - .execute(&self.pool) + .execute(&mut tx) .await .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("info_hash") { + log::error!("DB error: {:?}", err); + if err.message().contains("Duplicate entry") && err.message().contains("info_hash") { database::Error::TorrentAlreadyExists - } else if err.message().contains("title") { - database::Error::TorrentTitleAlreadyExists } else { database::Error::Error } @@ -472,6 +471,27 @@ impl Database for Mysql { _ => database::Error::Error })?; + // add torrent canonical infohash + + let insert_info_hash_result = + query("INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) VALUES (?, ?, ?)") + .bind(original_info_hash.to_hex_string()) + .bind(canonical_info_hash.to_hex_string()) + .bind(true) + .execute(&mut tx) + .await + .map(|_| ()) + .map_err(|err| { + log::error!("DB error: {:?}", err); + database::Error::Error + }); + + // rollback transaction on error + if let Err(e) = insert_info_hash_result { + drop(tx.rollback().await); + return Err(e); + } + let insert_torrent_files_result = if let Some(length) = torrent.info.length { query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, length) VALUES (?, ?, ?)") .bind(torrent.info.md5sum.clone()) @@ -549,9 +569,8 @@ impl Database for Mysql { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("info_hash") { - database::Error::TorrentAlreadyExists - } else if err.message().contains("title") { + log::error!("DB error: {:?}", err); + if err.message().contains("Duplicate entry") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { database::Error::Error @@ -573,6 +592,40 @@ impl Database for Mysql { } } + async fn get_torrent_original_info_hashes(&self, canonical: &InfoHash) -> Result { + let db_info_hashes = query_as::<_, DbTorrentInfoHash>( + "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE canonical_info_hash = ?", + ) + .bind(canonical.to_hex_string()) + .fetch_all(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + + let info_hashes: Vec = db_info_hashes + .into_iter() + .map(|db_info_hash| { + InfoHash::from_str(&db_info_hash.info_hash) + .unwrap_or_else(|_| panic!("Invalid info-hash in database: {}", db_info_hash.info_hash)) + }) + .collect(); + + Ok(OriginalInfoHashes { + canonical_info_hash: *canonical, + original_info_hashes: info_hashes, + }) + } + + async fn insert_torrent_info_hash(&self, info_hash: &InfoHash, canonical: &InfoHash) -> Result<(), database::Error> { + query("INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) VALUES (?, ?, ?)") + .bind(info_hash.to_hex_string()) + .bind(canonical.to_hex_string()) + .bind(true) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", @@ -678,7 +731,8 @@ impl Database for Mysql { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("UNIQUE") { + log::error!("DB error: {:?}", err); + if err.message().contains("Duplicate entry") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { database::Error::Error diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 14aa8808..d183cd80 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -17,6 +17,7 @@ use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrent use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; +use crate::services::torrent::{DbTorrentInfoHash, OriginalInfoHashes}; use crate::utils::clock; use crate::utils::hex::from_bytes; @@ -240,7 +241,8 @@ impl Database for Sqlite { .map(|v| v.last_insert_rowid()) .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("UNIQUE") { + log::error!("DB error: {:?}", err); + if err.message().contains("UNIQUE") && err.message().contains("name") { database::Error::CategoryAlreadyExists } else { database::Error::Error @@ -413,7 +415,8 @@ impl Database for Sqlite { title: &str, description: &str, ) -> Result { - let info_hash = torrent.info_hash(); + let info_hash = torrent.info_hash_hex(); + let canonical_info_hash = torrent.canonical_info_hash(); // open pool connection let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; @@ -432,7 +435,7 @@ impl Database for Sqlite { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, original_info_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -443,16 +446,14 @@ impl Database for Sqlite { .bind(private) .bind(root_hash) .bind(torrent.info.source.clone()) - .bind(original_info_hash.to_hex_string()) - .execute(&self.pool) + .execute(&mut tx) .await .map(|v| v.last_insert_rowid()) .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("info_hash") { + log::error!("DB error: {:?}", err); + if err.message().contains("UNIQUE") && err.message().contains("info_hash") { database::Error::TorrentAlreadyExists - } else if err.message().contains("title") { - database::Error::TorrentTitleAlreadyExists } else { database::Error::Error } @@ -460,6 +461,27 @@ impl Database for Sqlite { _ => database::Error::Error })?; + // add torrent canonical infohash + + let insert_info_hash_result = + query("INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) VALUES (?, ?, ?)") + .bind(original_info_hash.to_hex_string()) + .bind(canonical_info_hash.to_hex_string()) + .bind(true) + .execute(&mut tx) + .await + .map(|_| ()) + .map_err(|err| { + log::error!("DB error: {:?}", err); + database::Error::Error + }); + + // rollback transaction on error + if let Err(e) = insert_info_hash_result { + drop(tx.rollback().await); + return Err(e); + } + let insert_torrent_files_result = if let Some(length) = torrent.info.length { query("INSERT INTO torrust_torrent_files (md5sum, torrent_id, length) VALUES (?, ?, ?)") .bind(torrent.info.md5sum.clone()) @@ -537,9 +559,8 @@ impl Database for Sqlite { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("info_hash") { - database::Error::TorrentAlreadyExists - } else if err.message().contains("title") { + log::error!("DB error: {:?}", err); + if err.message().contains("UNIQUE") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { database::Error::Error @@ -561,6 +582,40 @@ impl Database for Sqlite { } } + async fn get_torrent_original_info_hashes(&self, canonical: &InfoHash) -> Result { + let db_info_hashes = query_as::<_, DbTorrentInfoHash>( + "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE canonical_info_hash = ?", + ) + .bind(canonical.to_hex_string()) + .fetch_all(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + + let info_hashes: Vec = db_info_hashes + .into_iter() + .map(|db_info_hash| { + InfoHash::from_str(&db_info_hash.info_hash) + .unwrap_or_else(|_| panic!("Invalid info-hash in database: {}", db_info_hash.info_hash)) + }) + .collect(); + + Ok(OriginalInfoHashes { + canonical_info_hash: *canonical, + original_info_hashes: info_hashes, + }) + } + + async fn insert_torrent_info_hash(&self, original: &InfoHash, canonical: &InfoHash) -> Result<(), database::Error> { + query("INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) VALUES (?, ?, ?)") + .bind(original.to_hex_string()) + .bind(canonical.to_hex_string()) + .bind(true) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(|err| database::Error::ErrorWithText(err.to_string())) + } + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", @@ -666,7 +721,8 @@ impl Database for Sqlite { .await .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("UNIQUE") { + log::error!("DB error: {:?}", err); + if err.message().contains("UNIQUE") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { database::Error::Error diff --git a/src/errors.rs b/src/errors.rs index c3cd08ea..6706cc57 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -112,6 +112,9 @@ pub enum ServiceError { #[display(fmt = "This torrent already exists in our database.")] InfoHashAlreadyExists, + #[display(fmt = "A torrent with the same canonical infohash already exists in our database.")] + CanonicalInfoHashAlreadyExists, + #[display(fmt = "This torrent title has already been used.")] TorrentTitleAlreadyExists, @@ -147,6 +150,7 @@ impl From for ServiceError { if let Some(err) = e.as_database_error() { return if err.code() == Some(Cow::from("2067")) { if err.message().contains("torrust_torrents.info_hash") { + println!("info_hash already exists {}", err.message()); ServiceError::InfoHashAlreadyExists } else { ServiceError::InternalServerError @@ -228,6 +232,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::InvalidTag => StatusCode::BAD_REQUEST, ServiceError::Unauthorized => StatusCode::FORBIDDEN, ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::CanonicalInfoHashAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST, @@ -259,5 +264,6 @@ pub fn map_database_error_to_service_error(error: &database::Error) -> ServiceEr database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists, database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists, database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError, + database::Error::TorrentInfoHashNotFound => ServiceError::TorrentNotFound, } } diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 125a457e..97294252 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -3,6 +3,7 @@ use serde_bencode::ser; use serde_bytes::ByteBuf; use sha1::{Digest, Sha1}; +use super::info_hash::InfoHash; use crate::config::Configuration; use crate::services::torrent_file::NewTorrentInfoRequest; use crate::utils::hex::{from_bytes, into_bytes}; @@ -228,11 +229,15 @@ impl Torrent { } #[must_use] - pub fn info_hash(&self) -> String { - // todo: return an InfoHash struct + pub fn info_hash_hex(&self) -> String { from_bytes(&self.calculate_info_hash_as_bytes()).to_lowercase() } + #[must_use] + pub fn canonical_info_hash(&self) -> InfoHash { + self.calculate_info_hash_as_bytes().into() + } + #[must_use] pub fn file_size(&self) -> i64 { match self.info.length { @@ -372,7 +377,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + assert_eq!(torrent.info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); } mod infohash_should_be_calculated_for { @@ -413,7 +418,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + assert_eq!(torrent.info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); } #[test] @@ -452,7 +457,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash(), "aa2aca91ab650c4d249c475ca3fa604f2ccb0d2a"); + assert_eq!(torrent.info_hash_hex(), "aa2aca91ab650c4d249c475ca3fa604f2ccb0d2a"); } #[test] @@ -487,7 +492,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash(), "ccc1cf4feb59f3fa85c96c9be1ebbafcfe8a9cc8"); + assert_eq!(torrent.info_hash_hex(), "ccc1cf4feb59f3fa85c96c9be1ebbafcfe8a9cc8"); } #[test] @@ -522,7 +527,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash(), "d3a558d0a19aaa23ba6f9f430f40924d10fefa86"); + assert_eq!(torrent.info_hash_hex(), "d3a558d0a19aaa23ba6f9f430f40924d10fefa86"); } } } diff --git a/src/services/torrent.rs b/src/services/torrent.rs index aa6b8b0b..19cf082b 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -1,7 +1,8 @@ //! Torrent service. use std::sync::Arc; -use serde_derive::Deserialize; +use log::debug; +use serde_derive::{Deserialize, Serialize}; use super::category::DbCategoryRepository; use super::user::DbUserRepository; @@ -28,6 +29,7 @@ pub struct Index { user_repository: Arc, category_repository: Arc, torrent_repository: Arc, + torrent_info_hash_repository: Arc, torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, @@ -83,6 +85,7 @@ impl Index { user_repository: Arc, category_repository: Arc, torrent_repository: Arc, + torrent_info_hash_repository: Arc, torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, @@ -96,6 +99,7 @@ impl Index { user_repository, category_repository, torrent_repository, + torrent_info_hash_repository, torrent_info_repository, torrent_file_repository, torrent_announce_url_repository, @@ -162,25 +166,51 @@ impl Index { .await .map_err(|_| ServiceError::InvalidCategory)?; + let canonical_info_hash = torrent.canonical_info_hash(); + + let original_info_hashes = self + .torrent_info_hash_repository + .get_torrent_original_info_hashes(&canonical_info_hash) + .await?; + + if !original_info_hashes.is_empty() { + // Torrent with the same canonical infohash was already uploaded + debug!("Canonical infohash found: {:?}", canonical_info_hash.to_hex_string()); + + if let Some(original_info_hash) = original_info_hashes.find(&original_info_hash) { + // The exact original infohash was already uploaded + debug!("Original infohash found: {:?}", original_info_hash.to_hex_string()); + + return Err(ServiceError::InfoHashAlreadyExists); + } + + // A new original infohash is being uploaded with a canonical infohash that already exists. + debug!("Original infohash not found: {:?}", original_info_hash.to_hex_string()); + + // Add the new associated original infohash to the canonical one. + self.torrent_info_hash_repository + .add(&original_info_hash, &canonical_info_hash) + .await?; + return Err(ServiceError::CanonicalInfoHashAlreadyExists); + } + + // First time a torrent with this original infohash is uploaded. + let torrent_id = self .torrent_repository .add(&original_info_hash, &torrent, &metadata, user_id, category) .await?; - - let info_hash: InfoHash = torrent - .info_hash() - .parse() - .expect("the parsed torrent should have a valid info hash"); + let info_hash = torrent.canonical_info_hash(); drop( self.tracker_statistics_importer - .import_torrent_statistics(torrent_id, &torrent.info_hash()) + .import_torrent_statistics(torrent_id, &torrent.info_hash_hex()) .await, ); // We always whitelist the torrent on the tracker because even if the tracker mode is `public` // it could be changed to `private` later on. - if let Err(e) = self.tracker_service.whitelist_info_hash(torrent.info_hash()).await { + if let Err(e) = self.tracker_service.whitelist_info_hash(torrent.info_hash_hex()).await { // If the torrent can't be whitelisted somehow, remove the torrent from database drop(self.torrent_repository.delete(&torrent_id).await); return Err(e); @@ -518,6 +548,73 @@ impl DbTorrentRepository { } } +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DbTorrentInfoHash { + pub info_hash: String, + pub canonical_info_hash: String, + pub original_is_known: bool, +} + +pub struct DbTorrentInfoHashRepository { + database: Arc>, +} + +pub struct OriginalInfoHashes { + pub canonical_info_hash: InfoHash, + pub original_info_hashes: Vec, +} + +impl OriginalInfoHashes { + #[must_use] + pub fn is_empty(&self) -> bool { + self.original_info_hashes.is_empty() + } + + #[must_use] + pub fn find(&self, original_info_hash: &InfoHash) -> Option<&InfoHash> { + self.original_info_hashes.iter().find(|&hash| *hash == *original_info_hash) + } +} + +impl DbTorrentInfoHashRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// It returns all the original infohashes associated to the canonical one. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn get_torrent_original_info_hashes(&self, info_hash: &InfoHash) -> Result { + self.database.get_torrent_original_info_hashes(info_hash).await + } + + /// Inserts a new infohash for the torrent. Torrents can be associated to + /// different infohashes because the Index might change the original infohash. + /// The index track the final infohash used (canonical) and all the original + /// ones. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn add(&self, original_info_hash: &InfoHash, canonical_info_hash: &InfoHash) -> Result<(), Error> { + self.database + .insert_torrent_info_hash(original_info_hash, canonical_info_hash) + .await + } + + /// Deletes the entire torrent in the database. + /// + /// # Errors + /// + /// This function will return an error there is a database error. + pub async fn delete(&self, torrent_id: &TorrentId) -> Result<(), Error> { + self.database.delete_torrent(*torrent_id).await + } +} + pub struct DbTorrentInfoRepository { database: Arc>, } diff --git a/src/tracker/service.rs b/src/tracker/service.rs index c49c7ac1..e39cf0a6 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -147,12 +147,17 @@ impl Service { let body = response.text().await; if let Ok(body) = body { + if body == *"torrent not known" { + // todo: temporary fix. the service should return a 404 (StatusCode::NOT_FOUND). + return Err(ServiceError::TorrentNotFound); + } + let torrent_info = serde_json::from_str(&body); if let Ok(torrent_info) = torrent_info { Ok(torrent_info) } else { - error!("Failed to parse torrent info from tracker response"); + error!("Failed to parse torrent info from tracker response. Body: {}", body); Err(ServiceError::InternalServerError) } } else { diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 8fbf3aa2..eb298687 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -118,7 +118,7 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) .map_err(|e| match e { sqlx::Error::Database(err) => { - if err.message().contains("UNIQUE") { + if err.message().contains("UNIQUE") && err.message().contains("name") { database::Error::CategoryAlreadyExists } else { database::Error::Error diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 0a0999ac..21a219d5 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -98,6 +98,9 @@ mod tests { // The infohash is not the original infohash of the torrent file, // but the infohash of the info dictionary without the custom keys. - assert_eq!(torrent.info_hash(), "8aa01a4c816332045ffec83247ccbc654547fedf".to_string()); + assert_eq!( + torrent.info_hash_hex(), + "8aa01a4c816332045ffec83247ccbc654547fedf".to_string() + ); } } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 2165256c..6f9c158a 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -92,7 +92,7 @@ pub async fn download_torrent_handler( return ServiceError::InternalServerError.into_response(); }; - torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash()) + torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash_hex()) } /// It returns a list of torrents matching the search criteria. @@ -242,7 +242,7 @@ pub async fn create_random_torrent_handler(State(_app_data): State> return ServiceError::InternalServerError.into_response(); }; - torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash()) + torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash_hex()) } /// Extracts the [`TorrentRequest`] from the multipart form payload. From 110e1596d0af58bd216735ba939b63930d21562a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 6 Sep 2023 18:29:55 +0100 Subject: [PATCH 304/357] test: [#261]: do not allow uploading two torrents with the same canonical infohash If you upload a torrent, the infohash migth change if the `info` dictionary contains custom fields. The Index removes non-standard custom fields, and that generates a new infohash for the torrent. If you upload a second torrent which is different from a previous one only in the custom fields, the same canonical infohash will be generated, so the torrent will be rejected as duplicated. The new original infohash will be stored in the database. --- .github/workflows/coverage.yaml | 41 ++++++- .github/workflows/testing.yaml | 8 +- src/databases/database.rs | 7 +- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/lib.rs | 2 +- src/services/torrent.rs | 8 +- src/web/api/mod.rs | 2 +- src/web/api/v1/contexts/torrent/mod.rs | 26 +++++ src/web/api/v1/mod.rs | 2 +- tests/common/contexts/torrent/file.rs | 10 +- tests/common/contexts/torrent/fixtures.rs | 105 ++++++++++++++++++ .../web/api/v1/contexts/torrent/contract.rs | 35 +++++- 13 files changed, 226 insertions(+), 24 deletions(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 828f2fd6..2bc0b3e4 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -2,14 +2,37 @@ name: Coverage on: push: - pull_request: + branches: + - develop + pull_request_target: + branches: + - develop env: CARGO_TERM_COLOR: always jobs: + secrets: + name: Secrets + environment: coverage + runs-on: ubuntu-latest + + outputs: + continue: ${{ steps.check.outputs.continue }} + + steps: + - id: check + name: Check + env: + CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}" + if: "${{ env.CODECOV_TOKEN != '' }}" + run: echo "continue=true" >> $GITHUB_OUTPUT + report: name: Report + environment: coverage + needs: secrets + if: needs.secrets.outputs.continue == 'true' runs-on: ubuntu-latest env: CARGO_INCREMENTAL: "0" @@ -17,9 +40,17 @@ jobs: RUSTDOCFLAGS: "-Z profile -C codegen-units=1 -C inline-threshold=0 -C link-dead-code -C overflow-checks=off -C panic=abort -Z panic_abort_tests" steps: - - id: checkout - name: Checkout Repository - uses: actions/checkout@v3 + - id: checkout_push + if: github.event_name == 'push' + name: Checkout Repository (Push) + uses: actions/checkout@v4 + + - id: checkout_pull_request_target + if: github.event_name == 'pull_request_target' + name: Checkout Repository (Pull Request Target) + uses: actions/checkout@v4 + with: + ref: "refs/pull/${{ github.event.pull_request.number }}/head" - id: setup name: Setup Toolchain @@ -61,4 +92,4 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ steps.coverage.outputs.report }} verbose: true - fail_ci_if_error: true + fail_ci_if_error: true \ No newline at end of file diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index beaf0754..6c1fc1e3 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -65,10 +65,14 @@ jobs: name: Run Lint Checks run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic - - id: doc - name: Run Documentation Checks + - id: testdoc + name: Run Documentation Tests run: cargo test --doc + - id: builddoc + name: Build Documentation + run: cargo doc --no-deps --bins --examples --workspace --all-features + unit: name: Units runs-on: ubuntu-latest diff --git a/src/databases/database.rs b/src/databases/database.rs index 6b5e8983..84b506a5 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -231,14 +231,17 @@ pub trait Database: Sync + Send { )) } - /// Returns the list of original infohashes ofr a canonical infohash. + /// Returns the list of all infohashes producing the same canonical infohash. /// /// When you upload a torrent the infohash migth change because the Index /// remove the non-standard fields in the `info` dictionary. That makes the /// infohash change. The canonical infohash is the resulting infohash. /// This function returns the original infohashes of a canonical infohash. + /// + /// If the original infohash was unknown, it returns the canonical infohash. + /// /// The relationship is 1 canonical infohash -> N original infohashes. - async fn get_torrent_original_info_hashes(&self, canonical: &InfoHash) -> Result; + async fn get_torrent_canonical_info_hash_group(&self, canonical: &InfoHash) -> Result; async fn insert_torrent_info_hash(&self, original: &InfoHash, canonical: &InfoHash) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 8f342f21..38edcdde 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -592,7 +592,7 @@ impl Database for Mysql { } } - async fn get_torrent_original_info_hashes(&self, canonical: &InfoHash) -> Result { + async fn get_torrent_canonical_info_hash_group(&self, canonical: &InfoHash) -> Result { let db_info_hashes = query_as::<_, DbTorrentInfoHash>( "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE canonical_info_hash = ?", ) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index d183cd80..6cae2d4a 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -582,7 +582,7 @@ impl Database for Sqlite { } } - async fn get_torrent_original_info_hashes(&self, canonical: &InfoHash) -> Result { + async fn get_torrent_canonical_info_hash_group(&self, canonical: &InfoHash) -> Result { let db_info_hashes = query_as::<_, DbTorrentInfoHash>( "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE canonical_info_hash = ?", ) diff --git a/src/lib.rs b/src/lib.rs index faffb360..8712093f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -202,7 +202,7 @@ //! torrent_info_update_interval = 3600 //! ``` //! -//! For more information about configuration you can visit the documentation for the [`config`](crate::config) module. +//! For more information about configuration you can visit the documentation for the [`config`]) module. //! //! Alternatively to the `config.toml` file you can use one environment variable `TORRUST_IDX_BACK_CONFIG` to pass the configuration to the tracker: //! diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 19cf082b..635e6016 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -170,7 +170,7 @@ impl Index { let original_info_hashes = self .torrent_info_hash_repository - .get_torrent_original_info_hashes(&canonical_info_hash) + .get_canonical_info_hash_group(&canonical_info_hash) .await?; if !original_info_hashes.is_empty() { @@ -582,13 +582,13 @@ impl DbTorrentInfoHashRepository { Self { database } } - /// It returns all the original infohashes associated to the canonical one. + /// It returns all the infohashes associated to the canonical one. /// /// # Errors /// /// This function will return an error there is a database error. - pub async fn get_torrent_original_info_hashes(&self, info_hash: &InfoHash) -> Result { - self.database.get_torrent_original_info_hashes(info_hash).await + pub async fn get_canonical_info_hash_group(&self, info_hash: &InfoHash) -> Result { + self.database.get_torrent_canonical_info_hash_group(info_hash).await } /// Inserts a new infohash for the torrent. Torrents can be associated to diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs index 46ffe2b7..a7b5bf86 100644 --- a/src/web/api/mod.rs +++ b/src/web/api/mod.rs @@ -2,7 +2,7 @@ //! //! Currently, the API has only one version: `v1`. //! -//! Refer to the [`v1`](crate::web::api::v1) module for more information. +//! Refer to the [`v1`]) module for more information. pub mod server; pub mod v1; diff --git a/src/web/api/v1/contexts/torrent/mod.rs b/src/web/api/v1/contexts/torrent/mod.rs index 82536cb8..a4a95de1 100644 --- a/src/web/api/v1/contexts/torrent/mod.rs +++ b/src/web/api/v1/contexts/torrent/mod.rs @@ -2,6 +2,32 @@ //! //! This API context is responsible for handling all torrent related requests. //! +//! # Original and canonical infohashes +//! +//! Uploaded torrents can contain non-standard fields in the `info` dictionary. +//! +//! For example, this is a torrent file in JSON format with a "custom" field. +//! +//! ```json +//! { +//! "info": { +//! "length": 602515, +//! "name": "mandelbrot_set_01", +//! "piece length": 32768, +//! "pieces": "8A 88 32 BE ED 05 5F AA C4 AF 4A 90 4B 9A BF 0D EC 83 42 1C 73 39 05 B8 D6 20 2C 1B D1 8A 53 28 1F B5 D4 23 0A 23 C8 DB AC C4 E6 6B 16 12 08 C7 A4 AD 64 45 70 ED 91 0D F1 38 E7 DF 0C 1A D0 C9 23 27 7C D1 F9 D4 E5 A1 5F F5 E5 A0 E4 9E FB B1 43 F5 4B AD 0E D4 9D CB 49 F7 E6 7B BA 30 5F AF F9 88 56 FB 45 9A B4 95 92 3E 2C 7F DA A6 D3 82 E7 63 A3 BB 4B 28 F3 57 C7 CB 7D 8C 06 E3 46 AB D7 E8 8E 8A 8C 9F C7 E6 C5 C5 64 82 ED 47 BB 2A F1 B7 3F A5 3C 5B 9C AF 43 EC 2A E1 08 68 9A 49 C8 BF 1B 07 AD BE E9 2D 7E BE 9C 18 7F 4C A1 97 0E 54 3A 18 94 0E 60 8D 5C 69 0E 41 46 0D 3C 9A 37 F6 81 62 4F 95 C0 73 92 CA 9A D5 A9 89 AC 8B 85 12 53 0B FB E2 96 26 3E 26 A6 5B 70 53 48 65 F3 6C 27 0F 6B BD 1C EE EB 1A 9D 5F 77 A8 D8 AF D8 14 82 4A E0 B4 62 BC F1 A5 F5 F2 C7 60 F8 38 C8 5B 0B A9 07 DD 86 FA C0 7B F0 26 D7 D1 9A 42 C3 1F 9F B9 59 83 10 62 41 E9 06 3C 6D A1 19 75 01 57 25 9E B7 FE DF 91 04 D4 51 4B 6D 44 02 8D 31 8E 84 26 95 0F 30 31 F0 2C 16 39 BD 53 1D CF D3 5E 3E 41 A9 1E 14 3F 73 24 AC 5E 9E FC 4D C5 70 45 0F 45 8B 9B 52 E6 D0 26 47 8F 43 08 9E 2A 7C C5 92 D5 86 36 FE 48 E9 B8 86 84 92 23 49 5B EE C4 31 B2 1D 10 75 8E 4C 07 84 8F", +//! "custom": "custom03" +//! } +//! } +//! ``` +//! +//! When you upload a torrent file with non-standards fields in the `info` +//! dictionary, the Index removes those non-standard fields. That generates a +//! new info-hash because all fields in the `info` key are used to calculate it. +//! +//! The Index stores the original info-hash. The resulting info-hash after +//! removing the non-standard fields is called "canonical" infohash. The Index +//! stores the relationship between the original info-hash and the canonical one. +//! //! # Endpoints //! //! - [Upload new torrent](#upload-new-torrent) diff --git a/src/web/api/v1/mod.rs b/src/web/api/v1/mod.rs index e9b3e9d6..d7ae5bab 100644 --- a/src/web/api/v1/mod.rs +++ b/src/web/api/v1/mod.rs @@ -2,7 +2,7 @@ //! //! The API is organized in contexts. //! -//! Refer to the [`contexts`](crate::web::api::v1::contexts) module for more +//! Refer to the [`contexts`] module for more //! information. pub mod auth; pub mod contexts; diff --git a/tests/common/contexts/torrent/file.rs b/tests/common/contexts/torrent/file.rs index ce3fbf95..b5f58339 100644 --- a/tests/common/contexts/torrent/file.rs +++ b/tests/common/contexts/torrent/file.rs @@ -8,21 +8,21 @@ use serde::Deserialize; use which::which; /// Attributes parsed from a torrent file. -#[derive(Deserialize, Clone)] +#[derive(Deserialize, Clone, Debug)] pub struct TorrentFileInfo { pub name: String, pub comment: Option, - pub creation_date: u64, - pub created_by: String, + pub creation_date: Option, + pub created_by: Option, pub source: Option, pub info_hash: String, pub torrent_size: u64, pub content_size: u64, pub private: bool, pub tracker: Option, - pub announce_list: Vec>, + pub announce_list: Option>>, pub update_url: Option, - pub dht_nodes: Vec, + pub dht_nodes: Option>, pub piece_size: u64, pub piece_count: u64, pub file_count: u64, diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index a464651f..5e89ce6e 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -2,7 +2,11 @@ use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; +use serde_bytes::ByteBuf; use tempfile::{tempdir, TempDir}; +use torrust_index_backend::services::hasher::sha1; +use torrust_index_backend::utils::hex::into_bytes; use uuid::Uuid; use super::file::{create_torrent, parse_torrent, TorrentFileInfo}; @@ -94,6 +98,45 @@ impl TestTorrent { } } + pub fn with_custom_info_dict_field(id: Uuid, file_contents: &str, custom: &str) -> Self { + let temp_dir = temp_dir(); + + let torrents_dir_path = temp_dir.path().to_owned(); + + // Create the torrent in memory + let torrent = TestTorrentWithCustomInfoField::with_contents(id, file_contents, custom); + + // Bencode the torrent + let torrent_data = TestTorrentWithCustomInfoField::encode(&torrent).unwrap(); + + // Torrent temporary file path + let filename = format!("file-{id}.txt.torrent"); + let torrent_path = torrents_dir_path.join(filename.clone()); + + // Write the torrent file to the temporary file + let mut file = File::create(torrent_path.clone()).unwrap(); + file.write_all(&torrent_data).unwrap(); + + // Load torrent binary file + let torrent_file = BinaryFile::from_file_at_path(&torrent_path); + + // Load torrent file metadata + let torrent_info = parse_torrent(&torrent_path); + + let torrent_to_index = TorrentIndexInfo { + title: format!("title-{id}"), + description: format!("description-{id}"), + category: software_predefined_category_name(), + torrent_file, + name: filename, + }; + + TestTorrent { + file_info: torrent_info, + index_info: torrent_to_index, + } + } + pub fn info_hash(&self) -> InfoHash { self.file_info.info_hash.clone() } @@ -128,3 +171,65 @@ pub fn random_txt_file(dir: &Path, id: &Uuid) -> String { pub fn temp_dir() -> TempDir { tempdir().unwrap() } + +/// A minimal torrent file with a custom field in the info dict. +/// +/// ```json +/// { +/// "info": { +/// "length": 602515, +/// "name": "mandelbrot_set_01", +/// "piece length": 32768, +/// "pieces": "8A 88 32 BE ED 05 5F AA C4 AF 4A 90 4B 9A BF 0D EC 83 42 1C 73 39 05 B8 D6 20 2C 1B D1 8A 53 28 1F B5 D4 23 0A 23 C8 DB AC C4 E6 6B 16 12 08 C7 A4 AD 64 45 70 ED 91 0D F1 38 E7 DF 0C 1A D0 C9 23 27 7C D1 F9 D4 E5 A1 5F F5 E5 A0 E4 9E FB B1 43 F5 4B AD 0E D4 9D CB 49 F7 E6 7B BA 30 5F AF F9 88 56 FB 45 9A B4 95 92 3E 2C 7F DA A6 D3 82 E7 63 A3 BB 4B 28 F3 57 C7 CB 7D 8C 06 E3 46 AB D7 E8 8E 8A 8C 9F C7 E6 C5 C5 64 82 ED 47 BB 2A F1 B7 3F A5 3C 5B 9C AF 43 EC 2A E1 08 68 9A 49 C8 BF 1B 07 AD BE E9 2D 7E BE 9C 18 7F 4C A1 97 0E 54 3A 18 94 0E 60 8D 5C 69 0E 41 46 0D 3C 9A 37 F6 81 62 4F 95 C0 73 92 CA 9A D5 A9 89 AC 8B 85 12 53 0B FB E2 96 26 3E 26 A6 5B 70 53 48 65 F3 6C 27 0F 6B BD 1C EE EB 1A 9D 5F 77 A8 D8 AF D8 14 82 4A E0 B4 62 BC F1 A5 F5 F2 C7 60 F8 38 C8 5B 0B A9 07 DD 86 FA C0 7B F0 26 D7 D1 9A 42 C3 1F 9F B9 59 83 10 62 41 E9 06 3C 6D A1 19 75 01 57 25 9E B7 FE DF 91 04 D4 51 4B 6D 44 02 8D 31 8E 84 26 95 0F 30 31 F0 2C 16 39 BD 53 1D CF D3 5E 3E 41 A9 1E 14 3F 73 24 AC 5E 9E FC 4D C5 70 45 0F 45 8B 9B 52 E6 D0 26 47 8F 43 08 9E 2A 7C C5 92 D5 86 36 FE 48 E9 B8 86 84 92 23 49 5B EE C4 31 B2 1D 10 75 8E 4C 07 84 8F", +/// "custom": "custom03" +/// } +/// } +/// ``` +/// +/// Changing the value of the `custom` field will change the info-hash of the torrent. +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +pub struct TestTorrentWithCustomInfoField { + pub info: InfoDictWithCustomField, +} + +/// A minimal torrent info dict with a custom field. +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct InfoDictWithCustomField { + #[serde(default)] + pub length: i64, + #[serde(default)] + pub name: String, + #[serde(rename = "piece length")] + pub piece_length: i64, + #[serde(default)] + pub pieces: ByteBuf, + #[serde(default)] + pub custom: String, +} + +impl TestTorrentWithCustomInfoField { + pub fn with_contents(id: Uuid, file_contents: &str, custom: &str) -> Self { + let sha1_of_file_contents = sha1(file_contents); + let pieces = into_bytes(&sha1_of_file_contents).expect("sha1 of test torrent contents cannot be converted to bytes"); + + Self { + info: InfoDictWithCustomField { + length: i64::try_from(file_contents.len()).expect("file contents size in bytes cannot exceed i64::MAX"), + name: format!("file-{id}.txt"), + piece_length: 16384, + pieces: ByteBuf::from(pieces), + custom: custom.to_owned(), + }, + } + } + + pub fn encode(torrent: &Self) -> Result, serde_bencode::Error> { + match serde_bencode::to_bytes(torrent) { + Ok(bencode_bytes) => Ok(bencode_bytes), + Err(e) => { + eprintln!("{e:?}"); + Err(e) + } + } + } +} diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 9ddd5c33..32236100 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -293,10 +293,11 @@ mod for_authenticated_users { use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; + use uuid::Uuid; use crate::common::asserts::assert_json_error_response; use crate::common::client::Client; - use crate::common::contexts::torrent::fixtures::random_torrent; + use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent}; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::responses::UploadedTorrentResponse; use crate::e2e::environment::TestEnv; @@ -410,6 +411,38 @@ mod for_authenticated_users { assert_eq!(response.status, 400); } + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_whose_canonical_info_hash_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let id1 = Uuid::new_v4(); + + // Upload the first torrent + let first_torrent = TestTorrent::with_custom_info_dict_field(id1, "data", "custom 01"); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same canonical info-hash as the first one. + // We need to change the title otherwise the torrent will be rejected + // because of the duplicate title. + let mut torrent_with_the_same_canonical_info_hash = TestTorrent::with_custom_info_dict_field(id1, "data", "custom 02"); + torrent_with_the_same_canonical_info_hash.index_info.title = format!("{first_torrent_title}-clone"); + let form: UploadTorrentMultipartForm = torrent_with_the_same_canonical_info_hash.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + #[tokio::test] async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() { let mut env = TestEnv::new(); From 05a497779b7b4f40449011eac96e232ecd507240 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 12 Sep 2023 13:26:20 +0100 Subject: [PATCH 305/357] fix: [#279] rename tracker container env var TORRUST_TRACKER_DATABASE_DRIVER It was renamed in the Tracker container. --- .env.local | 2 +- compose.yaml | 2 +- docker/README.md | 2 +- docker/bin/e2e/mysql/e2e-env-up.sh | 2 +- docker/bin/e2e/sqlite/e2e-env-up.sh | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.local b/.env.local index af213711..8d5f8e89 100644 --- a/.env.local +++ b/.env.local @@ -2,5 +2,5 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc TORRUST_IDX_BACK_CONFIG= TORRUST_IDX_BACK_USER_UID=1000 TORRUST_TRACKER_CONFIG= -TORRUST_TRACKER_DATABASE=sqlite3 +TORRUST_TRACKER_DATABASE_DRIVER=sqlite3 TORRUST_TRACKER_API_ADMIN_TOKEN=MyAccessToken diff --git a/compose.yaml b/compose.yaml index 8c09ad96..09956826 100644 --- a/compose.yaml +++ b/compose.yaml @@ -41,7 +41,7 @@ services: tty: true environment: - TORRUST_TRACKER_CONFIG=${TORRUST_TRACKER_CONFIG} - - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-sqlite3} + - TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-sqlite3} - TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} networks: - server_side diff --git a/docker/README.md b/docker/README.md index 4e094776..cd17789d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -65,7 +65,7 @@ Build and run it locally: ```s TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.local.toml) \ - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-mysql} \ + TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-mysql} \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ docker compose up -d --build diff --git a/docker/bin/e2e/mysql/e2e-env-up.sh b/docker/bin/e2e/mysql/e2e-env-up.sh index ddf54d57..4bbbd9f7 100755 --- a/docker/bin/e2e/mysql/e2e-env-up.sh +++ b/docker/bin/e2e/mysql/e2e-env-up.sh @@ -7,7 +7,7 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.mysql.local.toml) \ TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_backend_e2e_testing" \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-mysql} \ + TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-mysql} \ TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ docker compose up -d diff --git a/docker/bin/e2e/sqlite/e2e-env-up.sh b/docker/bin/e2e/sqlite/e2e-env-up.sh index ca3442cf..8deca42f 100755 --- a/docker/bin/e2e/sqlite/e2e-env-up.sh +++ b/docker/bin/e2e/sqlite/e2e-env-up.sh @@ -7,7 +7,7 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.sqlite.local.toml) \ TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_backend_e2e_testing" \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-sqlite3} \ + TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-sqlite3} \ TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ docker compose up -d From b34c7d896506d7dbc204999a38b2a711422b6bdf Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 13 Sep 2023 13:35:35 +0100 Subject: [PATCH 306/357] test: [#282] compare uploaded and downloaded torrent info-hashes The test for downloading a torrent now compares the uplaoded torrent info-hash with the downloaded one. It fails becuasse the Index adds the `private` field in the `info` dictioanry always, changing the info-hash. --- tests/common/contexts/torrent/fixtures.rs | 2 +- .../web/api/v1/contexts/torrent/contract.rs | 47 ++++++++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index 5e89ce6e..5d82ab2b 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -137,7 +137,7 @@ impl TestTorrent { } } - pub fn info_hash(&self) -> InfoHash { + pub fn info_hash_as_hex_string(&self) -> InfoHash { self.file_info.info_hash.clone() } } diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 32236100..067d3496 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -16,7 +16,7 @@ Get torrent info: mod for_guests { - use torrust_index_backend::utils::parse_torrent::decode_torrent; + use torrust_index_backend::utils::parse_torrent::{calculate_info_hash, decode_torrent}; use torrust_index_backend::web::api; use crate::common::client::Client; @@ -166,7 +166,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.get_torrent(&test_torrent.info_hash()).await; + let response = client.get_torrent(&test_torrent.info_hash_as_hex_string()).await; let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); @@ -226,18 +226,33 @@ mod for_guests { } let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let uploader = new_logged_in_user(&env).await; - let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.download_torrent(&test_torrent.info_hash()).await; + // Upload a new torrent + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); - let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; - assert_eq!(torrent, expected_torrent); + + // Download the torrent + let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + + let downloaded_torrent_info_hash = calculate_info_hash(&response.bytes); + let downloaded_torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + + // Response should be a torrent file and status OK assert!(response.is_bittorrent_and_ok()); + + // Info-hash should be the same + assert_eq!( + downloaded_torrent_info_hash.to_hex_string(), + test_torrent.info_hash_as_hex_string(), + "downloaded torrent info-hash does not match uploaded torrent info-hash" + ); + + // Torrent should be the same + let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; + assert_eq!(downloaded_torrent, expected_torrent); } #[tokio::test] @@ -283,7 +298,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.delete_torrent(&test_torrent.info_hash()).await; + let response = client.delete_torrent(&test_torrent.info_hash_as_hex_string()).await; assert_eq!(response.status, 401); } @@ -319,7 +334,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let test_torrent = random_torrent(); - let info_hash = test_torrent.info_hash().clone(); + let info_hash = test_torrent.info_hash_as_hex_string().clone(); let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); @@ -462,7 +477,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); // When the user downloads the torrent - let response = client.download_torrent(&test_torrent.info_hash()).await; + let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); @@ -504,7 +519,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - let response = client.delete_torrent(&test_torrent.info_hash()).await; + let response = client.delete_torrent(&test_torrent.info_hash_as_hex_string()).await; assert_eq!(response.status, 403); } @@ -532,7 +547,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.info_hash(), + &test_torrent.info_hash_as_hex_string(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -577,7 +592,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.info_hash(), + &test_torrent.info_hash_as_hex_string(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -624,7 +639,7 @@ mod for_authenticated_users { let admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); - let response = client.delete_torrent(&test_torrent.info_hash()).await; + let response = client.delete_torrent(&test_torrent.info_hash_as_hex_string()).await; let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); @@ -653,7 +668,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.info_hash(), + &test_torrent.info_hash_as_hex_string(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), From 6ff360050cabecf7311bb72757ca75bfacc0a65e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 13 Sep 2023 14:09:22 +0100 Subject: [PATCH 307/357] fix: [#282] downloaded torrent info-hash matches uploaded one NOTICE: They only matche when the index doesn't change it becuase it removs non-standard fields from the `info` key dictionary. They should match if the uploaded torrent does not have any non-stanrdard field in the `info` dictionary key. --- src/databases/mysql.rs | 4 +--- src/databases/sqlite.rs | 4 +--- src/models/torrent_file.rs | 6 ++---- src/services/torrent_file.rs | 4 ++-- tests/e2e/config.rs | 2 +- tests/e2e/web/api/v1/contexts/torrent/asserts.rs | 6 ++++-- 6 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 38edcdde..503d30b5 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -442,8 +442,6 @@ impl Database for Mysql { (root_hash.to_string(), true) }; - let private = torrent.info.private.unwrap_or(0); - // add torrent let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") .bind(uploader_id) @@ -453,7 +451,7 @@ impl Database for Mysql { .bind(torrent.info.name.to_string()) .bind(pieces) .bind(torrent.info.piece_length) - .bind(private) + .bind(torrent.info.private) .bind(root_hash) .bind(torrent.info.source.clone()) .execute(&mut tx) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 6cae2d4a..085b3960 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -432,8 +432,6 @@ impl Database for Sqlite { (root_hash.to_string(), true) }; - let private = torrent.info.private.unwrap_or(0); - // add torrent let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") .bind(uploader_id) @@ -443,7 +441,7 @@ impl Database for Sqlite { .bind(torrent.info.name.to_string()) .bind(pieces) .bind(torrent.info.piece_length) - .bind(private) + .bind(torrent.info.private) .bind(root_hash) .bind(torrent.info.source.clone()) .execute(&mut tx) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 97294252..c8849170 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -113,8 +113,6 @@ impl Torrent { /// This function will panic if the `torrent_info.pieces` is not a valid hex string. #[must_use] pub fn from_new_torrent_info_request(torrent_info: NewTorrentInfoRequest) -> Self { - let private = u8::try_from(torrent_info.private.unwrap_or(0)).ok(); - // the info part of the torrent file let mut info = TorrentInfo { name: torrent_info.name.to_string(), @@ -123,7 +121,7 @@ impl Torrent { md5sum: None, length: None, files: None, - private, + private: torrent_info.private, path: None, root_hash: None, source: None, @@ -296,7 +294,7 @@ pub struct DbTorrentInfo { pub pieces: String, pub piece_length: i64, #[serde(default)] - pub private: Option, + pub private: Option, pub root_hash: i64, } diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 6e474d99..dfa72dbd 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -12,7 +12,7 @@ pub struct NewTorrentInfoRequest { pub name: String, pub pieces: String, pub piece_length: i64, - pub private: Option, + pub private: Option, pub root_hash: i64, pub files: Vec, pub announce_urls: Vec>, @@ -77,7 +77,7 @@ mod tests { md5sum: None, length: Some(37), files: None, - private: Some(0), + private: None, path: None, root_hash: None, source: None, diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index 60595c79..86b857c7 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -19,7 +19,7 @@ pub const ENV_VAR_E2E_CONFIG_PATH: &str = "TORRUST_IDX_BACK_E2E_CONFIG_PATH"; // Default values -pub const ENV_VAR_E2E_DEFAULT_CONFIG_PATH: &str = "./config-idx-back.local.toml"; +pub const ENV_VAR_E2E_DEFAULT_CONFIG_PATH: &str = "./config-idx-back.sqlite.local.toml"; /// Initialize configuration from file or env var. /// diff --git a/tests/e2e/web/api/v1/contexts/torrent/asserts.rs b/tests/e2e/web/api/v1/contexts/torrent/asserts.rs index f8a31714..9f08306d 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/asserts.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/asserts.rs @@ -24,10 +24,12 @@ pub async fn expected_torrent(mut uploaded_torrent: Torrent, env: &TestEnv, down None => None, }; - uploaded_torrent.info.private = Some(0); uploaded_torrent.announce = Some(build_announce_url(&tracker_url, &tracker_key)); - uploaded_torrent.encoding = None; uploaded_torrent.announce_list = Some(build_announce_list(&tracker_url, &tracker_key)); + + // These fields are not persisted in the database yet. + // See https://github.com/torrust/torrust-index-backend/issues/284 + uploaded_torrent.encoding = None; uploaded_torrent.creation_date = None; uploaded_torrent.created_by = None; From d7cc040da4321b810781ca141bbe8ab1a0ce5e13 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 13 Sep 2023 16:12:16 +0100 Subject: [PATCH 308/357] refactor: [#282] tests --- tests/common/contexts/torrent/asserts.rs | 8 -- tests/common/responses.rs | 6 +- .../web/api/v1/contexts/torrent/asserts.rs | 19 +-- .../web/api/v1/contexts/torrent/contract.rs | 110 +++++++++++++----- 4 files changed, 93 insertions(+), 50 deletions(-) diff --git a/tests/common/contexts/torrent/asserts.rs b/tests/common/contexts/torrent/asserts.rs index d4b2be4a..6e76a4be 100644 --- a/tests/common/contexts/torrent/asserts.rs +++ b/tests/common/contexts/torrent/asserts.rs @@ -35,11 +35,3 @@ pub fn assert_expected_torrent_details(torrent: &TorrentDetails, expected_torren "torrent `magnet_link` mismatch" ); } - -/// Full assert for test debugging purposes. -pub fn _assert_eq_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) { - assert_eq!( - torrent, expected_torrent, - "\nUnexpected torrent details:\n{torrent:#?} torrent details should match the previously indexed torrent:\n{expected_torrent:#?}" - ); -} diff --git a/tests/common/responses.rs b/tests/common/responses.rs index 0be8bbd6..2545a9e8 100644 --- a/tests/common/responses.rs +++ b/tests/common/responses.rs @@ -53,11 +53,11 @@ impl BinaryResponse { bytes: response.bytes().await.unwrap().to_vec(), } } - pub fn is_bittorrent_and_ok(&self) -> bool { - self.is_ok() && self.is_bittorrent() + pub fn is_a_bit_torrent_file(&self) -> bool { + self.is_ok() && self.is_bittorrent_content_type() } - pub fn is_bittorrent(&self) -> bool { + pub fn is_bittorrent_content_type(&self) -> bool { if let Some(content_type) = &self.content_type { return content_type == "application/x-bittorrent"; } diff --git a/tests/e2e/web/api/v1/contexts/torrent/asserts.rs b/tests/e2e/web/api/v1/contexts/torrent/asserts.rs index 9f08306d..328a8513 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/asserts.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/asserts.rs @@ -8,15 +8,14 @@ use crate::common::contexts::user::responses::LoggedInUserData; use crate::e2e::environment::TestEnv; /// The backend does not generate exactly the same torrent that was uploaded. -/// So we need to update the expected torrent to match the one generated by -/// the backend. -pub async fn expected_torrent(mut uploaded_torrent: Torrent, env: &TestEnv, downloader: &Option) -> Torrent { - // code-review: The backend does not generate exactly the same torrent - // that was uploaded and created by the `imdl` command-line tool. - // So we need to update the expected torrent to match the one generated - // by the backend. For some of them it makes sense (`announce` and `announce_list`), - // for others it does not. - +/// +/// The backend stores the canonical version of the uploaded torrent. So we need +/// to update the expected torrent to match the one generated by the backend. +pub async fn canonical_torrent_for( + mut uploaded_torrent: Torrent, + env: &TestEnv, + downloader: &Option, +) -> Torrent { let tracker_url = env.server_settings().unwrap().tracker.url.to_string(); let tracker_key = match downloader { @@ -29,6 +28,8 @@ pub async fn expected_torrent(mut uploaded_torrent: Torrent, env: &TestEnv, down // These fields are not persisted in the database yet. // See https://github.com/torrust/torrust-index-backend/issues/284 + // They are ignore when the user uploads the torrent. So the stored + // canonical torrent does not contain them. uploaded_torrent.encoding = None; uploaded_torrent.creation_date = None; uploaded_torrent.created_by = None; diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 067d3496..a30eec24 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -16,7 +16,6 @@ Get torrent info: mod for_guests { - use torrust_index_backend::utils::parse_torrent::{calculate_info_hash, decode_torrent}; use torrust_index_backend::web::api; use crate::common::client::Client; @@ -28,7 +27,6 @@ mod for_guests { }; use crate::common::http::{Query, QueryParam}; use crate::e2e::environment::TestEnv; - use crate::e2e::web::api::v1::contexts::torrent::asserts::expected_torrent; use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; @@ -215,44 +213,96 @@ mod for_guests { assert!(response.is_json_and_ok()); } - #[tokio::test] - async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; + mod it_should_allow_guests_download_a_torrent_file_searching_by_info_hash { - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; + use torrust_index_backend::utils::parse_torrent::{calculate_info_hash, decode_torrent}; + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::torrent::asserts::canonical_torrent_for; + use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; + + #[tokio::test] + async fn returning_a_bittorrent_binary_ok_response() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + let uploader = new_logged_in_user(&env).await; + + // Upload + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + + // Download + let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + + assert!(response.is_a_bit_torrent_file()); } - let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); - let uploader = new_logged_in_user(&env).await; + #[tokio::test] + async fn the_downloaded_torrent_should_keep_the_same_info_hash_if_the_torrent_does_not_have_non_standard_fields_in_the_info_dict( + ) { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; - // Upload a new torrent - let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } - let uploaded_torrent = - decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + let uploader = new_logged_in_user(&env).await; - // Download the torrent - let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + // Upload + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let downloaded_torrent_info_hash = calculate_info_hash(&response.bytes); - let downloaded_torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + // Download + let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; - // Response should be a torrent file and status OK - assert!(response.is_bittorrent_and_ok()); + let downloaded_torrent_info_hash = calculate_info_hash(&response.bytes); - // Info-hash should be the same - assert_eq!( - downloaded_torrent_info_hash.to_hex_string(), - test_torrent.info_hash_as_hex_string(), - "downloaded torrent info-hash does not match uploaded torrent info-hash" - ); + assert_eq!( + downloaded_torrent_info_hash.to_hex_string(), + test_torrent.info_hash_as_hex_string(), + "downloaded torrent info-hash does not match uploaded torrent info-hash" + ); + } - // Torrent should be the same - let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; - assert_eq!(downloaded_torrent, expected_torrent); + #[tokio::test] + async fn the_downloaded_torrent_should_be_the_canonical_version_of_the_uploaded_one() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + let uploader = new_logged_in_user(&env).await; + + // Upload + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + + let uploaded_torrent = + decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); + + // Download + let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + + let downloaded_torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + + let expected_downloaded_torrent = canonical_torrent_for(uploaded_torrent, &env, &None).await; + + assert_eq!(downloaded_torrent, expected_downloaded_torrent); + } } #[tokio::test] From 5e2ae685b54d160b08257ca7cb2e0854c93e280c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 13 Sep 2023 16:37:07 +0100 Subject: [PATCH 309/357] ci: enable rust cache for integration test --- .github/workflows/testing.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 6c1fc1e3..19dcc84d 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -125,6 +125,14 @@ jobs: name: Checkout Repository uses: actions/checkout@v3 + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + + - id: cache + name: Enable Job Cache + uses: Swatinem/rust-cache@v2 + - id: test name: Run Integration Tests run: ./docker/bin/e2e/run-e2e-tests.sh From 83d31f2f9fdeffe99989901a5f4223c6b2f557e5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 12 Sep 2023 17:01:13 +0100 Subject: [PATCH 310/357] feat: [#278] redirect to URL with canonical infohash For endpoints using a GET param with an infohash: - GET /v1/torrent/CANONICAL_INFO_HASH - GET /v1/torrent/download/CANONICAL_INFO_HASH Suppose the URL contains an info-hash, which is not canonical but belongs to a canonical info-hash group. In that case, the response will be a redirection (307) to the same URL but using the canonical info-hash. --- src/app.rs | 8 +-- src/common.rs | 6 +- src/databases/database.rs | 31 +++++++--- src/databases/mysql.rs | 33 ++++++++-- src/databases/sqlite.rs | 33 ++++++++-- src/services/torrent.rs | 67 +++++++++++++-------- src/web/api/v1/contexts/torrent/handlers.rs | 67 +++++++++++++++------ 7 files changed, 176 insertions(+), 69 deletions(-) diff --git a/src/app.rs b/src/app.rs index 614dda02..353ce274 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,7 @@ use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebTok use crate::services::category::{self, DbCategoryRepository}; use crate::services::tag::{self, DbTagRepository}; use crate::services::torrent::{ - DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoHashRepository, DbTorrentInfoRepository, + DbCanonicalInfoHashGroupRepository, DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, }; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; @@ -68,7 +68,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let user_authentication_repository = Arc::new(DbUserAuthenticationRepository::new(database.clone())); let user_profile_repository = Arc::new(DbUserProfileRepository::new(database.clone())); let torrent_repository = Arc::new(DbTorrentRepository::new(database.clone())); - let torrent_info_hash_repository = Arc::new(DbTorrentInfoHashRepository::new(database.clone())); + let canonical_info_hash_group_repository = Arc::new(DbCanonicalInfoHashGroupRepository::new(database.clone())); let torrent_info_repository = Arc::new(DbTorrentInfoRepository::new(database.clone())); let torrent_file_repository = Arc::new(DbTorrentFileRepository::new(database.clone())); let torrent_announce_url_repository = Arc::new(DbTorrentAnnounceUrlRepository::new(database.clone())); @@ -93,7 +93,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running user_repository.clone(), category_repository.clone(), torrent_repository.clone(), - torrent_info_hash_repository.clone(), + canonical_info_hash_group_repository.clone(), torrent_info_repository.clone(), torrent_file_repository.clone(), torrent_announce_url_repository.clone(), @@ -137,7 +137,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running user_authentication_repository, user_profile_repository, torrent_repository, - torrent_info_hash_repository, + canonical_info_hash_group_repository, torrent_info_repository, torrent_file_repository, torrent_announce_url_repository, diff --git a/src/common.rs b/src/common.rs index 09255678..bf16889a 100644 --- a/src/common.rs +++ b/src/common.rs @@ -7,7 +7,7 @@ use crate::services::authentication::{DbUserAuthenticationRepository, JsonWebTok use crate::services::category::{self, DbCategoryRepository}; use crate::services::tag::{self, DbTagRepository}; use crate::services::torrent::{ - DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoHashRepository, DbTorrentInfoRepository, + DbCanonicalInfoHashGroupRepository, DbTorrentAnnounceUrlRepository, DbTorrentFileRepository, DbTorrentInfoRepository, DbTorrentListingGenerator, DbTorrentRepository, DbTorrentTagRepository, }; use crate::services::user::{self, DbBannedUserList, DbUserProfileRepository, DbUserRepository}; @@ -34,7 +34,7 @@ pub struct AppData { pub user_authentication_repository: Arc, pub user_profile_repository: Arc, pub torrent_repository: Arc, - pub torrent_info_hash_repository: Arc, + pub torrent_info_hash_repository: Arc, pub torrent_info_repository: Arc, pub torrent_file_repository: Arc, pub torrent_announce_url_repository: Arc, @@ -70,7 +70,7 @@ impl AppData { user_authentication_repository: Arc, user_profile_repository: Arc, torrent_repository: Arc, - torrent_info_hash_repository: Arc, + torrent_info_hash_repository: Arc, torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, diff --git a/src/databases/database.rs b/src/databases/database.rs index 84b506a5..45fbdb3f 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -12,7 +12,7 @@ use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; -use crate::services::torrent::OriginalInfoHashes; +use crate::services::torrent::CanonicalInfoHashGroup; /// Database tables to be truncated when upgrading from v1.0.0 to v2.0.0. /// They must be in the correct order to avoid foreign key errors. @@ -231,19 +231,30 @@ pub trait Database: Sync + Send { )) } - /// Returns the list of all infohashes producing the same canonical infohash. - /// - /// When you upload a torrent the infohash migth change because the Index - /// remove the non-standard fields in the `info` dictionary. That makes the - /// infohash change. The canonical infohash is the resulting infohash. - /// This function returns the original infohashes of a canonical infohash. + /// It returns the list of all infohashes producing the same canonical + /// infohash. /// /// If the original infohash was unknown, it returns the canonical infohash. /// - /// The relationship is 1 canonical infohash -> N original infohashes. - async fn get_torrent_canonical_info_hash_group(&self, canonical: &InfoHash) -> Result; + /// # Errors + /// + /// Returns an error is there was a problem with the database. + async fn get_torrent_canonical_info_hash_group(&self, canonical: &InfoHash) -> Result; - async fn insert_torrent_info_hash(&self, original: &InfoHash, canonical: &InfoHash) -> Result<(), Error>; + /// It returns the [`CanonicalInfoHashGroup`] the info-hash belongs to, if + /// the info-hash belongs to a group. Otherwise, returns `None`. + /// + /// # Errors + /// + /// Returns an error is there was a problem with the database. + async fn find_canonical_info_hash_for(&self, info_hash: &InfoHash) -> Result, Error>; + + /// It adds a new info-hash to the canonical info-hash group. + /// + /// # Errors + /// + /// Returns an error is there was a problem with the database. + async fn add_info_hash_to_canonical_info_hash_group(&self, original: &InfoHash, canonical: &InfoHash) -> Result<(), Error>; /// Get torrent's info as `DbTorrentInfo` from `torrent_id`. async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 503d30b5..b3d18ac3 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -17,7 +17,7 @@ use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrent use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; -use crate::services::torrent::{DbTorrentInfoHash, OriginalInfoHashes}; +use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash}; use crate::utils::clock; use crate::utils::hex::from_bytes; @@ -590,7 +590,10 @@ impl Database for Mysql { } } - async fn get_torrent_canonical_info_hash_group(&self, canonical: &InfoHash) -> Result { + async fn get_torrent_canonical_info_hash_group( + &self, + canonical: &InfoHash, + ) -> Result { let db_info_hashes = query_as::<_, DbTorrentInfoHash>( "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE canonical_info_hash = ?", ) @@ -607,13 +610,35 @@ impl Database for Mysql { }) .collect(); - Ok(OriginalInfoHashes { + Ok(CanonicalInfoHashGroup { canonical_info_hash: *canonical, original_info_hashes: info_hashes, }) } - async fn insert_torrent_info_hash(&self, info_hash: &InfoHash, canonical: &InfoHash) -> Result<(), database::Error> { + async fn find_canonical_info_hash_for(&self, info_hash: &InfoHash) -> Result, database::Error> { + let maybe_db_torrent_info_hash = query_as::<_, DbTorrentInfoHash>( + "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE info_hash = ?", + ) + .bind(info_hash.to_hex_string()) + .fetch_optional(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + + match maybe_db_torrent_info_hash { + Some(db_torrent_info_hash) => Ok(Some( + InfoHash::from_str(&db_torrent_info_hash.canonical_info_hash) + .unwrap_or_else(|_| panic!("Invalid info-hash in database: {}", db_torrent_info_hash.canonical_info_hash)), + )), + None => Ok(None), + } + } + + async fn add_info_hash_to_canonical_info_hash_group( + &self, + info_hash: &InfoHash, + canonical: &InfoHash, + ) -> Result<(), database::Error> { query("INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) VALUES (?, ?, ?)") .bind(info_hash.to_hex_string()) .bind(canonical.to_hex_string()) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 085b3960..6b2ebbd8 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -17,7 +17,7 @@ use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrent use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; -use crate::services::torrent::{DbTorrentInfoHash, OriginalInfoHashes}; +use crate::services::torrent::{CanonicalInfoHashGroup, DbTorrentInfoHash}; use crate::utils::clock; use crate::utils::hex::from_bytes; @@ -580,7 +580,10 @@ impl Database for Sqlite { } } - async fn get_torrent_canonical_info_hash_group(&self, canonical: &InfoHash) -> Result { + async fn get_torrent_canonical_info_hash_group( + &self, + canonical: &InfoHash, + ) -> Result { let db_info_hashes = query_as::<_, DbTorrentInfoHash>( "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE canonical_info_hash = ?", ) @@ -597,13 +600,35 @@ impl Database for Sqlite { }) .collect(); - Ok(OriginalInfoHashes { + Ok(CanonicalInfoHashGroup { canonical_info_hash: *canonical, original_info_hashes: info_hashes, }) } - async fn insert_torrent_info_hash(&self, original: &InfoHash, canonical: &InfoHash) -> Result<(), database::Error> { + async fn find_canonical_info_hash_for(&self, info_hash: &InfoHash) -> Result, database::Error> { + let maybe_db_torrent_info_hash = query_as::<_, DbTorrentInfoHash>( + "SELECT info_hash, canonical_info_hash, original_is_known FROM torrust_torrent_info_hashes WHERE info_hash = ?", + ) + .bind(info_hash.to_hex_string()) + .fetch_optional(&self.pool) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string()))?; + + match maybe_db_torrent_info_hash { + Some(db_torrent_info_hash) => Ok(Some( + InfoHash::from_str(&db_torrent_info_hash.canonical_info_hash) + .unwrap_or_else(|_| panic!("Invalid info-hash in database: {}", db_torrent_info_hash.canonical_info_hash)), + )), + None => Ok(None), + } + } + + async fn add_info_hash_to_canonical_info_hash_group( + &self, + original: &InfoHash, + canonical: &InfoHash, + ) -> Result<(), database::Error> { query("INSERT INTO torrust_torrent_info_hashes (info_hash, canonical_info_hash, original_is_known) VALUES (?, ?, ?)") .bind(original.to_hex_string()) .bind(canonical.to_hex_string()) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 635e6016..7dce0db1 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -29,7 +29,7 @@ pub struct Index { user_repository: Arc, category_repository: Arc, torrent_repository: Arc, - torrent_info_hash_repository: Arc, + torrent_info_hash_repository: Arc, torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, @@ -85,7 +85,7 @@ impl Index { user_repository: Arc, category_repository: Arc, torrent_repository: Arc, - torrent_info_hash_repository: Arc, + torrent_info_hash_repository: Arc, torrent_info_repository: Arc, torrent_file_repository: Arc, torrent_announce_url_repository: Arc, @@ -189,7 +189,7 @@ impl Index { // Add the new associated original infohash to the canonical one. self.torrent_info_hash_repository - .add(&original_info_hash, &canonical_info_hash) + .add_info_hash_to_canonical_info_hash_group(&original_info_hash, &canonical_info_hash) .await?; return Err(ServiceError::CanonicalInfoHashAlreadyExists); } @@ -555,16 +555,24 @@ pub struct DbTorrentInfoHash { pub original_is_known: bool, } -pub struct DbTorrentInfoHashRepository { - database: Arc>, -} - -pub struct OriginalInfoHashes { +/// All the infohashes associated to a canonical one. +/// +/// When you upload a torrent the info-hash migth change because the Index +/// remove the non-standard fields in the `info` dictionary. That makes the +/// infohash change. The canonical infohash is the resulting infohash. +/// This function returns the original infohashes of a canonical infohash. +/// +/// The relationship is 1 canonical infohash -> N original infohashes. +pub struct CanonicalInfoHashGroup { pub canonical_info_hash: InfoHash, + /// The list of original infohashes associated to the canonical one. pub original_info_hashes: Vec, } +pub struct DbCanonicalInfoHashGroupRepository { + database: Arc>, +} -impl OriginalInfoHashes { +impl CanonicalInfoHashGroup { #[must_use] pub fn is_empty(&self) -> bool { self.original_info_hashes.is_empty() @@ -576,7 +584,7 @@ impl OriginalInfoHashes { } } -impl DbTorrentInfoHashRepository { +impl DbCanonicalInfoHashGroupRepository { #[must_use] pub fn new(database: Arc>) -> Self { Self { database } @@ -587,31 +595,42 @@ impl DbTorrentInfoHashRepository { /// # Errors /// /// This function will return an error there is a database error. - pub async fn get_canonical_info_hash_group(&self, info_hash: &InfoHash) -> Result { + /// + /// # Errors + /// + /// Returns an error is there was a problem with the database. + pub async fn get_canonical_info_hash_group(&self, info_hash: &InfoHash) -> Result { self.database.get_torrent_canonical_info_hash_group(info_hash).await } - /// Inserts a new infohash for the torrent. Torrents can be associated to - /// different infohashes because the Index might change the original infohash. - /// The index track the final infohash used (canonical) and all the original - /// ones. + /// It returns the list of all infohashes producing the same canonical + /// infohash. + /// + /// If the original infohash was unknown, it returns the canonical infohash. /// /// # Errors /// - /// This function will return an error there is a database error. - pub async fn add(&self, original_info_hash: &InfoHash, canonical_info_hash: &InfoHash) -> Result<(), Error> { - self.database - .insert_torrent_info_hash(original_info_hash, canonical_info_hash) - .await + /// Returns an error is there was a problem with the database. + pub async fn find_canonical_info_hash_for(&self, info_hash: &InfoHash) -> Result, Error> { + self.database.find_canonical_info_hash_for(info_hash).await } - /// Deletes the entire torrent in the database. + /// It returns the list of all infohashes producing the same canonical + /// infohash. + /// + /// If the original infohash was unknown, it returns the canonical infohash. /// /// # Errors /// - /// This function will return an error there is a database error. - pub async fn delete(&self, torrent_id: &TorrentId) -> Result<(), Error> { - self.database.delete_torrent(*torrent_id).await + /// Returns an error is there was a problem with the database. + pub async fn add_info_hash_to_canonical_info_hash_group( + &self, + original_info_hash: &InfoHash, + canonical_info_hash: &InfoHash, + ) -> Result<(), Error> { + self.database + .add_info_hash_to_canonical_info_hash_group(original_info_hash, canonical_info_hash) + .await } } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 6f9c158a..72a14655 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use std::sync::Arc; use axum::extract::{self, Multipart, Path, Query, State}; -use axum::response::{IntoResponse, Response}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::Json; use serde::Deserialize; use uuid::Uuid; @@ -78,21 +78,25 @@ pub async fn download_torrent_handler( return errors::Request::InvalidInfoHashParam.into_response(); }; - let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { - Ok(opt_user_id) => opt_user_id, - Err(error) => return error.into_response(), - }; + if let Some(redirect_response) = guard_that_canonical_info_hash_is_used_or_redirect(&app_data, &info_hash).await { + redirect_response + } else { + let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { + Ok(opt_user_id) => opt_user_id, + Err(error) => return error.into_response(), + }; - let torrent = match app_data.torrent_service.get_torrent(&info_hash, opt_user_id).await { - Ok(torrent) => torrent, - Err(error) => return error.into_response(), - }; + let torrent = match app_data.torrent_service.get_torrent(&info_hash, opt_user_id).await { + Ok(torrent) => torrent, + Err(error) => return error.into_response(), + }; - let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { - return ServiceError::InternalServerError.into_response(); - }; + let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else { + return ServiceError::InternalServerError.into_response(); + }; - torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash_hex()) + torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash_hex()) + } } /// It returns a list of torrents matching the search criteria. @@ -128,14 +132,37 @@ pub async fn get_torrent_info_handler( return errors::Request::InvalidInfoHashParam.into_response(); }; - let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { - Ok(opt_user_id) => opt_user_id, - Err(error) => return error.into_response(), - }; + if let Some(redirect_response) = guard_that_canonical_info_hash_is_used_or_redirect(&app_data, &info_hash).await { + redirect_response + } else { + let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { + Ok(opt_user_id) => opt_user_id, + Err(error) => return error.into_response(), + }; + + match app_data.torrent_service.get_torrent_info(&info_hash, opt_user_id).await { + Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(), + Err(error) => error.into_response(), + } + } +} - match app_data.torrent_service.get_torrent_info(&info_hash, opt_user_id).await { - Ok(torrent_response) => Json(OkResponseData { data: torrent_response }).into_response(), - Err(error) => error.into_response(), +async fn guard_that_canonical_info_hash_is_used_or_redirect(app_data: &Arc, info_hash: &InfoHash) -> Option { + match app_data + .torrent_info_hash_repository + .find_canonical_info_hash_for(info_hash) + .await + { + Ok(Some(canonical_info_hash)) => { + if canonical_info_hash != *info_hash { + return Some( + Redirect::temporary(&format!("/v1/torrent/{}", canonical_info_hash.to_hex_string())).into_response(), + ); + } + None + } + Ok(None) => None, + Err(error) => Some(error.into_response()), } } From 05b67c7906a3c3cc5c99a124370fd1900700fcde Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 12 Sep 2023 18:32:56 +0100 Subject: [PATCH 311/357] test: [#278] use non canonical info-hash to get torrent details --- tests/common/contexts/torrent/fixtures.rs | 2 +- .../web/api/v1/contexts/torrent/contract.rs | 75 +++++++++++++++---- .../e2e/web/api/v1/contexts/torrent/steps.rs | 29 +++++++ 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index 5d82ab2b..309be5bd 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -137,7 +137,7 @@ impl TestTorrent { } } - pub fn info_hash_as_hex_string(&self) -> InfoHash { + pub fn file_info_hash(&self) -> InfoHash { self.file_info.info_hash.clone() } } diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index a30eec24..1e7145f5 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -17,17 +17,19 @@ Get torrent info: mod for_guests { use torrust_index_backend::web::api; + use uuid::Uuid; use crate::common::client::Client; use crate::common::contexts::category::fixtures::software_predefined_category_id; use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::common::contexts::torrent::fixtures::TestTorrent; use crate::common::contexts::torrent::requests::InfoHash; use crate::common::contexts::torrent::responses::{ Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, }; use crate::common::http::{Query, QueryParam}; use crate::e2e::environment::TestEnv; - use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; + use crate::e2e::web::api::v1::contexts::torrent::steps::{upload_random_torrent_to_index, upload_test_torrent}; use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; #[tokio::test] @@ -164,7 +166,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.get_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.get_torrent(&test_torrent.file_info_hash()).await; let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); @@ -213,7 +215,50 @@ mod for_guests { assert!(response.is_json_and_ok()); } - mod it_should_allow_guests_download_a_torrent_file_searching_by_info_hash { + #[tokio::test] + async fn it_should_allow_guests_to_find_torrent_details_using_a_non_canonical_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + // Sample data needed to build two torrents with the same canonical info-hash. + // Those torrents belong to the same Canonical Infohash Group. + let id = Uuid::new_v4(); + let title = format!("title-{id}"); + let file_contents = "data".to_string(); + + let mut first_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 01"); + first_torrent.index_info.title = title.clone(); + + let first_torrent_canonical_info_hash = upload_test_torrent(&client, &first_torrent) + .await + .expect("first torrent should be uploaded"); + + let mut second_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 02"); + second_torrent.index_info.title = format!("{title}-clone"); + + let _result = upload_test_torrent(&client, &second_torrent).await; + + // Get torrent details using the non-canonical info-hash (second torrent info-hash) + let response = client.get_torrent(&second_torrent.file_info_hash()).await; + let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); + + // The returned torrent info should be the same as the first torrent + assert_eq!(response.status, 200); + assert_eq!( + torrent_details_response.data.info_hash, + first_torrent_canonical_info_hash.to_hex_string() + ); + } + + mod it_should_allow_guests_to_download_a_torrent_file_searching_by_info_hash { use torrust_index_backend::utils::parse_torrent::{calculate_info_hash, decode_torrent}; use torrust_index_backend::web::api; @@ -241,7 +286,7 @@ mod for_guests { let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; // Download - let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.download_torrent(&test_torrent.file_info_hash()).await; assert!(response.is_a_bit_torrent_file()); } @@ -264,13 +309,13 @@ mod for_guests { let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; // Download - let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.download_torrent(&test_torrent.file_info_hash()).await; let downloaded_torrent_info_hash = calculate_info_hash(&response.bytes); assert_eq!( downloaded_torrent_info_hash.to_hex_string(), - test_torrent.info_hash_as_hex_string(), + test_torrent.file_info_hash(), "downloaded torrent info-hash does not match uploaded torrent info-hash" ); } @@ -295,7 +340,7 @@ mod for_guests { decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); // Download - let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.download_torrent(&test_torrent.file_info_hash()).await; let downloaded_torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); @@ -348,7 +393,7 @@ mod for_guests { let uploader = new_logged_in_user(&env).await; let (test_torrent, _uploaded_torrent) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.delete_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.delete_torrent(&test_torrent.file_info_hash()).await; assert_eq!(response.status, 401); } @@ -384,7 +429,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); let test_torrent = random_torrent(); - let info_hash = test_torrent.info_hash_as_hex_string().clone(); + let info_hash = test_torrent.file_info_hash().clone(); let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); @@ -527,7 +572,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); // When the user downloads the torrent - let response = client.download_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.download_torrent(&test_torrent.file_info_hash()).await; let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); @@ -569,7 +614,7 @@ mod for_authenticated_users { let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - let response = client.delete_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.delete_torrent(&test_torrent.file_info_hash()).await; assert_eq!(response.status, 403); } @@ -597,7 +642,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.info_hash_as_hex_string(), + &test_torrent.file_info_hash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -642,7 +687,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.info_hash_as_hex_string(), + &test_torrent.file_info_hash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), @@ -689,7 +734,7 @@ mod for_authenticated_users { let admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &admin.token); - let response = client.delete_torrent(&test_torrent.info_hash_as_hex_string()).await; + let response = client.delete_torrent(&test_torrent.file_info_hash()).await; let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); @@ -718,7 +763,7 @@ mod for_authenticated_users { let response = client .update_torrent( - &test_torrent.info_hash_as_hex_string(), + &test_torrent.file_info_hash(), UpdateTorrentFrom { title: Some(new_title.clone()), description: Some(new_description.clone()), diff --git a/tests/e2e/web/api/v1/contexts/torrent/steps.rs b/tests/e2e/web/api/v1/contexts/torrent/steps.rs index 57a9f0ba..c516b09c 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/steps.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/steps.rs @@ -1,3 +1,8 @@ +use std::str::FromStr; + +use torrust_index_backend::models::info_hash::InfoHash; +use torrust_index_backend::web::api::v1::responses::ErrorResponseData; + use crate::common::client::Client; use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent, TorrentIndexInfo, TorrentListedInIndex}; use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; @@ -28,3 +33,27 @@ pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexI TorrentListedInIndex::from(torrent.clone(), res.unwrap().data.torrent_id) } + +/// Upload a torrent to the index. +/// +/// # Errors +/// +/// Returns an `ErrorResponseData` if the response is not a 200. +pub async fn upload_test_torrent(client: &Client, test_torrent: &TestTorrent) -> Result { + let form: UploadTorrentMultipartForm = test_torrent.clone().index_info.into(); + let response = client.upload_torrent(form.into()).await; + + if response.status != 200 { + let error: ErrorResponseData = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a ErrorResponseData", response.body)); + return Err(error); + } + + let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + let canonical_info_hash_hex = uploaded_torrent_response.data.info_hash.to_lowercase(); + + let canonical_info_hash = InfoHash::from_str(&canonical_info_hash_hex) + .unwrap_or_else(|_| panic!("Invalid info-hash in database: {canonical_info_hash_hex}")); + + Ok(canonical_info_hash) +} From 2a73f100254f7a90620e1581ceb9a2674cc04fb2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 13 Sep 2023 08:14:28 +0100 Subject: [PATCH 312/357] test: [#278] allow using non canonical info-hash to download a torrent --- src/web/api/v1/contexts/torrent/handlers.rs | 46 +++++++++++++++++-- tests/common/client.rs | 4 ++ .../web/api/v1/contexts/torrent/contract.rs | 46 +++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 72a14655..6c0754f0 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use axum::extract::{self, Multipart, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use axum::Json; +use log::debug; use serde::Deserialize; use uuid::Uuid; @@ -23,6 +24,7 @@ use crate::utils::parse_torrent; use crate::web::api::v1::auth::get_optional_logged_in_user; use crate::web::api::v1::extractors::bearer_token::Extract; use crate::web::api::v1::responses::OkResponseData; +use crate::web::api::v1::routes::API_VERSION_URL_PREFIX; /// Upload a new torrent file to the Index /// @@ -78,7 +80,10 @@ pub async fn download_torrent_handler( return errors::Request::InvalidInfoHashParam.into_response(); }; - if let Some(redirect_response) = guard_that_canonical_info_hash_is_used_or_redirect(&app_data, &info_hash).await { + debug!("Downloading torrent: {:?}", info_hash.to_hex_string()); + + if let Some(redirect_response) = redirect_to_download_url_using_canonical_info_hash_if_needed(&app_data, &info_hash).await { + debug!("Redirecting to URL with canonical info-hash"); redirect_response } else { let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { @@ -99,6 +104,32 @@ pub async fn download_torrent_handler( } } +async fn redirect_to_download_url_using_canonical_info_hash_if_needed( + app_data: &Arc, + info_hash: &InfoHash, +) -> Option { + match app_data + .torrent_info_hash_repository + .find_canonical_info_hash_for(info_hash) + .await + { + Ok(Some(canonical_info_hash)) => { + if canonical_info_hash != *info_hash { + return Some( + Redirect::temporary(&format!( + "/{API_VERSION_URL_PREFIX}/torrent/download/{}", + canonical_info_hash.to_hex_string() + )) + .into_response(), + ); + } + None + } + Ok(None) => None, + Err(error) => Some(error.into_response()), + } +} + /// It returns a list of torrents matching the search criteria. /// /// Eg: `/torrents?categories=music,other,movie&search=bunny&sort=size_DESC` @@ -132,7 +163,7 @@ pub async fn get_torrent_info_handler( return errors::Request::InvalidInfoHashParam.into_response(); }; - if let Some(redirect_response) = guard_that_canonical_info_hash_is_used_or_redirect(&app_data, &info_hash).await { + if let Some(redirect_response) = redirect_to_details_url_using_canonical_info_hash_if_needed(&app_data, &info_hash).await { redirect_response } else { let opt_user_id = match get_optional_logged_in_user(maybe_bearer_token, app_data.clone()).await { @@ -147,7 +178,10 @@ pub async fn get_torrent_info_handler( } } -async fn guard_that_canonical_info_hash_is_used_or_redirect(app_data: &Arc, info_hash: &InfoHash) -> Option { +async fn redirect_to_details_url_using_canonical_info_hash_if_needed( + app_data: &Arc, + info_hash: &InfoHash, +) -> Option { match app_data .torrent_info_hash_repository .find_canonical_info_hash_for(info_hash) @@ -156,7 +190,11 @@ async fn guard_that_canonical_info_hash_is_used_or_redirect(app_data: &Arc { if canonical_info_hash != *info_hash { return Some( - Redirect::temporary(&format!("/v1/torrent/{}", canonical_info_hash.to_hex_string())).into_response(), + Redirect::temporary(&format!( + "/{API_VERSION_URL_PREFIX}/torrent/{}", + canonical_info_hash.to_hex_string() + )) + .into_response(), ); } None diff --git a/tests/common/client.rs b/tests/common/client.rs index 7f2528d3..05ffa17b 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -208,6 +208,10 @@ impl Http { .await .unwrap(), }; + // todo: If the response is a JSON, it returns the JSON body in a byte + // array. This is not the expected behavior. + // - Rename BinaryResponse to BinaryTorrentResponse + // - Return an error if the response is not a bittorrent file BinaryResponse::from(response).await } diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 1e7145f5..bc93cfcc 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -16,6 +16,7 @@ Get torrent info: mod for_guests { + use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; use uuid::Uuid; @@ -234,6 +235,7 @@ mod for_guests { let title = format!("title-{id}"); let file_contents = "data".to_string(); + // Upload the first torrent let mut first_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 01"); first_torrent.index_info.title = title.clone(); @@ -241,6 +243,7 @@ mod for_guests { .await .expect("first torrent should be uploaded"); + // Upload the second torrent with the same canonical info-hash let mut second_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 02"); second_torrent.index_info.title = format!("{title}-clone"); @@ -350,6 +353,49 @@ mod for_guests { } } + #[tokio::test] + async fn it_should_allow_guests_to_download_a_torrent_using_a_non_canonical_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + // Sample data needed to build two torrents with the same canonical info-hash. + // Those torrents belong to the same Canonical Infohash Group. + let id = Uuid::new_v4(); + let title = format!("title-{id}"); + let file_contents = "data".to_string(); + + // Upload the first torrent + let mut first_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 01"); + first_torrent.index_info.title = title.clone(); + + let first_torrent_canonical_info_hash = upload_test_torrent(&client, &first_torrent) + .await + .expect("first torrent should be uploaded"); + + // Upload the second torrent with the same canonical info-hash + let mut second_torrent = TestTorrent::with_custom_info_dict_field(id, &file_contents, "custom 02"); + second_torrent.index_info.title = format!("{title}-clone"); + + let _result = upload_test_torrent(&client, &second_torrent).await; + + // Download the torrent using the non-canonical info-hash (second torrent info-hash) + let response = client.download_torrent(&second_torrent.file_info_hash()).await; + + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + + // The returned torrent info-hash should be the same as the first torrent + assert_eq!(response.status, 200); + assert_eq!(torrent.info_hash_hex(), first_torrent_canonical_info_hash.to_hex_string()); + } + #[tokio::test] async fn it_should_return_a_not_found_response_trying_to_get_the_torrent_info_for_a_non_existing_torrent() { let mut env = TestEnv::new(); From 022692eff84a2a730d534a3b75cfae4c9ccac1db Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 14 Sep 2023 13:45:09 +0100 Subject: [PATCH 313/357] fix: [#273] add tags to torrent details in test responses --- tests/common/contexts/category/fixtures.rs | 2 +- tests/common/contexts/torrent/asserts.rs | 65 ++++++++++--------- tests/common/contexts/torrent/fixtures.rs | 60 +++++++++-------- tests/common/contexts/torrent/responses.rs | 8 +++ .../web/api/v1/contexts/torrent/contract.rs | 1 + 5 files changed, 74 insertions(+), 62 deletions(-) diff --git a/tests/common/contexts/category/fixtures.rs b/tests/common/contexts/category/fixtures.rs index 18e62288..05ec2c26 100644 --- a/tests/common/contexts/category/fixtures.rs +++ b/tests/common/contexts/category/fixtures.rs @@ -1,6 +1,6 @@ use rand::Rng; -pub fn software_predefined_category_name() -> String { +pub fn software_category_name() -> String { "software".to_string() } diff --git a/tests/common/contexts/torrent/asserts.rs b/tests/common/contexts/torrent/asserts.rs index 6e76a4be..d0f1a8cf 100644 --- a/tests/common/contexts/torrent/asserts.rs +++ b/tests/common/contexts/torrent/asserts.rs @@ -1,37 +1,42 @@ use super::responses::TorrentDetails; +type Check = (&'static str, bool); + /// Assert that the torrent details match the expected ones. +/// /// It ignores some fields that are not relevant for the E2E tests /// or hard to assert due to the concurrent nature of the tests. pub fn assert_expected_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) { - assert_eq!( - torrent.torrent_id, expected_torrent.torrent_id, - "torrent `file_size` mismatch" - ); - assert_eq!(torrent.uploader, expected_torrent.uploader, "torrent `uploader` mismatch"); - assert_eq!(torrent.info_hash, expected_torrent.info_hash, "torrent `info_hash` mismatch"); - assert_eq!(torrent.title, expected_torrent.title, "torrent `title` mismatch"); - assert_eq!( - torrent.description, expected_torrent.description, - "torrent `description` mismatch" - ); - assert_eq!( - torrent.category.category_id, expected_torrent.category.category_id, - "torrent `category.category_id` mismatch" - ); - assert_eq!( - torrent.category.name, expected_torrent.category.name, - "torrent `category.name` mismatch" - ); - // assert_eq!(torrent.category.num_torrents, expected_torrent.category.num_torrents, "torrent `category.num_torrents` mismatch"); // Ignored - // assert_eq!(torrent.upload_date, expected_torrent.upload_date, "torrent `upload_date` mismatch"); // Ignored, can't mock time easily for now. - assert_eq!(torrent.file_size, expected_torrent.file_size, "torrent `file_size` mismatch"); - assert_eq!(torrent.seeders, expected_torrent.seeders, "torrent `seeders` mismatch"); - assert_eq!(torrent.leechers, expected_torrent.leechers, "torrent `leechers` mismatch"); - assert_eq!(torrent.files, expected_torrent.files, "torrent `files` mismatch"); - assert_eq!(torrent.trackers, expected_torrent.trackers, "torrent `trackers` mismatch"); - assert_eq!( - torrent.magnet_link, expected_torrent.magnet_link, - "torrent `magnet_link` mismatch" - ); + let mut discrepancies = Vec::new(); + + let checks: Vec = vec![ + ("torrent_id", torrent.torrent_id == expected_torrent.torrent_id), + ("uploader", torrent.uploader == expected_torrent.uploader), + ("info_hash", torrent.info_hash == expected_torrent.info_hash), + ("title", torrent.title == expected_torrent.title), + ("description", torrent.description == expected_torrent.description), + ( + "category.category_id", + torrent.category.category_id == expected_torrent.category.category_id, + ), + ("category.name", torrent.category.name == expected_torrent.category.name), + ("file_size", torrent.file_size == expected_torrent.file_size), + ("seeders", torrent.seeders == expected_torrent.seeders), + ("leechers", torrent.leechers == expected_torrent.leechers), + ("files", torrent.files == expected_torrent.files), + ("trackers", torrent.trackers == expected_torrent.trackers), + ("magnet_link", torrent.magnet_link == expected_torrent.magnet_link), + ("tags", torrent.tags == expected_torrent.tags), + ("name", torrent.name == expected_torrent.name), + ]; + + for (field_name, equals) in &checks { + if !equals { + discrepancies.push((*field_name).to_string()); + } + } + + let error_message = format!("left:\n{torrent:#?}\nright:\n{expected_torrent:#?}\ndiscrepancies: {discrepancies:#?}"); + + assert!(discrepancies.is_empty(), "{}", error_message); } diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index 309be5bd..3ee8c84a 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -13,14 +13,15 @@ use super::file::{create_torrent, parse_torrent, TorrentFileInfo}; use super::forms::{BinaryFile, UploadTorrentMultipartForm}; use super::requests::InfoHash; use super::responses::Id; -use crate::common::contexts::category::fixtures::software_predefined_category_name; +use crate::common::contexts::category::fixtures::software_category_name; -/// Information about a torrent that is going to added to the index. +/// Information about a torrent that is going to be added to the index. #[derive(Clone)] pub struct TorrentIndexInfo { pub title: String, pub description: String, pub category: String, + pub tags: Option>, pub torrent_file: BinaryFile, pub name: String, } @@ -78,24 +79,7 @@ impl TestTorrent { // Create a random torrent file let torrent_path = random_torrent_file(&torrents_dir_path, &id); - // Load torrent binary file - let torrent_file = BinaryFile::from_file_at_path(&torrent_path); - - // Load torrent file metadata - let torrent_info = parse_torrent(&torrent_path); - - let torrent_to_index = TorrentIndexInfo { - title: format!("title-{id}"), - description: format!("description-{id}"), - category: software_predefined_category_name(), - torrent_file, - name: format!("name-{id}"), - }; - - TestTorrent { - file_info: torrent_info, - index_info: torrent_to_index, - } + Self::build_from_torrent_file(&id, &torrent_path) } pub fn with_custom_info_dict_field(id: Uuid, file_contents: &str, custom: &str) -> Self { @@ -110,25 +94,39 @@ impl TestTorrent { let torrent_data = TestTorrentWithCustomInfoField::encode(&torrent).unwrap(); // Torrent temporary file path - let filename = format!("file-{id}.txt.torrent"); - let torrent_path = torrents_dir_path.join(filename.clone()); + let contents_filename = contents_file_name(&id); + let torrent_filename = format!("{contents_filename}.torrent"); + let torrent_path = torrents_dir_path.join(torrent_filename.clone()); // Write the torrent file to the temporary file let mut file = File::create(torrent_path.clone()).unwrap(); file.write_all(&torrent_data).unwrap(); + Self::build_from_torrent_file(&id, &torrent_path) + } + + pub fn file_info_hash(&self) -> InfoHash { + self.file_info.info_hash.clone() + } + + /// It builds a `TestTorrent` from a torrent file. + fn build_from_torrent_file(id: &Uuid, torrent_path: &Path) -> TestTorrent { // Load torrent binary file - let torrent_file = BinaryFile::from_file_at_path(&torrent_path); + let torrent_file = BinaryFile::from_file_at_path(torrent_path); // Load torrent file metadata - let torrent_info = parse_torrent(&torrent_path); + let torrent_info = parse_torrent(torrent_path); let torrent_to_index = TorrentIndexInfo { title: format!("title-{id}"), description: format!("description-{id}"), - category: software_predefined_category_name(), + category: software_category_name(), + // todo: include one tag in test torrents. Implementation is not + // trivial because the tag must exist in the database and there are + // no predefined tags in the database like there are for categories. + tags: None, torrent_file, - name: filename, + name: contents_file_name(id), }; TestTorrent { @@ -136,10 +134,6 @@ impl TestTorrent { index_info: torrent_to_index, } } - - pub fn file_info_hash(&self) -> InfoHash { - self.file_info.info_hash.clone() - } } pub fn random_torrent() -> TestTorrent { @@ -156,7 +150,7 @@ pub fn random_torrent_file(dir: &Path, id: &Uuid) -> PathBuf { pub fn random_txt_file(dir: &Path, id: &Uuid) -> String { // Sample file name - let file_name = format!("file-{id}.txt"); + let file_name = contents_file_name(id); // Sample file path let file_path = dir.join(file_name.clone()); @@ -168,6 +162,10 @@ pub fn random_txt_file(dir: &Path, id: &Uuid) -> String { file_name } +fn contents_file_name(id: &Uuid) -> String { + format!("file-{id}.txt") +} + pub fn temp_dir() -> TempDir { tempdir().unwrap() } diff --git a/tests/common/contexts/torrent/responses.rs b/tests/common/contexts/torrent/responses.rs index 001784d2..ee08c2dc 100644 --- a/tests/common/contexts/torrent/responses.rs +++ b/tests/common/contexts/torrent/responses.rs @@ -2,6 +2,7 @@ use serde::Deserialize; pub type Id = i64; pub type CategoryId = i64; +pub type TagId = i64; pub type UtcDateTime = String; // %Y-%m-%d %H:%M:%S #[derive(Deserialize, PartialEq, Debug)] @@ -61,6 +62,7 @@ pub struct TorrentDetails { pub files: Vec, pub trackers: Vec, pub magnet_link: String, + pub tags: Vec, pub name: String, } @@ -71,6 +73,12 @@ pub struct Category { pub num_torrents: u64, } +#[derive(Deserialize, PartialEq, Debug)] +pub struct Tag { + pub tag_id: TagId, + pub name: String, +} + #[derive(Deserialize, PartialEq, Debug)] pub struct File { pub path: Vec, diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index bc93cfcc..5ed440b1 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -209,6 +209,7 @@ mod for_guests { encoded_tracker_url, encoded_tracker_url ), + tags: vec![], name: test_torrent.index_info.name.clone(), }; From c2b74885c8cd3c59703857a51c55f3b31abb5f80 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 14 Sep 2023 16:02:49 +0100 Subject: [PATCH 314/357] feat!: [#288] do not allow empty category names It was allowed to use an empty string like "" or " " for a category name. From now on, it's not allowed. If there were some empty names in the database, they were not renamed. Admins must optionally do that. Names were anyway UNIQUE. A migration to rename empty names was not added because there can be more than one category, for example: - "" - " " - " " - Etcetera We could have generated names like "no category 1", "no category 2", but it's not likely that admins have created empty categories. --- src/errors.rs | 4 +++ src/services/category.rs | 8 ++++- .../web/api/v1/contexts/category/contract.rs | 29 +++++++------------ 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index 6706cc57..eb7b6dab 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -130,6 +130,9 @@ pub enum ServiceError { #[display(fmt = "Category already exists.")] CategoryAlreadyExists, + #[display(fmt = "Category name cannot be empty.")] + CategoryNameEmpty, + #[display(fmt = "Tag already exists.")] TagAlreadyExists, @@ -235,6 +238,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::CanonicalInfoHashAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, + ServiceError::CategoryNameEmpty => StatusCode::BAD_REQUEST, ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/services/category.rs b/src/services/category.rs index 548a2374..5abe8aa6 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -38,7 +38,13 @@ impl Service { return Err(ServiceError::Unauthorized); } - match self.category_repository.add(category_name).await { + let trimmed_name = category_name.trim(); + + if trimmed_name.is_empty() { + return Err(ServiceError::CategoryNameEmpty); + } + + match self.category_repository.add(trimmed_name).await { Ok(id) => Ok(id), Err(e) => match e { DatabaseError::CategoryAlreadyExists => Err(ServiceError::CategoryAlreadyExists), diff --git a/tests/e2e/web/api/v1/contexts/category/contract.rs b/tests/e2e/web/api/v1/contexts/category/contract.rs index 0ec559c9..84494ee9 100644 --- a/tests/e2e/web/api/v1/contexts/category/contract.rs +++ b/tests/e2e/web/api/v1/contexts/category/contract.rs @@ -104,32 +104,25 @@ async fn it_should_allow_admins_to_add_new_categories() { } #[tokio::test] -async fn it_should_allow_adding_empty_categories() { - // code-review: this is a bit weird, is it a intended behavior? - +async fn it_should_not_allow_adding_empty_categories() { let mut env = TestEnv::new(); env.start(api::Version::V1).await; - if env.is_shared() { - // This test cannot be run in a shared test env because it will fail - // when the empty category already exits - println!("Skipped"); - return; - } - let logged_in_admin = new_logged_in_admin(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); - let category_name = String::new(); + let invalid_category_names = vec![String::new(), " ".to_string()]; - let response = client - .add_category(AddCategoryForm { - name: category_name.to_string(), - icon: None, - }) - .await; + for invalid_name in invalid_category_names { + let response = client + .add_category(AddCategoryForm { + name: invalid_name, + icon: None, + }) + .await; - assert_added_category_response(&response, &category_name); + assert_eq!(response.status, 400); + } } #[tokio::test] From 01a7d2e90c0bb561f91eef495876913cf91dafff Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 14 Sep 2023 16:28:08 +0100 Subject: [PATCH 315/357] feat!: [#289] do not allow empty tag names It was allowed to use an empty strings like "" or " " for a tag name. From now on, it's not allowed. If there are some empty names in the database, they are not renamed. A migration to rename empty names was not added because there can be more than one tag, for example: - "" - " " - " " - Etcetera We could have generated names like "no tag 1", "no tag 2", but it's not likely that admins have created empty tags. --- src/errors.rs | 4 ++++ src/services/tag.rs | 8 +++++++- tests/e2e/web/api/v1/contexts/tag/contract.rs | 14 ++++++++------ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index eb7b6dab..25ea007d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -136,6 +136,9 @@ pub enum ServiceError { #[display(fmt = "Tag already exists.")] TagAlreadyExists, + #[display(fmt = "Tag name cannot be empty.")] + TagNameEmpty, + #[display(fmt = "Category not found.")] CategoryNotFound, @@ -240,6 +243,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::CategoryNameEmpty => StatusCode::BAD_REQUEST, ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST, + ServiceError::TagNameEmpty => StatusCode::BAD_REQUEST, ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::EmailMissing => StatusCode::NOT_FOUND, diff --git a/src/services/tag.rs b/src/services/tag.rs index 9bac69b4..551e58dc 100644 --- a/src/services/tag.rs +++ b/src/services/tag.rs @@ -38,7 +38,13 @@ impl Service { return Err(ServiceError::Unauthorized); } - match self.tag_repository.add(tag_name).await { + let trimmed_name = tag_name.trim(); + + if trimmed_name.is_empty() { + return Err(ServiceError::TagNameEmpty); + } + + match self.tag_repository.add(trimmed_name).await { Ok(()) => Ok(()), Err(e) => match e { DatabaseError::TagAlreadyExists => Err(ServiceError::TagAlreadyExists), diff --git a/tests/e2e/web/api/v1/contexts/tag/contract.rs b/tests/e2e/web/api/v1/contexts/tag/contract.rs index bfb7b1b8..152de450 100644 --- a/tests/e2e/web/api/v1/contexts/tag/contract.rs +++ b/tests/e2e/web/api/v1/contexts/tag/contract.rs @@ -121,15 +121,17 @@ async fn it_should_allow_adding_duplicated_tags() { } #[tokio::test] -async fn it_should_allow_adding_a_tag_with_an_empty_name() { - // code-review: is this an intended behavior? - +async fn it_should_not_allow_adding_a_tag_with_an_empty_name() { let mut env = TestEnv::new(); env.start(api::Version::V1).await; - let empty_tag_name = String::new(); - let response = add_tag(&empty_tag_name, &env).await; - assert_eq!(response.status, 200); + let invalid_tag_names = vec![String::new(), " ".to_string()]; + + for invalid_name in invalid_tag_names { + let response = add_tag(&invalid_name, &env).await; + + assert_eq!(response.status, 400); + } } #[tokio::test] From 7fedf15c52e447a508a66d3ffc1a7ef406a9ec3f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 14 Sep 2023 18:11:01 +0100 Subject: [PATCH 316/357] feat!: [#289] do not allow duplicate tags Duplicate tag names were allowed. This introduce unique constrains. If there were duyplicate tags they are updated appeding the id as a suffix. For example, having two tags: ```json [ { tag_id: 1, name: "fractal" }, { tag_id: 2, name: "fractal" } ] ``` They would be renamed to: ```json [ { tag_id: 1, name: "fractal_1" }, { tag_id: 2, name: "fractal_2" } ] ``` From now on, duplicate tags are not allowed. --- ...0230914155441_torrust_no_duplicate_tags.sql | 12 ++++++++++++ ...0230914155441_torrust_no_duplicate_tags.sql | 13 +++++++++++++ src/databases/database.rs | 2 +- src/databases/mysql.rs | 16 +++++++++++++--- src/databases/sqlite.rs | 18 ++++++++++++++---- src/services/tag.rs | 8 ++++---- .../databases/sqlite_v2_0_0.rs | 2 +- src/web/api/v1/contexts/tag/handlers.rs | 2 +- tests/e2e/web/api/v1/contexts/tag/contract.rs | 7 +++---- tests/e2e/web/api/v1/contexts/user/steps.rs | 2 +- 10 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 migrations/mysql/20230914155441_torrust_no_duplicate_tags.sql create mode 100644 migrations/sqlite3/20230914155441_torrust_no_duplicate_tags.sql diff --git a/migrations/mysql/20230914155441_torrust_no_duplicate_tags.sql b/migrations/mysql/20230914155441_torrust_no_duplicate_tags.sql new file mode 100644 index 00000000..6b513fbe --- /dev/null +++ b/migrations/mysql/20230914155441_torrust_no_duplicate_tags.sql @@ -0,0 +1,12 @@ +-- Step 1 & 2: Identify and update the duplicate names +UPDATE torrust_torrent_tags +JOIN ( + SELECT name + FROM torrust_torrent_tags + GROUP BY name + HAVING COUNT(*) > 1 +) AS DuplicateNames ON torrust_torrent_tags.name = DuplicateNames.name +SET torrust_torrent_tags.name = CONCAT(torrust_torrent_tags.name, '_', torrust_torrent_tags.tag_id); + +-- Step 3: Add the UNIQUE constraint to the name column +ALTER TABLE torrust_torrent_tags ADD UNIQUE (name); diff --git a/migrations/sqlite3/20230914155441_torrust_no_duplicate_tags.sql b/migrations/sqlite3/20230914155441_torrust_no_duplicate_tags.sql new file mode 100644 index 00000000..e16f2ef0 --- /dev/null +++ b/migrations/sqlite3/20230914155441_torrust_no_duplicate_tags.sql @@ -0,0 +1,13 @@ +-- Step 1: Identify and update the duplicate names +WITH DuplicateNames AS ( + SELECT name + FROM torrust_torrent_tags + GROUP BY name + HAVING COUNT(*) > 1 +) +UPDATE torrust_torrent_tags +SET name = name || '_' || tag_id +WHERE name IN (SELECT name FROM DuplicateNames); + +-- Step 2: Create a UNIQUE index on the name column +CREATE UNIQUE INDEX idx_unique_name ON torrust_torrent_tags(name); diff --git a/src/databases/database.rs b/src/databases/database.rs index 45fbdb3f..8fc10d79 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -287,7 +287,7 @@ pub trait Database: Sync + Send { async fn update_torrent_category(&self, torrent_id: i64, category_id: CategoryId) -> Result<(), Error>; /// Add a new tag. - async fn add_tag(&self, name: &str) -> Result<(), Error>; + async fn insert_tag_and_get_id(&self, name: &str) -> Result; /// Delete a tag. async fn delete_tag(&self, tag_id: TagId) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index b3d18ac3..8a044fb4 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -804,13 +804,23 @@ impl Database for Mysql { }) } - async fn add_tag(&self, name: &str) -> Result<(), database::Error> { + async fn insert_tag_and_get_id(&self, name: &str) -> Result { query("INSERT INTO torrust_torrent_tags (name) VALUES (?)") .bind(name) .execute(&self.pool) .await - .map(|_| ()) - .map_err(|err| database::Error::ErrorWithText(err.to_string())) + .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) + .map_err(|e| match e { + sqlx::Error::Database(err) => { + log::error!("DB error: {:?}", err); + if err.message().contains("Duplicate entry") && err.message().contains("name") { + database::Error::TagAlreadyExists + } else { + database::Error::Error + } + } + _ => database::Error::Error, + }) } async fn delete_tag(&self, tag_id: TagId) -> Result<(), database::Error> { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 6b2ebbd8..86b71570 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -794,13 +794,23 @@ impl Database for Sqlite { }) } - async fn add_tag(&self, name: &str) -> Result<(), database::Error> { + async fn insert_tag_and_get_id(&self, tag_name: &str) -> Result { query("INSERT INTO torrust_torrent_tags (name) VALUES (?)") - .bind(name) + .bind(tag_name) .execute(&self.pool) .await - .map(|_| ()) - .map_err(|err| database::Error::ErrorWithText(err.to_string())) + .map(|v| v.last_insert_rowid()) + .map_err(|e| match e { + sqlx::Error::Database(err) => { + log::error!("DB error: {:?}", err); + if err.message().contains("UNIQUE") && err.message().contains("name") { + database::Error::TagAlreadyExists + } else { + database::Error::Error + } + } + _ => database::Error::Error, + }) } async fn delete_tag(&self, tag_id: TagId) -> Result<(), database::Error> { diff --git a/src/services/tag.rs b/src/services/tag.rs index 551e58dc..fcbf56c3 100644 --- a/src/services/tag.rs +++ b/src/services/tag.rs @@ -29,7 +29,7 @@ impl Service { /// /// * The user does not have the required permissions. /// * There is a database error. - pub async fn add_tag(&self, tag_name: &str, user_id: &UserId) -> Result<(), ServiceError> { + pub async fn add_tag(&self, tag_name: &str, user_id: &UserId) -> Result { let user = self.user_repository.get_compact(user_id).await?; // Check if user is administrator @@ -45,7 +45,7 @@ impl Service { } match self.tag_repository.add(trimmed_name).await { - Ok(()) => Ok(()), + Ok(id) => Ok(id), Err(e) => match e { DatabaseError::TagAlreadyExists => Err(ServiceError::TagAlreadyExists), _ => Err(ServiceError::DatabaseError), @@ -95,8 +95,8 @@ impl DbTagRepository { /// # Errors /// /// It returns an error if there is a database error. - pub async fn add(&self, tag_name: &str) -> Result<(), Error> { - self.database.add_tag(tag_name).await + pub async fn add(&self, tag_name: &str) -> Result { + self.database.insert_tag_and_get_id(tag_name).await } /// It returns all the tags. diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index eb298687..f0315ff2 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -280,7 +280,7 @@ impl SqliteDatabaseV2_0_0 { query(&format!("DELETE FROM {table};")) .execute(&self.pool) .await - .expect("table {table} should be deleted"); + .unwrap_or_else(|_| panic!("table {table} should be deleted")); } Ok(()) diff --git a/src/web/api/v1/contexts/tag/handlers.rs b/src/web/api/v1/contexts/tag/handlers.rs index 04293bad..f750c385 100644 --- a/src/web/api/v1/contexts/tag/handlers.rs +++ b/src/web/api/v1/contexts/tag/handlers.rs @@ -52,7 +52,7 @@ pub async fn add_handler( }; match app_data.tag_service.add_tag(&add_tag_form.name, &user_id).await { - Ok(()) => added_tag(&add_tag_form.name).into_response(), + Ok(_) => added_tag(&add_tag_form.name).into_response(), Err(error) => error.into_response(), } } diff --git a/tests/e2e/web/api/v1/contexts/tag/contract.rs b/tests/e2e/web/api/v1/contexts/tag/contract.rs index 152de450..9a505520 100644 --- a/tests/e2e/web/api/v1/contexts/tag/contract.rs +++ b/tests/e2e/web/api/v1/contexts/tag/contract.rs @@ -104,9 +104,7 @@ async fn it_should_allow_admins_to_add_new_tags() { } #[tokio::test] -async fn it_should_allow_adding_duplicated_tags() { - // code-review: is this an intended behavior? - +async fn it_should_not_allow_adding_duplicated_tags() { let mut env = TestEnv::new(); env.start(api::Version::V1).await; @@ -117,7 +115,8 @@ async fn it_should_allow_adding_duplicated_tags() { // Try to add the same tag again let response = add_tag(&random_tag_name, &env).await; - assert_eq!(response.status, 200); + + assert_eq!(response.status, 400); } #[tokio::test] diff --git a/tests/e2e/web/api/v1/contexts/user/steps.rs b/tests/e2e/web/api/v1/contexts/user/steps.rs index f0c8a531..1a417064 100644 --- a/tests/e2e/web/api/v1/contexts/user/steps.rs +++ b/tests/e2e/web/api/v1/contexts/user/steps.rs @@ -20,7 +20,7 @@ pub async fn new_logged_in_admin(env: &TestEnv) -> LoggedInUserData { let user_profile = database .get_user_profile_from_username(&user.username) .await - .unwrap_or_else(|_| panic!("user {user:#?} should have a profile.")); + .unwrap_or_else(|_| panic!("no user profile for the user: {user:#?}.")); database.grant_admin_role(user_profile.user_id).await.unwrap(); From 50cef816cf7230e3165f552f08d673835a1c6173 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 15 Sep 2023 15:52:35 +0100 Subject: [PATCH 317/357] chore: update config for debuggin in Visual Studio Code --- .vscode/launch.json | 213 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 186 insertions(+), 27 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index f6f4ed4c..4f1a4c0b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,35 +1,194 @@ { + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'torrust-index-backend'", + "cargo": { + "args": [ + "test", + "--no-run", + "--lib", + "--package=torrust-index-backend" + ], + "filter": { + "name": "torrust-index-backend", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'main'", + "cargo": { + "args": [ + "build", + "--bin=main", + "--package=torrust-index-backend" + ], + "filter": { + "name": "main", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'main'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=main", + "--package=torrust-index-backend" + ], + "filter": { + "name": "main", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'import_tracker_statistics'", + "cargo": { + "args": [ + "build", + "--bin=import_tracker_statistics", + "--package=torrust-index-backend" + ], + "filter": { + "name": "import_tracker_statistics", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'import_tracker_statistics'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=import_tracker_statistics", + "--package=torrust-index-backend" + ], + "filter": { + "name": "import_tracker_statistics", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", "name": "Debug executable 'parse_torrent'", - "type": "cppdbg", - "request": "launch", - "program": "${workspaceFolder}/target/debug/parse_torrent", - "args": ["./tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent"], - "stopAtEntry": false, - "cwd": "${workspaceFolder}", - "environment": [], - "externalConsole": false, - "MIMode": "gdb", - "setupCommands": [ - { - "description": "Enable pretty-printing for gdb", - "text": "-enable-pretty-printing", - "ignoreFailures": true - } - ], - "preLaunchTask": "cargo build", - "miDebuggerPath": "/usr/bin/gdb", - "linux": { - "miDebuggerPath": "/usr/bin/gdb" - }, - "windows": { - "miDebuggerPath": "C:\\MinGW\\bin\\gdb.exe" - }, - "osx": { - "miDebuggerPath": "/usr/local/bin/gdb" - } + "cargo": { + "args": [ + "build", + "--bin=parse_torrent", + "--package=torrust-index-backend" + ], + "filter": { + "name": "parse_torrent", + "kind": "bin" + } + }, + "args": ["./tests/fixtures/torrents/not-working-with-two-nodes.torrent"], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'parse_torrent'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=parse_torrent", + "--package=torrust-index-backend" + ], + "filter": { + "name": "parse_torrent", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'upgrade'", + "cargo": { + "args": [ + "build", + "--bin=upgrade", + "--package=torrust-index-backend" + ], + "filter": { + "name": "upgrade", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'upgrade'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=upgrade", + "--package=torrust-index-backend" + ], + "filter": { + "name": "upgrade", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug integration test 'mod'", + "cargo": { + "args": [ + "test", + "--no-run", + "--test=mod", + "--package=torrust-index-backend" + ], + "filter": { + "name": "mod", + "kind": "test" + } + }, + "args": [], + "cwd": "${workspaceFolder}" } ] } \ No newline at end of file From 3cf9c446492f80a087da387ef4bd080dd9580e7f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 15 Sep 2023 15:57:45 +0100 Subject: [PATCH 318/357] test: add two more test torrents One of them produces an error while parsing it which is described here: https://github.com/toby/serde-bencode/pull/32 --- tests/fixtures/torrents/not-working-with-two-nodes.torrent | 1 + tests/fixtures/torrents/working-with-one-node.torrent | 1 + 2 files changed, 2 insertions(+) create mode 100644 tests/fixtures/torrents/not-working-with-two-nodes.torrent create mode 100644 tests/fixtures/torrents/working-with-one-node.torrent diff --git a/tests/fixtures/torrents/not-working-with-two-nodes.torrent b/tests/fixtures/torrents/not-working-with-two-nodes.torrent new file mode 100644 index 00000000..9378c375 --- /dev/null +++ b/tests/fixtures/torrents/not-working-with-two-nodes.torrent @@ -0,0 +1 @@ +d4:infod6:lengthi8e4:name11:minimal.txt12:piece lengthi16384e6:pieces20:0QJ Ќ4B&g#Mxe5:nodesll15:188.163.121.224i56711eel14:162.250.131.26i13386eeee \ No newline at end of file diff --git a/tests/fixtures/torrents/working-with-one-node.torrent b/tests/fixtures/torrents/working-with-one-node.torrent new file mode 100644 index 00000000..fdfcfc04 --- /dev/null +++ b/tests/fixtures/torrents/working-with-one-node.torrent @@ -0,0 +1 @@ +d4:infod6:lengthi8e4:name11:minimal.txt12:piece lengthi16384e6:pieces20:0QJ Ќ4B&g#Mxe5:nodesll15:188.163.121.224i56711eeee \ No newline at end of file From eb26c8d5d8a98c1c8f02337ee3f6c7e45139db83 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 18 Sep 2023 15:46:53 +0100 Subject: [PATCH 319/357] fix: tag name for random tag in tests --- tests/common/contexts/tag/fixtures.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common/contexts/tag/fixtures.rs b/tests/common/contexts/tag/fixtures.rs index 39ac3081..6012497f 100644 --- a/tests/common/contexts/tag/fixtures.rs +++ b/tests/common/contexts/tag/fixtures.rs @@ -1,7 +1,7 @@ use rand::Rng; pub fn random_tag_name() -> String { - format!("category name {}", random_id()) + format!("tag name {}", random_id()) } fn random_id() -> u64 { From e9476fcc5ef3937273925d2367309aefcd9ccea2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 18 Sep 2023 15:33:45 +0100 Subject: [PATCH 320/357] feat: [#296] persist torrent comment and add it to the API responses. - Torrent details endpoint - Torrent list endpoint - Include the comment in the downloaded torrent --- ...4_torrust_add_comment_field_to_torrent.sql | 1 + ...4_torrust_add_comment_field_to_torrent.sql | 1 + src/databases/mysql.rs | 147 +++++++++++------- src/databases/sqlite.rs | 145 ++++++++++------- src/models/response.rs | 2 + src/models/torrent.rs | 1 + src/models/torrent_file.rs | 6 +- src/services/torrent_file.rs | 4 + tests/common/contexts/torrent/responses.rs | 2 + .../web/api/v1/contexts/torrent/contract.rs | 1 + 10 files changed, 204 insertions(+), 106 deletions(-) create mode 100644 migrations/mysql/20230918103654_torrust_add_comment_field_to_torrent.sql create mode 100644 migrations/sqlite3/20230918103654_torrust_add_comment_field_to_torrent.sql diff --git a/migrations/mysql/20230918103654_torrust_add_comment_field_to_torrent.sql b/migrations/mysql/20230918103654_torrust_add_comment_field_to_torrent.sql new file mode 100644 index 00000000..2ecee2a9 --- /dev/null +++ b/migrations/mysql/20230918103654_torrust_add_comment_field_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN comment TEXT NULL; diff --git a/migrations/sqlite3/20230918103654_torrust_add_comment_field_to_torrent.sql b/migrations/sqlite3/20230918103654_torrust_add_comment_field_to_torrent.sql new file mode 100644 index 00000000..ff8774e2 --- /dev/null +++ b/migrations/sqlite3/20230918103654_torrust_add_comment_field_to_torrent.sql @@ -0,0 +1 @@ +ALTER TABLE "torrust_torrents" ADD COLUMN "comment" TEXT NULL; \ No newline at end of file diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 8a044fb4..550a3d7d 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -300,7 +300,8 @@ impl Database for Mysql { }) } - // TODO: refactor this + // todo: refactor this + #[allow(clippy::too_many_lines)] async fn get_torrents_search_sorted_paginated( &self, search: &Option, @@ -375,7 +376,17 @@ impl Database for Mysql { }; let mut query_string = format!( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, tt.name, + "SELECT + tt.torrent_id, + tp.username AS uploader, + tt.info_hash, + ti.title, + ti.description, + tt.category_id, + DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, + tt.size AS file_size, + tt.name, + tt.comment, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -443,31 +454,47 @@ impl Database for Mysql { }; // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") - .bind(uploader_id) - .bind(category_id) - .bind(info_hash.to_lowercase()) - .bind(torrent.file_size()) - .bind(torrent.info.name.to_string()) - .bind(pieces) - .bind(torrent.info.piece_length) - .bind(torrent.info.private) - .bind(root_hash) - .bind(torrent.info.source.clone()) - .execute(&mut tx) - .await - .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) - .map_err(|e| match e { - sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); - if err.message().contains("Duplicate entry") && err.message().contains("info_hash") { - database::Error::TorrentAlreadyExists - } else { - database::Error::Error - } + let torrent_id = query( + "INSERT INTO torrust_torrents ( + uploader_id, + category_id, + info_hash, + size, + name, + pieces, + piece_length, + private, + root_hash, + `source`, + comment, + date_uploaded + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())", + ) + .bind(uploader_id) + .bind(category_id) + .bind(info_hash.to_lowercase()) + .bind(torrent.file_size()) + .bind(torrent.info.name.to_string()) + .bind(pieces) + .bind(torrent.info.piece_length) + .bind(torrent.info.private) + .bind(root_hash) + .bind(torrent.info.source.clone()) + .bind(torrent.comment.clone()) + .execute(&mut tx) + .await + .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) + .map_err(|e| match e { + sqlx::Error::Database(err) => { + log::error!("DB error: {:?}", err); + if err.message().contains("Duplicate entry") && err.message().contains("info_hash") { + database::Error::TorrentAlreadyExists + } else { + database::Error::Error } - _ => database::Error::Error - })?; + } + _ => database::Error::Error, + })?; // add torrent canonical infohash @@ -650,23 +677,19 @@ impl Database for Mysql { } async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { - query_as::<_, DbTorrentInfo>( - "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", - ) - .bind(torrent_id) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { - query_as::<_, DbTorrentInfo>( - "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", - ) - .bind(info_hash.to_hex_string().to_lowercase()) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE info_hash = ?") + .bind(info_hash.to_hex_string().to_lowercase()) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, database::Error> { @@ -705,7 +728,17 @@ impl Database for Mysql { async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, tt.name, + "SELECT + tt.torrent_id, + tp.username AS uploader, + tt.info_hash, + ti.title, + ti.description, + tt.category_id, + DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, + tt.size AS file_size, + tt.name, + tt.comment, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -713,17 +746,27 @@ impl Database for Mysql { INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE tt.torrent_id = ? - GROUP BY torrent_id" + GROUP BY torrent_id", ) - .bind(torrent_id) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + .bind(torrent_id) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_torrent_listing_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, tt.size AS file_size, tt.name, + "SELECT + tt.torrent_id, + tp.username AS uploader, + tt.info_hash, + ti.title, + ti.description, + tt.category_id, + DATE_FORMAT(tt.date_uploaded, '%Y-%m-%d %H:%i:%s') AS date_uploaded, + tt.size AS file_size, + tt.name, + tt.comment, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -731,12 +774,12 @@ impl Database for Mysql { INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE tt.info_hash = ? - GROUP BY torrent_id" + GROUP BY torrent_id", ) - .bind(info_hash.to_hex_string().to_lowercase()) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + .bind(info_hash.to_hex_string().to_lowercase()) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_all_torrents_compact(&self) -> Result, database::Error> { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 86b71570..9a0dae7a 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -290,7 +290,8 @@ impl Database for Sqlite { }) } - // TODO: refactor this + // todo: refactor this + #[allow(clippy::too_many_lines)] async fn get_torrents_search_sorted_paginated( &self, search: &Option, @@ -365,7 +366,17 @@ impl Database for Sqlite { }; let mut query_string = format!( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, tt.name, + "SELECT + tt.torrent_id, + tp.username AS uploader, + tt.info_hash, + ti.title, + ti.description, + tt.category_id, + tt.date_uploaded, + tt.size AS file_size, + tt.name, + tt.comment, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -433,31 +444,47 @@ impl Database for Sqlite { }; // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") - .bind(uploader_id) - .bind(category_id) - .bind(info_hash.to_lowercase()) - .bind(torrent.file_size()) - .bind(torrent.info.name.to_string()) - .bind(pieces) - .bind(torrent.info.piece_length) - .bind(torrent.info.private) - .bind(root_hash) - .bind(torrent.info.source.clone()) - .execute(&mut tx) - .await - .map(|v| v.last_insert_rowid()) - .map_err(|e| match e { - sqlx::Error::Database(err) => { - log::error!("DB error: {:?}", err); - if err.message().contains("UNIQUE") && err.message().contains("info_hash") { - database::Error::TorrentAlreadyExists - } else { - database::Error::Error - } + let torrent_id = query( + "INSERT INTO torrust_torrents ( + uploader_id, + category_id, + info_hash, + size, + name, + pieces, + piece_length, + private, + root_hash, + `source`, + comment, + date_uploaded + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))", + ) + .bind(uploader_id) + .bind(category_id) + .bind(info_hash.to_lowercase()) + .bind(torrent.file_size()) + .bind(torrent.info.name.to_string()) + .bind(pieces) + .bind(torrent.info.piece_length) + .bind(torrent.info.private) + .bind(root_hash) + .bind(torrent.info.source.clone()) + .bind(torrent.comment.clone()) + .execute(&mut tx) + .await + .map(|v| v.last_insert_rowid()) + .map_err(|e| match e { + sqlx::Error::Database(err) => { + log::error!("DB error: {:?}", err); + if err.message().contains("UNIQUE") && err.message().contains("info_hash") { + database::Error::TorrentAlreadyExists + } else { + database::Error::Error } - _ => database::Error::Error - })?; + } + _ => database::Error::Error, + })?; // add torrent canonical infohash @@ -640,23 +667,19 @@ impl Database for Sqlite { } async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { - query_as::<_, DbTorrentInfo>( - "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", - ) - .bind(torrent_id) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE torrent_id = ?") + .bind(torrent_id) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { - query_as::<_, DbTorrentInfo>( - "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", - ) - .bind(info_hash.to_hex_string().to_lowercase()) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE info_hash = ?") + .bind(info_hash.to_hex_string().to_lowercase()) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, database::Error> { @@ -695,7 +718,16 @@ impl Database for Sqlite { async fn get_torrent_listing_from_id(&self, torrent_id: i64) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, tt.name, + "SELECT + tt.torrent_id, + tp.username AS uploader, + tt.info_hash, ti.title, + ti.description, + tt.category_id, + tt.date_uploaded, + tt.size AS file_size, + tt.name, + tt.comment, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -703,17 +735,26 @@ impl Database for Sqlite { INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE tt.torrent_id = ? - GROUP BY ts.torrent_id" + GROUP BY ts.torrent_id", ) - .bind(torrent_id) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + .bind(torrent_id) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_torrent_listing_from_info_hash(&self, info_hash: &InfoHash) -> Result { query_as::<_, TorrentListing>( - "SELECT tt.torrent_id, tp.username AS uploader, tt.info_hash, ti.title, ti.description, tt.category_id, tt.date_uploaded, tt.size AS file_size, tt.name, + "SELECT + tt.torrent_id, + tp.username AS uploader, + tt.info_hash, ti.title, + ti.description, + tt.category_id, + tt.date_uploaded, + tt.size AS file_size, + tt.name, + tt.comment, CAST(COALESCE(sum(ts.seeders),0) as signed) as seeders, CAST(COALESCE(sum(ts.leechers),0) as signed) as leechers FROM torrust_torrents tt @@ -721,12 +762,12 @@ impl Database for Sqlite { INNER JOIN torrust_torrent_info ti ON tt.torrent_id = ti.torrent_id LEFT JOIN torrust_torrent_tracker_stats ts ON tt.torrent_id = ts.torrent_id WHERE tt.info_hash = ? - GROUP BY ts.torrent_id" + GROUP BY ts.torrent_id", ) - .bind(info_hash.to_string().to_lowercase()) - .fetch_one(&self.pool) - .await - .map_err(|_| database::Error::TorrentNotFound) + .bind(info_hash.to_string().to_lowercase()) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::TorrentNotFound) } async fn get_all_torrents_compact(&self) -> Result, database::Error> { diff --git a/src/models/response.rs b/src/models/response.rs index adb1de07..7d408b79 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -62,6 +62,7 @@ pub struct TorrentResponse { pub magnet_link: String, pub tags: Vec, pub name: String, + pub comment: Option, } impl TorrentResponse { @@ -83,6 +84,7 @@ impl TorrentResponse { magnet_link: String::new(), tags: vec![], name: torrent_listing.name, + comment: torrent_listing.comment, } } } diff --git a/src/models/torrent.rs b/src/models/torrent.rs index eb2bcde2..150d2bba 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -21,6 +21,7 @@ pub struct TorrentListing { pub seeders: i64, pub leechers: i64, pub name: String, + pub comment: Option, } #[derive(Debug, Deserialize)] diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index c8849170..effd0f48 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -98,7 +98,7 @@ pub struct Torrent { #[serde(default)] #[serde(rename = "creation date")] pub creation_date: Option, - #[serde(rename = "comment")] + #[serde(default)] pub comment: Option, #[serde(default)] #[serde(rename = "created by")] @@ -171,7 +171,7 @@ impl Torrent { httpseeds: None, announce_list: Some(torrent_info.announce_urls), creation_date: None, - comment: None, + comment: torrent_info.comment, created_by: None, } } @@ -191,6 +191,7 @@ impl Torrent { root_hash: torrent_info.root_hash, files: torrent_files, announce_urls: torrent_announce_urls, + comment: torrent_info.comment, }; Torrent::from_new_torrent_info_request(torrent_info_request) } @@ -296,6 +297,7 @@ pub struct DbTorrentInfo { #[serde(default)] pub private: Option, pub root_hash: i64, + pub comment: Option, } #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index dfa72dbd..dbfa72f5 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -9,13 +9,16 @@ use crate::services::hasher::sha1; /// It's not the full in-memory representation of a torrent file. The full /// in-memory representation is the `Torrent` struct. pub struct NewTorrentInfoRequest { + // The `info` dictionary fields pub name: String, pub pieces: String, pub piece_length: i64, pub private: Option, pub root_hash: i64, pub files: Vec, + // Other fields of the root level metainfo dictionary pub announce_urls: Vec>, + pub comment: Option, } /// It generates a random single-file torrent for testing purposes. @@ -48,6 +51,7 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { root_hash: 0, files: torrent_files, announce_urls: torrent_announce_urls, + comment: None, }; Torrent::from_new_torrent_info_request(torrent_info_request) diff --git a/tests/common/contexts/torrent/responses.rs b/tests/common/contexts/torrent/responses.rs index ee08c2dc..f95d67ce 100644 --- a/tests/common/contexts/torrent/responses.rs +++ b/tests/common/contexts/torrent/responses.rs @@ -40,6 +40,7 @@ pub struct ListItem { pub seeders: i64, pub leechers: i64, pub name: String, + pub comment: Option, } #[derive(Deserialize, PartialEq, Debug)] @@ -64,6 +65,7 @@ pub struct TorrentDetails { pub magnet_link: String, pub tags: Vec, pub name: String, + pub comment: Option, } #[derive(Deserialize, PartialEq, Debug)] diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 5ed440b1..3e577b37 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -211,6 +211,7 @@ mod for_guests { ), tags: vec![], name: test_torrent.index_info.name.clone(), + comment: test_torrent.file_info.comment.clone(), }; assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); From f0ad6a442f93560c61eda5296b1cfe26f48299a3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 18 Sep 2023 17:18:48 +0100 Subject: [PATCH 321/357] refactor: rename structs and reorganize mods --- src/databases/database.rs | 18 +- src/databases/mysql.rs | 10 +- src/databases/sqlite.rs | 10 +- src/models/torrent_file.rs | 249 +++++++++--------- src/services/torrent.rs | 4 +- src/services/torrent_file.rs | 75 +++++- .../databases/sqlite_v2_0_0.rs | 4 +- 7 files changed, 215 insertions(+), 155 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 8fc10d79..e5778649 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -8,7 +8,7 @@ use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; -use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; @@ -209,11 +209,7 @@ pub trait Database: Sync + Send { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_info.torrent_id).await?; - Ok(Torrent::from_db_info_files_and_announce_urls( - torrent_info, - torrent_files, - torrent_announce_urls, - )) + Ok(Torrent::from_database(torrent_info, torrent_files, torrent_announce_urls)) } /// Get `Torrent` from `torrent_id`. @@ -224,11 +220,7 @@ pub trait Database: Sync + Send { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - Ok(Torrent::from_db_info_files_and_announce_urls( - torrent_info, - torrent_files, - torrent_announce_urls, - )) + Ok(Torrent::from_database(torrent_info, torrent_files, torrent_announce_urls)) } /// It returns the list of all infohashes producing the same canonical @@ -257,10 +249,10 @@ pub trait Database: Sync + Send { async fn add_info_hash_to_canonical_info_hash_group(&self, original: &InfoHash, canonical: &InfoHash) -> Result<(), Error>; /// Get torrent's info as `DbTorrentInfo` from `torrent_id`. - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; /// Get torrent's info as `DbTorrentInfo` from torrent `InfoHash`. - async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result; + async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result; /// Get all torrent's files as `Vec` from `torrent_id`. async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 550a3d7d..f793e5cb 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -13,7 +13,7 @@ use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; -use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::torrent_file::{DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; @@ -676,16 +676,16 @@ impl Database for Mysql { .map_err(|err| database::Error::ErrorWithText(err.to_string())) } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { - query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE torrent_id = ?") + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { + query_as::<_, DbTorrent>("SELECT * FROM torrust_torrents WHERE torrent_id = ?") .bind(torrent_id) .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { - query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE info_hash = ?") + async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { + query_as::<_, DbTorrent>("SELECT * FROM torrust_torrents WHERE info_hash = ?") .bind(info_hash.to_hex_string().to_lowercase()) .fetch_one(&self.pool) .await diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 9a0dae7a..980e7a3b 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -13,7 +13,7 @@ use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; -use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::torrent_file::{DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; use crate::models::user::{User, UserAuthentication, UserCompact, UserId, UserProfile}; @@ -666,16 +666,16 @@ impl Database for Sqlite { .map_err(|err| database::Error::ErrorWithText(err.to_string())) } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { - query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE torrent_id = ?") + async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { + query_as::<_, DbTorrent>("SELECT * FROM torrust_torrents WHERE torrent_id = ?") .bind(torrent_id) .fetch_one(&self.pool) .await .map_err(|_| database::Error::TorrentNotFound) } - async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { - query_as::<_, DbTorrentInfo>("SELECT * FROM torrust_torrents WHERE info_hash = ?") + async fn get_torrent_info_from_info_hash(&self, info_hash: &InfoHash) -> Result { + query_as::<_, DbTorrent>("SELECT * FROM torrust_torrents WHERE info_hash = ?") .bind(info_hash.to_hex_string().to_lowercase()) .fetch_one(&self.pool) .await diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index effd0f48..ace9f9fa 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -5,22 +5,38 @@ use sha1::{Digest, Sha1}; use super::info_hash::InfoHash; use crate::config::Configuration; -use crate::services::torrent_file::NewTorrentInfoRequest; +use crate::services::torrent_file::CreateTorrentRequest; use crate::utils::hex::{from_bytes, into_bytes}; -#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] -pub struct TorrentNode(String, i64); - -#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] -pub struct TorrentFile { - pub path: Vec, - pub length: i64, +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +pub struct Torrent { + pub info: TorrentInfoDictionary, // #[serde(default)] - pub md5sum: Option, + pub announce: Option, + #[serde(default)] + pub nodes: Option>, + #[serde(default)] + pub encoding: Option, + #[serde(default)] + pub httpseeds: Option>, + #[serde(default)] + #[serde(rename = "announce-list")] + pub announce_list: Option>>, + #[serde(default)] + #[serde(rename = "creation date")] + pub creation_date: Option, + #[serde(default)] + pub comment: Option, + #[serde(default)] + #[serde(rename = "created by")] + pub created_by: Option, } #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] -pub struct TorrentInfo { +pub struct TorrentNode(String, i64); + +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct TorrentInfoDictionary { pub name: String, #[serde(default)] pub pieces: Option, @@ -43,108 +59,79 @@ pub struct TorrentInfo { pub source: Option, } -impl TorrentInfo { - /// torrent file can only hold a pieces key or a root hash key: - /// [BEP 39](http://www.bittorrent.org/beps/bep_0030.html) - #[must_use] - pub fn get_pieces_as_string(&self) -> String { - match &self.pieces { - None => String::new(), - Some(byte_buf) => from_bytes(byte_buf.as_ref()), - } - } +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct TorrentFile { + pub path: Vec, + pub length: i64, + #[serde(default)] + pub md5sum: Option, +} - /// It returns the root hash as a `i64` value. +impl Torrent { + /// It builds a `Torrent` from a request. /// /// # Panics /// - /// This function will panic if the root hash cannot be converted into a - /// `i64` value. + /// This function will panic if the `torrent_info.pieces` is not a valid hex string. #[must_use] - pub fn get_root_hash_as_i64(&self) -> i64 { - match &self.root_hash { - None => 0i64, - Some(root_hash) => root_hash - .parse::() - .expect("variable `root_hash` cannot be converted into a `i64`"), - } - } + pub fn from_request(create_torrent_req: CreateTorrentRequest) -> Self { + let info_dict = create_torrent_req.build_info_dictionary(); - #[must_use] - pub fn is_a_single_file_torrent(&self) -> bool { - self.length.is_some() - } - - #[must_use] - pub fn is_a_multiple_file_torrent(&self) -> bool { - self.files.is_some() + Self { + info: info_dict, + announce: None, + nodes: None, + encoding: None, + httpseeds: None, + announce_list: Some(create_torrent_req.announce_urls), + creation_date: None, + comment: create_torrent_req.comment, + created_by: None, + } } -} -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] -pub struct Torrent { - pub info: TorrentInfo, // - #[serde(default)] - pub announce: Option, - #[serde(default)] - pub nodes: Option>, - #[serde(default)] - pub encoding: Option, - #[serde(default)] - pub httpseeds: Option>, - #[serde(default)] - #[serde(rename = "announce-list")] - pub announce_list: Option>>, - #[serde(default)] - #[serde(rename = "creation date")] - pub creation_date: Option, - #[serde(default)] - pub comment: Option, - #[serde(default)] - #[serde(rename = "created by")] - pub created_by: Option, -} - -impl Torrent { - /// It builds a `Torrent` from a `NewTorrentInfoRequest`. + /// It hydrates a `Torrent` struct from the database data. /// /// # Panics /// - /// This function will panic if the `torrent_info.pieces` is not a valid hex string. + /// This function will panic if the `torrent_info.pieces` is not a valid + /// hex string. #[must_use] - pub fn from_new_torrent_info_request(torrent_info: NewTorrentInfoRequest) -> Self { - // the info part of the torrent file - let mut info = TorrentInfo { - name: torrent_info.name.to_string(), + pub fn from_database( + db_torrent: DbTorrent, + torrent_files: Vec, + torrent_announce_urls: Vec>, + ) -> Self { + let mut info_dict = TorrentInfoDictionary { + name: db_torrent.name, pieces: None, - piece_length: torrent_info.piece_length, + piece_length: db_torrent.piece_length, md5sum: None, length: None, files: None, - private: torrent_info.private, + private: db_torrent.private, path: None, root_hash: None, source: None, }; // a torrent file has a root hash or a pieces key, but not both. - if torrent_info.root_hash > 0 { - info.root_hash = Some(torrent_info.pieces); + if db_torrent.root_hash > 0 { + info_dict.root_hash = Some(db_torrent.pieces); } else { - let pieces = into_bytes(&torrent_info.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); - info.pieces = Some(ByteBuf::from(pieces)); + let buffer = into_bytes(&db_torrent.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); + info_dict.pieces = Some(ByteBuf::from(buffer)); } // either set the single file or the multiple files information - if torrent_info.files.len() == 1 { - let torrent_file = torrent_info - .files + if torrent_files.len() == 1 { + let torrent_file = torrent_files .first() .expect("vector `torrent_files` should have at least one element"); - info.md5sum = torrent_file.md5sum.clone(); + info_dict.md5sum = torrent_file.md5sum.clone(); - info.length = Some(torrent_file.length); + info_dict.length = Some(torrent_file.length); let path = if torrent_file .path @@ -158,44 +145,24 @@ impl Torrent { Some(torrent_file.path.clone()) }; - info.path = path; + info_dict.path = path; } else { - info.files = Some(torrent_info.files); + info_dict.files = Some(torrent_files); } Self { - info, + info: info_dict, announce: None, nodes: None, encoding: None, httpseeds: None, - announce_list: Some(torrent_info.announce_urls), + announce_list: Some(torrent_announce_urls), creation_date: None, - comment: torrent_info.comment, + comment: db_torrent.comment.clone(), created_by: None, } } - /// It hydrates a `Torrent` struct from the database data. - #[must_use] - pub fn from_db_info_files_and_announce_urls( - torrent_info: DbTorrentInfo, - torrent_files: Vec, - torrent_announce_urls: Vec>, - ) -> Self { - let torrent_info_request = NewTorrentInfoRequest { - name: torrent_info.name, - pieces: torrent_info.pieces, - piece_length: torrent_info.piece_length, - private: torrent_info.private, - root_hash: torrent_info.root_hash, - files: torrent_files, - announce_urls: torrent_announce_urls, - comment: torrent_info.comment, - }; - Torrent::from_new_torrent_info_request(torrent_info_request) - } - /// Sets the announce url to the tracker url and removes all other trackers /// if the torrent is private. pub async fn set_announce_urls(&mut self, cfg: &Configuration) { @@ -278,17 +245,46 @@ impl Torrent { } } -#[allow(clippy::module_name_repetitions)] -#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct DbTorrentFile { - pub path: Option, - pub length: i64, - #[serde(default)] - pub md5sum: Option, +impl TorrentInfoDictionary { + /// torrent file can only hold a pieces key or a root hash key: + /// [BEP 39](http://www.bittorrent.org/beps/bep_0030.html) + #[must_use] + pub fn get_pieces_as_string(&self) -> String { + match &self.pieces { + None => String::new(), + Some(byte_buf) => from_bytes(byte_buf.as_ref()), + } + } + + /// It returns the root hash as a `i64` value. + /// + /// # Panics + /// + /// This function will panic if the root hash cannot be converted into a + /// `i64` value. + #[must_use] + pub fn get_root_hash_as_i64(&self) -> i64 { + match &self.root_hash { + None => 0i64, + Some(root_hash) => root_hash + .parse::() + .expect("variable `root_hash` cannot be converted into a `i64`"), + } + } + + #[must_use] + pub fn is_a_single_file_torrent(&self) -> bool { + self.length.is_some() + } + + #[must_use] + pub fn is_a_multiple_file_torrent(&self) -> bool { + self.files.is_some() + } } #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct DbTorrentInfo { +pub struct DbTorrent { pub torrent_id: i64, pub info_hash: String, pub name: String, @@ -300,6 +296,15 @@ pub struct DbTorrentInfo { pub comment: Option, } +#[allow(clippy::module_name_repetitions)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DbTorrentFile { + pub path: Option, + pub length: i64, + #[serde(default)] + pub md5sum: Option, +} + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct DbTorrentAnnounceUrl { pub tracker_url: String, @@ -312,7 +317,7 @@ mod tests { use serde_bytes::ByteBuf; - use crate::models::torrent_file::{Torrent, TorrentInfo}; + use crate::models::torrent_file::{Torrent, TorrentInfoDictionary}; #[test] fn the_parsed_torrent_file_should_calculated_the_torrent_info_hash() { @@ -349,7 +354,7 @@ mod tests { let sample_data_in_txt_file = "mandelbrot\n"; - let info = TorrentInfo { + let info = TorrentInfoDictionary { name: "sample.txt".to_string(), pieces: Some(ByteBuf::from(vec![ // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex @@ -384,13 +389,13 @@ mod tests { use serde_bytes::ByteBuf; - use crate::models::torrent_file::{Torrent, TorrentFile, TorrentInfo}; + use crate::models::torrent_file::{Torrent, TorrentFile, TorrentInfoDictionary}; #[test] fn a_simple_single_file_torrent() { let sample_data_in_txt_file = "mandelbrot\n"; - let info = TorrentInfo { + let info = TorrentInfoDictionary { name: "sample.txt".to_string(), pieces: Some(ByteBuf::from(vec![ // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex @@ -425,7 +430,7 @@ mod tests { fn a_simple_multi_file_torrent() { let sample_data_in_txt_file = "mandelbrot\n"; - let info = TorrentInfo { + let info = TorrentInfoDictionary { name: "sample".to_string(), pieces: Some(ByteBuf::from(vec![ // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex @@ -464,7 +469,7 @@ mod tests { fn a_simple_single_file_torrent_with_a_source() { let sample_data_in_txt_file = "mandelbrot\n"; - let info = TorrentInfo { + let info = TorrentInfoDictionary { name: "sample.txt".to_string(), pieces: Some(ByteBuf::from(vec![ // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex @@ -499,7 +504,7 @@ mod tests { fn a_simple_single_file_private_torrent() { let sample_data_in_txt_file = "mandelbrot\n"; - let info = TorrentInfo { + let info = TorrentInfoDictionary { name: "sample.txt".to_string(), pieces: Some(ByteBuf::from(vec![ // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 7dce0db1..71c8fb48 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -13,7 +13,7 @@ use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::{DeletedTorrentResponse, TorrentResponse, TorrentsResponse}; use crate::models::torrent::{Metadata, TorrentId, TorrentListing}; -use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; +use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::user::UserId; use crate::tracker::statistics_importer::StatisticsImporter; @@ -649,7 +649,7 @@ impl DbTorrentInfoRepository { /// # Errors /// /// This function will return an error there is a database error. - pub async fn get_by_info_hash(&self, info_hash: &InfoHash) -> Result { + pub async fn get_by_info_hash(&self, info_hash: &InfoHash) -> Result { self.database.get_torrent_info_from_info_hash(info_hash).await } diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index dbfa72f5..3b180ab2 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -1,14 +1,16 @@ //! This module contains the services related to torrent file management. +use serde_bytes::ByteBuf; use uuid::Uuid; -use crate::models::torrent_file::{Torrent, TorrentFile}; +use crate::models::torrent_file::{Torrent, TorrentFile, TorrentInfoDictionary}; use crate::services::hasher::sha1; +use crate::utils::hex::into_bytes; /// It contains the information required to create a new torrent file. /// /// It's not the full in-memory representation of a torrent file. The full /// in-memory representation is the `Torrent` struct. -pub struct NewTorrentInfoRequest { +pub struct CreateTorrentRequest { // The `info` dictionary fields pub name: String, pub pieces: String, @@ -21,6 +23,67 @@ pub struct NewTorrentInfoRequest { pub comment: Option, } +impl CreateTorrentRequest { + /// It builds a `TorrentInfoDictionary` from the current torrent request. + /// + /// # Panics + /// + /// This function will panic if the `pieces` field is not a valid hex string. + #[must_use] + pub fn build_info_dictionary(&self) -> TorrentInfoDictionary { + let mut info_dict = TorrentInfoDictionary { + name: self.name.to_string(), + pieces: None, + piece_length: self.piece_length, + md5sum: None, + length: None, + files: None, + private: self.private, + path: None, + root_hash: None, + source: None, + }; + + // a torrent file has a root hash or a pieces key, but not both. + if self.root_hash > 0 { + info_dict.root_hash = Some(self.pieces.clone()); + } else { + let buffer = into_bytes(&self.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); + info_dict.pieces = Some(ByteBuf::from(buffer)); + } + + // either set the single file or the multiple files information + if self.files.len() == 1 { + let torrent_file = self + .files + .first() + .expect("vector `torrent_files` should have at least one element"); + + info_dict.md5sum = torrent_file.md5sum.clone(); + + info_dict.length = Some(torrent_file.length); + + let path = if torrent_file + .path + .first() + .as_ref() + .expect("the vector for the `path` should have at least one element") + .is_empty() + { + None + } else { + Some(torrent_file.path.clone()) + }; + + info_dict.path = path; + } else { + info_dict.files = Some(self.files.clone()); + } + + info_dict + } +} + /// It generates a random single-file torrent for testing purposes. /// /// The torrent will contain a single text file with the UUID as its content. @@ -43,7 +106,7 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { let torrent_announce_urls: Vec> = vec![]; - let torrent_info_request = NewTorrentInfoRequest { + let torrent_info_request = CreateTorrentRequest { name: format!("file-{id}.txt"), pieces: sha1(&file_contents), piece_length: 16384, @@ -54,7 +117,7 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { comment: None, }; - Torrent::from_new_torrent_info_request(torrent_info_request) + Torrent::from_request(torrent_info_request) } #[cfg(test)] @@ -62,7 +125,7 @@ mod tests { use serde_bytes::ByteBuf; use uuid::Uuid; - use crate::models::torrent_file::{Torrent, TorrentInfo}; + use crate::models::torrent_file::{Torrent, TorrentInfoDictionary}; use crate::services::torrent_file::generate_random_torrent; #[test] @@ -72,7 +135,7 @@ mod tests { let torrent = generate_random_torrent(uuid); let expected_torrent = Torrent { - info: TorrentInfo { + info: TorrentInfoDictionary { name: "file-d6170378-2c14-4ccc-870d-2a8e15195e23.txt".to_string(), pieces: Some(ByteBuf::from(vec![ 62, 231, 243, 51, 234, 165, 204, 209, 51, 132, 163, 133, 249, 50, 107, 46, 24, 15, 251, 32, diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index f0315ff2..f5a0204c 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -7,7 +7,7 @@ use sqlx::{query, query_as, SqlitePool}; use super::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; use crate::databases::database::{self, TABLES_TO_TRUNCATE}; -use crate::models::torrent_file::{TorrentFile, TorrentInfo}; +use crate::models::torrent_file::{TorrentFile, TorrentInfoDictionary}; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct CategoryRecordV2 { @@ -32,7 +32,7 @@ pub struct TorrentRecordV2 { impl TorrentRecordV2 { #[must_use] - pub fn from_v1_data(torrent: &TorrentRecordV1, torrent_info: &TorrentInfo, uploader: &UserRecordV1) -> Self { + pub fn from_v1_data(torrent: &TorrentRecordV1, torrent_info: &TorrentInfoDictionary, uploader: &UserRecordV1) -> Self { Self { torrent_id: torrent.torrent_id, uploader_id: uploader.user_id, From b6fe36b86c386168d42f7e711f034b7baaae9158 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 10:43:06 +0100 Subject: [PATCH 322/357] refactor: [#296] extract duplicate code --- src/databases/database.rs | 4 +- src/models/torrent_file.rs | 127 +++++++++++++++++++++-------------- src/services/torrent_file.rs | 60 +++-------------- 3 files changed, 88 insertions(+), 103 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index e5778649..e947090a 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -209,7 +209,7 @@ pub trait Database: Sync + Send { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_info.torrent_id).await?; - Ok(Torrent::from_database(torrent_info, torrent_files, torrent_announce_urls)) + Ok(Torrent::from_database(&torrent_info, &torrent_files, torrent_announce_urls)) } /// Get `Torrent` from `torrent_id`. @@ -220,7 +220,7 @@ pub trait Database: Sync + Send { let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - Ok(Torrent::from_database(torrent_info, torrent_files, torrent_announce_urls)) + Ok(Torrent::from_database(&torrent_info, &torrent_files, torrent_announce_urls)) } /// It returns the list of all infohashes producing the same canonical diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index ace9f9fa..4a7ade58 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -98,57 +98,18 @@ impl Torrent { /// hex string. #[must_use] pub fn from_database( - db_torrent: DbTorrent, - torrent_files: Vec, + db_torrent: &DbTorrent, + torrent_files: &Vec, torrent_announce_urls: Vec>, ) -> Self { - let mut info_dict = TorrentInfoDictionary { - name: db_torrent.name, - pieces: None, - piece_length: db_torrent.piece_length, - md5sum: None, - length: None, - files: None, - private: db_torrent.private, - path: None, - root_hash: None, - source: None, - }; - - // a torrent file has a root hash or a pieces key, but not both. - if db_torrent.root_hash > 0 { - info_dict.root_hash = Some(db_torrent.pieces); - } else { - let buffer = into_bytes(&db_torrent.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); - info_dict.pieces = Some(ByteBuf::from(buffer)); - } - - // either set the single file or the multiple files information - if torrent_files.len() == 1 { - let torrent_file = torrent_files - .first() - .expect("vector `torrent_files` should have at least one element"); - - info_dict.md5sum = torrent_file.md5sum.clone(); - - info_dict.length = Some(torrent_file.length); - - let path = if torrent_file - .path - .first() - .as_ref() - .expect("the vector for the `path` should have at least one element") - .is_empty() - { - None - } else { - Some(torrent_file.path.clone()) - }; - - info_dict.path = path; - } else { - info_dict.files = Some(torrent_files); - } + let info_dict = TorrentInfoDictionary::with( + &db_torrent.name, + db_torrent.piece_length, + db_torrent.private, + db_torrent.root_hash, + &db_torrent.pieces, + torrent_files, + ); Self { info: info_dict, @@ -246,6 +207,74 @@ impl Torrent { } impl TorrentInfoDictionary { + /// Constructor. + /// + /// # Panics + /// + /// This function will panic if: + /// + /// - The `pieces` field is not a valid hex string. + /// - For single files torrents the `TorrentFile` path is empty. + #[must_use] + pub fn with( + name: &str, + piece_length: i64, + private: Option, + root_hash: i64, + pieces: &str, + files: &Vec, + ) -> Self { + let mut info_dict = Self { + name: name.to_string(), + pieces: None, + piece_length, + md5sum: None, + length: None, + files: None, + private, + path: None, + root_hash: None, + source: None, + }; + + // a torrent file has a root hash or a pieces key, but not both. + if root_hash > 0 { + info_dict.root_hash = Some(pieces.to_owned()); + } else { + let buffer = into_bytes(pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); + info_dict.pieces = Some(ByteBuf::from(buffer)); + } + + // either set the single file or the multiple files information + if files.len() == 1 { + let torrent_file = files + .first() + .expect("vector `torrent_files` should have at least one element"); + + info_dict.md5sum = torrent_file.md5sum.clone(); + + info_dict.length = Some(torrent_file.length); + + let path = if torrent_file + .path + .first() + .as_ref() + .expect("the vector for the `path` should have at least one element") + .is_empty() + { + None + } else { + Some(torrent_file.path.clone()) + }; + + info_dict.path = path; + } else { + info_dict.files = Some(files.clone()); + } + + info_dict + } + /// torrent file can only hold a pieces key or a root hash key: /// [BEP 39](http://www.bittorrent.org/beps/bep_0030.html) #[must_use] diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 3b180ab2..15e414b4 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -1,10 +1,8 @@ //! This module contains the services related to torrent file management. -use serde_bytes::ByteBuf; use uuid::Uuid; use crate::models::torrent_file::{Torrent, TorrentFile, TorrentInfoDictionary}; use crate::services::hasher::sha1; -use crate::utils::hex::into_bytes; /// It contains the information required to create a new torrent file. /// @@ -31,56 +29,14 @@ impl CreateTorrentRequest { /// This function will panic if the `pieces` field is not a valid hex string. #[must_use] pub fn build_info_dictionary(&self) -> TorrentInfoDictionary { - let mut info_dict = TorrentInfoDictionary { - name: self.name.to_string(), - pieces: None, - piece_length: self.piece_length, - md5sum: None, - length: None, - files: None, - private: self.private, - path: None, - root_hash: None, - source: None, - }; - - // a torrent file has a root hash or a pieces key, but not both. - if self.root_hash > 0 { - info_dict.root_hash = Some(self.pieces.clone()); - } else { - let buffer = into_bytes(&self.pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); - info_dict.pieces = Some(ByteBuf::from(buffer)); - } - - // either set the single file or the multiple files information - if self.files.len() == 1 { - let torrent_file = self - .files - .first() - .expect("vector `torrent_files` should have at least one element"); - - info_dict.md5sum = torrent_file.md5sum.clone(); - - info_dict.length = Some(torrent_file.length); - - let path = if torrent_file - .path - .first() - .as_ref() - .expect("the vector for the `path` should have at least one element") - .is_empty() - { - None - } else { - Some(torrent_file.path.clone()) - }; - - info_dict.path = path; - } else { - info_dict.files = Some(self.files.clone()); - } - - info_dict + TorrentInfoDictionary::with( + &self.name, + self.piece_length, + self.private, + self.root_hash, + &self.pieces, + &self.files, + ) } } From 1660fd5a5180c5e9f64be12eaa08919ef152bc1e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 10:44:44 +0100 Subject: [PATCH 323/357] refactor: [#296] rename vars to follow type name. --- src/databases/database.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index e947090a..9205d843 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -203,24 +203,24 @@ pub trait Database: Sync + Send { /// Get `Torrent` from `InfoHash`. async fn get_torrent_from_info_hash(&self, info_hash: &InfoHash) -> Result { - let torrent_info = self.get_torrent_info_from_info_hash(info_hash).await?; + let db_torrent = self.get_torrent_info_from_info_hash(info_hash).await?; - let torrent_files = self.get_torrent_files_from_id(torrent_info.torrent_id).await?; + let torrent_files = self.get_torrent_files_from_id(db_torrent.torrent_id).await?; - let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_info.torrent_id).await?; + let torrent_announce_urls = self.get_torrent_announce_urls_from_id(db_torrent.torrent_id).await?; - Ok(Torrent::from_database(&torrent_info, &torrent_files, torrent_announce_urls)) + Ok(Torrent::from_database(&db_torrent, &torrent_files, torrent_announce_urls)) } /// Get `Torrent` from `torrent_id`. async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { - let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; + let db_torrent = self.get_torrent_info_from_id(torrent_id).await?; let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - Ok(Torrent::from_database(&torrent_info, &torrent_files, torrent_announce_urls)) + Ok(Torrent::from_database(&db_torrent, &torrent_files, torrent_announce_urls)) } /// It returns the list of all infohashes producing the same canonical From dfdac195c8a6160db8256e97894ce687954e678d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 10:54:33 +0100 Subject: [PATCH 324/357] refator: [#296] move logic to service layer The service should know the model but the model should not know the service. The dependency should be only on one way. --- src/models/torrent_file.rs | 23 ----------------------- src/services/torrent_file.rs | 28 +++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 4a7ade58..a7e98bb4 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -5,7 +5,6 @@ use sha1::{Digest, Sha1}; use super::info_hash::InfoHash; use crate::config::Configuration; -use crate::services::torrent_file::CreateTorrentRequest; use crate::utils::hex::{from_bytes, into_bytes}; #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] @@ -68,28 +67,6 @@ pub struct TorrentFile { } impl Torrent { - /// It builds a `Torrent` from a request. - /// - /// # Panics - /// - /// This function will panic if the `torrent_info.pieces` is not a valid hex string. - #[must_use] - pub fn from_request(create_torrent_req: CreateTorrentRequest) -> Self { - let info_dict = create_torrent_req.build_info_dictionary(); - - Self { - info: info_dict, - announce: None, - nodes: None, - encoding: None, - httpseeds: None, - announce_list: Some(create_torrent_req.announce_urls), - creation_date: None, - comment: create_torrent_req.comment, - created_by: None, - } - } - /// It hydrates a `Torrent` struct from the database data. /// /// # Panics diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 15e414b4..1a1dd933 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -22,13 +22,35 @@ pub struct CreateTorrentRequest { } impl CreateTorrentRequest { + /// It builds a `Torrent` from a request. + /// + /// # Panics + /// + /// This function will panic if the `torrent_info.pieces` is not a valid hex string. + #[must_use] + pub fn build_torrent(&self) -> Torrent { + let info_dict = self.build_info_dictionary(); + + Torrent { + info: info_dict, + announce: None, + nodes: None, + encoding: None, + httpseeds: None, + announce_list: Some(self.announce_urls.clone()), + creation_date: None, + comment: self.comment.clone(), + created_by: None, + } + } + /// It builds a `TorrentInfoDictionary` from the current torrent request. /// /// # Panics /// /// This function will panic if the `pieces` field is not a valid hex string. #[must_use] - pub fn build_info_dictionary(&self) -> TorrentInfoDictionary { + fn build_info_dictionary(&self) -> TorrentInfoDictionary { TorrentInfoDictionary::with( &self.name, self.piece_length, @@ -62,7 +84,7 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { let torrent_announce_urls: Vec> = vec![]; - let torrent_info_request = CreateTorrentRequest { + let create_torrent_req = CreateTorrentRequest { name: format!("file-{id}.txt"), pieces: sha1(&file_contents), piece_length: 16384, @@ -73,7 +95,7 @@ pub fn generate_random_torrent(id: Uuid) -> Torrent { comment: None, }; - Torrent::from_request(torrent_info_request) + create_torrent_req.build_torrent() } #[cfg(test)] From 88213461ede54446879486a888cc54c583c997e3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 12:33:25 +0100 Subject: [PATCH 325/357] doc: add some comments for BEP 30 implementation --- src/models/torrent_file.rs | 1 + src/services/torrent_file.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index a7e98bb4..aba633c5 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -216,6 +216,7 @@ impl TorrentInfoDictionary { // a torrent file has a root hash or a pieces key, but not both. if root_hash > 0 { + // If `root_hash` is true the `pieces` field contains the `root hash` info_dict.root_hash = Some(pieces.to_owned()); } else { let buffer = into_bytes(pieces).expect("variable `torrent_info.pieces` is not a valid hex string"); diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 1a1dd933..338ba6e6 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -14,7 +14,7 @@ pub struct CreateTorrentRequest { pub pieces: String, pub piece_length: i64, pub private: Option, - pub root_hash: i64, + pub root_hash: i64, // True (1) if it's a BEP 30 torrent. pub files: Vec, // Other fields of the root level metainfo dictionary pub announce_urls: Vec>, From 378d19b0ebe9484180f980b4dc46de8d8c650355 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 13:58:29 +0100 Subject: [PATCH 326/357] chore: cargo update --- Cargo.lock | 793 +++++++++--------- .../databases/sqlite_v2_0_0.rs | 2 +- 2 files changed, 375 insertions(+), 420 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f61892a..96237746 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,17 +36,17 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2079246596c18b4a33e274ae10c0e50613f4d32a4198e09c7b93771013fed74" +checksum = "a92ef85799cba03f76e4f7c10f533e66d87c9a7e7055f3391f09000ad8351bc9" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", "ahash 0.8.3", - "base64 0.21.2", - "bitflags 1.3.2", + "base64 0.21.4", + "bitflags 2.4.0", "brotli", "bytes", "bytestring", @@ -70,24 +70,24 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "zstd 0.12.3+zstd.1.5.2", + "zstd", ] [[package]] name = "actix-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465a6172cf69b960917811022d8f29bc0b7fa1398bc4f78b3c466673db1213b6" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.37", ] [[package]] name = "actix-multipart" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee489e3c01eae4d1c35b03c4493f71cb40d93f66b14558feb1b1a807671cc4e" +checksum = "3b960e2aea75f49c8f069108063d12a48d329fc8b60b786dfc7552a9d5918d2d" dependencies = [ "actix-multipart-derive", "actix-utils", @@ -110,15 +110,15 @@ dependencies = [ [[package]] name = "actix-multipart-derive" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ec592f234db8a253cf80531246a4407c8a70530423eea80688a6c5a44a110e7" +checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d" dependencies = [ "darling", "parse-size", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.37", ] [[package]] @@ -136,9 +136,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15265b6b8e2347670eb363c47fc8c75208b4a4994b27192f345fcbe707804f3e" +checksum = "28f32d40287d3f402ae0028a9d54bef51af15c8769492826a69d28f81893151d" dependencies = [ "futures-core", "tokio", @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e8613a75dd50cc45f473cee3c34d59ed677c0f7b44480ce3b8247d7dc519327" +checksum = "3eb13e7eef0423ea6eab0e59f6c72e7cb46d33691ad56a726b3cd07ddec2c2d4" dependencies = [ "actix-rt", "actix-service", @@ -156,8 +156,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "num_cpus", - "socket2", + "socket2 0.5.4", "tokio", "tracing", ] @@ -185,9 +184,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.3.1" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3cb42f9566ab176e1ef0b8b3a896529062b4efc6be0123046095914c4c1c96" +checksum = "0e4a5b5e29603ca8c94a77c65cf874718ceb60292c5a5c3e5f4ace041af462b9" dependencies = [ "actix-codec", "actix-http", @@ -198,7 +197,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web-codegen", - "ahash 0.7.6", + "ahash 0.8.3", "bytes", "bytestring", "cfg-if", @@ -207,7 +206,6 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "http", "itoa", "language-tags", "log", @@ -219,28 +217,28 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2", + "socket2 0.5.4", "time", "url", ] [[package]] name = "actix-web-codegen" -version = "4.2.0" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2262160a7ae29e3415554a3f1fc04c764b1540c116aa524683208078b7a75bc9" +checksum = "eb1f50ebbb30eca122b188319a4398b3f7bb4a8cdf50ecfb73bfc6a3c3ce54f5" dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.37", ] [[package]] name = "addr2line" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] @@ -276,9 +274,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.0.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +checksum = "0f2135563fb5c609d2b2b87c1e8ce7bc41b0b45430fa9661f457981503dd5bf0" dependencies = [ "memchr", ] @@ -300,9 +298,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56fc6cf8dc8c4158eed8649f9b8b0ea1518eb62b544fe9490d66fa0b349eafe9" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" [[package]] name = "android-tzdata" @@ -321,12 +319,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95c2fcf79ad1932ac6269a738109997a83c227c09b75842ae564dc8ede6a861c" +checksum = "17ba4cac0a46bc1d2912652a751c47f2a9f3a7fe89bcae2275d418f5270402f9" dependencies = [ "base64ct", "blake2", + "cpufeatures", "password-hash", ] @@ -350,9 +349,9 @@ checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "async-compression" -version = "0.3.15" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a" +checksum = "bb42b2197bf15ccb092b62c74515dbd8b86d0effd934795f6687c93b6e679a2c" dependencies = [ "brotli", "flate2", @@ -360,19 +359,19 @@ dependencies = [ "memchr", "pin-project-lite", "tokio", - "zstd 0.11.2+zstd.1.5.2", - "zstd-safe 5.0.2+zstd.1.5.2", + "zstd", + "zstd-safe", ] [[package]] name = "async-trait" -version = "0.1.69" +version = "0.1.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2d0f03b3640e3a630367e40c468cb7f309529c708ed1d88597047b0e7c6ef7" +checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] @@ -384,17 +383,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -403,9 +391,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "axum" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8175979259124331c1d7bf6586ee7e0da434155e4b2d48ec2c8386281d8df39" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" dependencies = [ "async-trait", "axum-core", @@ -453,9 +441,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.68" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" dependencies = [ "addr2line", "cc", @@ -474,9 +462,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.2" +version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" +checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" [[package]] name = "base64ct" @@ -498,9 +486,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.3" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" [[package]] name = "blake2" @@ -543,15 +531,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" [[package]] name = "bytemuck" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" +checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" [[package]] name = "byteorder" @@ -561,9 +549,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "bytestring" @@ -576,11 +564,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.79" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ "jobserver", + "libc", ] [[package]] @@ -591,25 +580,25 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.26" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "winapi", + "windows-targets", ] [[package]] name = "colored" -version = "2.0.0" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" +checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" dependencies = [ - "atty", + "is-terminal", "lazy_static", - "winapi", + "windows-sys", ] [[package]] @@ -672,9 +661,9 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e69e28e9f7f77debdedbaafa2866e1de9ba56df55a8bd7cfc724c25a09987c" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" dependencies = [ "libc", ] @@ -744,9 +733,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" dependencies = [ "darling_core", "darling_macro", @@ -754,27 +743,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 1.0.109", + "syn 2.0.37", ] [[package]] name = "darling_macro" -version = "0.14.4" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" dependencies = [ "darling_core", "quote", - "syn 1.0.109", + "syn 2.0.37", ] [[package]] @@ -797,6 +786,12 @@ dependencies = [ "pem-rfc7468", ] +[[package]] +name = "deranged" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" + [[package]] name = "derive-new" version = "0.5.9" @@ -846,9 +841,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "email-encoding" @@ -856,7 +851,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" dependencies = [ - "base64 0.21.2", + "base64 0.21.4", "memchr", ] @@ -871,28 +866,28 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071a31f4ee85403370b58aca746f01041ede6f0da2730960ad001edc2b71b394" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" dependencies = [ "cfg-if", ] [[package]] name = "equivalent" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" dependencies = [ "errno-dragonfly", "libc", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -920,6 +915,12 @@ dependencies = [ "instant", ] +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + [[package]] name = "fdeflate" version = "0.3.0" @@ -940,21 +941,27 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.21" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153" +checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.2.16", - "windows-sys 0.48.0", + "redox_syscall 0.3.5", + "windows-sys", ] +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + [[package]] name = "flate2" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010" dependencies = [ "crc32fast", "miniz_oxide", @@ -1086,7 +1093,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] @@ -1142,15 +1149,15 @@ dependencies = [ [[package]] name = "gimli" -version = "0.27.3" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" +checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" [[package]] name = "h2" -version = "0.3.20" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" +checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" dependencies = [ "bytes", "fnv", @@ -1186,9 +1193,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312f66718a2d7789ffef4f4b7b213138ed9f1eb3aa1d0d82fc99f88fb3ffd26f" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" dependencies = [ "hashbrown 0.14.0", ] @@ -1204,18 +1211,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - -[[package]] -name = "hermit-abi" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" [[package]] name = "hex" @@ -1238,7 +1236,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -1276,9 +1274,9 @@ dependencies = [ [[package]] name = "http-range-header" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" [[package]] name = "httparse" @@ -1288,9 +1286,9 @@ checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" [[package]] name = "httpdate" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" @@ -1309,7 +1307,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.4.9", "tokio", "tower-service", "tracing", @@ -1407,37 +1405,37 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.1", - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "ipnet" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + [[package]] name = "itertools" -version = "0.10.5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aa48fab2893d8a49caa94082ae8488f4e1050d73b367881dcd2198f4199fd8" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "itoap" @@ -1486,7 +1484,7 @@ version = "8.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" dependencies = [ - "base64 0.21.2", + "base64 0.21.4", "pem", "ring", "serde", @@ -1525,10 +1523,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d" dependencies = [ "async-trait", - "base64 0.21.2", + "base64 0.21.4", "email-encoding", "email_address", - "fastrand", + "fastrand 1.9.0", "futures-io", "futures-util", "hostname", @@ -1541,7 +1539,7 @@ dependencies = [ "quoted_printable", "rustls", "rustls-pemfile", - "socket2", + "socket2 0.4.9", "tokio", "tokio-native-tls", "tokio-rustls", @@ -1550,9 +1548,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.147" +version = "0.2.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" [[package]] name = "libm" @@ -1579,19 +1577,18 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.3.8" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" [[package]] name = "local-channel" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f303ec0e94c6c54447f84f3b0ef7af769858a9c4ef56ef2a986d3dcd4c3fc9c" +checksum = "e0a493488de5f18c8ffcba89eebb8532ffc562dc400490eb65b84893fae0b178" dependencies = [ "futures-core", "futures-sink", - "futures-util", "local-waker", ] @@ -1613,9 +1610,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "match_cfg" @@ -1631,15 +1628,15 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "matchit" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" +checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" [[package]] name = "memchr" -version = "2.5.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" [[package]] name = "memmap2" @@ -1691,7 +1688,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -1742,9 +1739,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" dependencies = [ "autocfg", "num-integer", @@ -1791,9 +1788,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", "libm", @@ -1805,15 +1802,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.3.1", + "hermit-abi", "libc", ] [[package]] name = "object" -version = "0.31.1" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" dependencies = [ "memchr", ] @@ -1826,11 +1823,11 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "openssl" -version = "0.10.55" +version = "0.10.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" +checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.4.0", "cfg-if", "foreign-types", "libc", @@ -1847,7 +1844,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] @@ -1858,9 +1855,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.90" +version = "0.9.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6" +checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d" dependencies = [ "cc", "libc", @@ -1945,9 +1942,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.12" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" [[package]] name = "pathdiff" @@ -1957,9 +1954,9 @@ checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" [[package]] name = "pbkdf2" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0ca0b5a68607598bf3bad68f32227a8164f6254833f84eafaac409cd6746c31" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", "hmac", @@ -1993,19 +1990,20 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "pest" -version = "2.7.0" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f73935e4d55e2abf7f130186537b19e7a4abc886a0252380b59248af473a3fc9" +checksum = "d7a4d085fd991ac8d5b05a147b437791b4260b76326baf0fc60cf7c9c27ecd33" dependencies = [ + "memchr", "thiserror", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.0" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef623c9bbfa0eedf5a0efba11a5ee83209c326653ca31ff019bec3a95bfff2b" +checksum = "a2bee7be22ce7918f641a33f08e3f43388c7656772244e2bbb2477f44cc9021a" dependencies = [ "pest", "pest_generator", @@ -2013,22 +2011,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.0" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e8cba4ec22bada7fc55ffe51e2deb6a0e0db2d0b7ab0b103acc80d2510c190" +checksum = "d1511785c5e98d79a05e8a6bc34b4ac2168a0e3e92161862030ad84daa223141" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] name = "pest_meta" -version = "2.7.0" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a01f71cb40bd8bb94232df14b946909e14660e33fc05db3e50ae2a82d7ea0ca0" +checksum = "b42f0394d3123e33353ca5e1e89092e533d2cc490389f2bd6131c43c634ebc5f" dependencies = [ "once_cell", "pest", @@ -2043,29 +2041,29 @@ checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468" [[package]] name = "pin-project" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030ad2bc4db10a8944cb0d837f158bdfec4d4a4873ab701a95046770d11f8842" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] name = "pin-project-lite" -version = "0.2.10" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" [[package]] name = "pin-utils" @@ -2103,9 +2101,9 @@ checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" [[package]] name = "png" -version = "0.17.9" +version = "0.17.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59871cc5b6cce7eaccca5a802b4173377a1c2ba90654246789a8fa2334426d11" +checksum = "dd75bf2d8dd3702b9707cdbc56a5b9ef42cec752eb8b3bafc01234558442aa64" dependencies = [ "bitflags 1.3.2", "crc32fast", @@ -2122,18 +2120,18 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.63" +version = "1.0.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" +checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.29" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] @@ -2200,9 +2198,21 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.4" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" +checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" dependencies = [ "aho-corasick", "memchr", @@ -2211,17 +2221,17 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "reqwest" -version = "0.11.18" +version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" +checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1" dependencies = [ - "base64 0.21.2", + "base64 0.21.4", "bytes", "encoding_rs", "futures-core", @@ -2359,27 +2369,26 @@ dependencies = [ [[package]] name = "rustix" -version = "0.37.22" +version = "0.38.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8818fa822adcc98b18fedbb3632a6a33213c070556b5aa7c4c8cc21cff565c4c" +checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.4.0", "errno", - "io-lifetimes", "libc", "linux-raw-sys", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] name = "rustls" -version = "0.21.2" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e32ca28af694bc1bbf399c33a516dbdf1c90090b8ab23c2bc24f834aa2247f5f" +checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.5", "sct", ] @@ -2389,14 +2398,24 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" dependencies = [ - "base64 0.21.2", + "base64 0.21.4", +] + +[[package]] +name = "rustls-webpki" +version = "0.100.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3" +dependencies = [ + "ring", + "untrusted", ] [[package]] name = "rustls-webpki" -version = "0.100.1" +version = "0.101.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +checksum = "45a27e3b59326c16e23d30aeb7a36a24cc0d29e71d68ff611cdfb4a01d013bed" dependencies = [ "ring", "untrusted", @@ -2404,9 +2423,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.12" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" [[package]] name = "rustybuzz" @@ -2426,9 +2445,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.13" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" [[package]] name = "safe_arch" @@ -2463,8 +2482,8 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.23", - "toml 0.7.5", + "syn 2.0.37", + "toml 0.7.8", ] [[package]] @@ -2479,18 +2498,18 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" dependencies = [ - "windows-sys 0.42.0", + "windows-sys", ] [[package]] name = "scopeguard" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sct" @@ -2504,9 +2523,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" dependencies = [ "bitflags 1.3.2", "core-foundation", @@ -2517,9 +2536,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" dependencies = [ "core-foundation-sys", "libc", @@ -2527,15 +2546,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" +checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" [[package]] name = "serde" -version = "1.0.164" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" +checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" dependencies = [ "serde_derive", ] @@ -2552,29 +2571,29 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416bda436f9aab92e02c8e10d49a15ddd339cea90b6e340fe51ed97abb548294" +checksum = "ab33ec92f677585af6d88c65593ae2375adde54efdbf16d597f2cbc7a6d368ff" dependencies = [ "serde", ] [[package]] name = "serde_derive" -version = "1.0.164" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" +checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] name = "serde_json" -version = "1.0.99" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3" +checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" dependencies = [ "itoa", "ryu", @@ -2583,9 +2602,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1b6471d7496b051e03f1958802a73f88b947866f5146f329e47e36554f4e55" +checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335" dependencies = [ "itoa", "serde", @@ -2593,9 +2612,9 @@ dependencies = [ [[package]] name = "serde_plain" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6018081315db179d0ce57b1fe4b62a12a0028c9cf9bbef868c9cf477b3c34ae" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" dependencies = [ "serde", ] @@ -2665,9 +2684,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "238abfbb77c1915110ad968465608b68e869e0772622c9656714e73e5a1a522f" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] name = "simple_asn1" @@ -2692,24 +2711,24 @@ dependencies = [ [[package]] name = "siphasher" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "slab" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "smallvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" [[package]] name = "socket2" @@ -2721,6 +2740,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "socket2" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "spin" version = "0.5.2" @@ -2748,9 +2777,9 @@ dependencies = [ [[package]] name = "sqlformat" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c12bc9199d1db8234678b7051747c07f517cdcf019262d1847b94ec8b1aee3e" +checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" dependencies = [ "itertools", "nom", @@ -2850,10 +2879,11 @@ dependencies = [ [[package]] name = "stringprep" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" dependencies = [ + "finl_unicode", "unicode-bidi", "unicode-normalization", ] @@ -2892,9 +2922,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.23" +version = "2.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fb7d6d8281a51045d62b8eb3a7d1ce347b76f312af50cd3dc0af39c87c1737" +checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8" dependencies = [ "proc-macro2", "quote", @@ -2909,16 +2939,15 @@ checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "tempfile" -version = "3.6.0" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" dependencies = [ - "autocfg", "cfg-if", - "fastrand", + "fastrand 2.0.0", "redox_syscall 0.3.5", "rustix", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -2950,30 +2979,31 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.40" +version = "1.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.40" +version = "1.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] name = "time" -version = "0.3.22" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" +checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" dependencies = [ + "deranged", "itoa", "serde", "time-core", @@ -2988,9 +3018,9 @@ checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" [[package]] name = "time-macros" -version = "0.2.9" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" +checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" dependencies = [ "time-core", ] @@ -3026,11 +3056,10 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.29.1" +version = "1.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" +checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" dependencies = [ - "autocfg", "backtrace", "bytes", "libc", @@ -3039,9 +3068,9 @@ dependencies = [ "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.4", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -3052,7 +3081,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", ] [[package]] @@ -3111,9 +3140,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.7.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebafdf5ad1220cb59e7d17cf4d2c72015297b75b19a10472f99b89225089240" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" dependencies = [ "serde", "serde_spanned", @@ -3132,9 +3161,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.19.11" +version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266f016b7f039eec8a1a80dfe6156b633d208b9fccca5e4db1d6775b0c4e34a7" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.0.0", "serde", @@ -3185,7 +3214,7 @@ dependencies = [ "text-to-png", "thiserror", "tokio", - "toml 0.7.5", + "toml 0.7.8", "tower-http", "urlencoding", "uuid", @@ -3210,12 +3239,12 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.4.1" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8bd22a874a2d0b70452d5597b12c537331d49060824a95f49f108994f94aa4c" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ "async-compression", - "bitflags 2.3.3", + "bitflags 2.4.0", "bytes", "futures-core", "futures-util", @@ -3276,21 +3305,21 @@ checksum = "7ae2f58a822f08abdaf668897e96a5656fe72f5a9ce66422423e8849384872e6" [[package]] name = "typenum" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" [[package]] name = "unicase" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" dependencies = [ "version_check", ] @@ -3321,9 +3350,9 @@ checksum = "07547e3ee45e28326cc23faac56d44f58f16ab23e413db526debce3b0bfd2742" [[package]] name = "unicode-ident" -version = "1.0.9" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-normalization" @@ -3366,9 +3395,9 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "url" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" dependencies = [ "form_urlencoded", "idna 0.4.0", @@ -3377,9 +3406,9 @@ dependencies = [ [[package]] name = "urlencoding" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "usvg" @@ -3410,9 +3439,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d023da39d1fde5a8a3fe1f3e01ca9632ada0a63e9797de55a879d6e2236277be" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ "getrandom", ] @@ -3465,7 +3494,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", "wasm-bindgen-shared", ] @@ -3499,7 +3528,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.23", + "syn 2.0.37", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3526,18 +3555,19 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" dependencies = [ - "rustls-webpki", + "rustls-webpki 0.100.3", ] [[package]] name = "which" -version = "4.4.0" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" dependencies = [ "either", - "libc", + "home", "once_cell", + "rustix", ] [[package]] @@ -3571,21 +3601,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -3597,126 +3612,85 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm 0.48.0", - "windows_aarch64_msvc 0.48.0", - "windows_i686_gnu 0.48.0", - "windows_i686_msvc 0.48.0", - "windows_x86_64_gnu 0.48.0", - "windows_x86_64_gnullvm 0.48.0", - "windows_x86_64_msvc 0.48.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "0.4.7" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0ace3845f0d96209f0375e6d367e3eb87eb65d27d445bdc9f1843a26f39448" +checksum = "7c2e3184b9c4e92ad5167ca73039d0c42476302ab603e2fec4487511f38ccefc" dependencies = [ "memchr", ] [[package]] name = "winreg" -version = "0.10.1" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "winapi", + "cfg-if", + "windows-sys", ] [[package]] name = "xml-rs" -version = "0.8.15" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a56c84a8ccd4258aed21c92f70c0f6dea75356b6892ae27c24139da456f9336" +checksum = "bab77e97b50aee93da431f2cee7cd0f43b4d1da3c408042f2d7d164187774f0a" [[package]] name = "xmlparser" @@ -3747,37 +3721,18 @@ checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" [[package]] name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.12.3+zstd.1.5.2" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76eea132fb024e0e13fd9c2f5d5d595d8a967aa72382ac2f9d39fcc95afd0806" +checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" dependencies = [ - "zstd-safe 6.0.5+zstd.1.5.4", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", + "zstd-safe", ] [[package]] name = "zstd-safe" -version = "6.0.5+zstd.1.5.4" +version = "6.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d9e60b4b1758206c238a10165fbcae3ca37b01744e394c463463f6529d23b" +checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" dependencies = [ "libc", "zstd-sys", diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index f5a0204c..37a06d5e 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -60,7 +60,7 @@ pub fn convert_timestamp_to_datetime(timestamp: i64) -> String { // MySQL uses a DATETIME column and SQLite uses a TEXT column. let naive_datetime = NaiveDateTime::from_timestamp_opt(timestamp, 0).expect("Overflow of i64 seconds, very future!"); - let datetime_again: DateTime = DateTime::from_utc(naive_datetime, Utc); + let datetime_again: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); // Format without timezone datetime_again.format("%Y-%m-%d %H:%M:%S").to_string() From 2cb0ade07cfe00a085d162ef784697c15a4b7f44 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 14:03:55 +0100 Subject: [PATCH 327/357] chore: remove unused dependencies The API was migrated from Axum to ActixWeb but the dependency was not removed. --- Cargo.lock | 370 +---------------------------------------------------- Cargo.toml | 3 - 2 files changed, 2 insertions(+), 371 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96237746..f981ccf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,238 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "actix-codec" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617a8268e3537fe1d8c9ead925fca49ef6400927ee7bc26750e90ecee14ce4b8" -dependencies = [ - "bitflags 1.3.2", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-cors" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b340e9cfa5b08690aae90fb61beb44e9b06f44fe3d0f93781aaa58cfba86245e" -dependencies = [ - "actix-utils", - "actix-web", - "derive_more", - "futures-util", - "log", - "once_cell", - "smallvec", -] - -[[package]] -name = "actix-http" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92ef85799cba03f76e4f7c10f533e66d87c9a7e7055f3391f09000ad8351bc9" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "ahash 0.8.3", - "base64 0.21.4", - "bitflags 2.4.0", - "brotli", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "flate2", - "futures-core", - "h2", - "http", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand", - "sha1", - "smallvec", - "tokio", - "tokio-util", - "tracing", - "zstd", -] - -[[package]] -name = "actix-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" -dependencies = [ - "quote", - "syn 2.0.37", -] - -[[package]] -name = "actix-multipart" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b960e2aea75f49c8f069108063d12a48d329fc8b60b786dfc7552a9d5918d2d" -dependencies = [ - "actix-multipart-derive", - "actix-utils", - "actix-web", - "bytes", - "derive_more", - "futures-core", - "futures-util", - "httparse", - "local-waker", - "log", - "memchr", - "mime", - "serde", - "serde_json", - "serde_plain", - "tempfile", - "tokio", -] - -[[package]] -name = "actix-multipart-derive" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d" -dependencies = [ - "darling", - "parse-size", - "proc-macro2", - "quote", - "syn 2.0.37", -] - -[[package]] -name = "actix-router" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66ff4d247d2b160861fa2866457e85706833527840e4133f8f49aa423a38799" -dependencies = [ - "bytestring", - "http", - "regex", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28f32d40287d3f402ae0028a9d54bef51af15c8769492826a69d28f81893151d" -dependencies = [ - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb13e7eef0423ea6eab0e59f6c72e7cb46d33691ad56a726b3cd07ddec2c2d4" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2 0.5.4", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" -dependencies = [ - "futures-core", - "paste", - "pin-project-lite", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4a5b5e29603ca8c94a77c65cf874718ceb60292c5a5c3e5f4ace041af462b9" -dependencies = [ - "actix-codec", - "actix-http", - "actix-macros", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-utils", - "actix-web-codegen", - "ahash 0.8.3", - "bytes", - "bytestring", - "cfg-if", - "cookie", - "derive_more", - "encoding_rs", - "futures-core", - "futures-util", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2 0.5.4", - "time", - "url", -] - -[[package]] -name = "actix-web-codegen" -version = "4.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1f50ebbb30eca122b188319a4398b3f7bb4a8cdf50ecfb73bfc6a3c3ce54f5" -dependencies = [ - "actix-router", - "proc-macro2", - "quote", - "syn 2.0.37", -] - [[package]] name = "addr2line" version = "0.21.0" @@ -267,7 +35,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" dependencies = [ "cfg-if", - "getrandom", "once_cell", "version_check", ] @@ -553,15 +320,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" -[[package]] -name = "bytestring" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "238e4886760d98c4f899360c834fa93e62cf7f721ac3c2da375cbdf4b8679aae" -dependencies = [ - "bytes", -] - [[package]] name = "cc" version = "1.0.83" @@ -632,17 +390,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" -[[package]] -name = "cookie" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "core-foundation" version = "0.9.3" @@ -731,41 +478,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "darling" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.37", -] - -[[package]] -name = "darling_macro" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.37", -] - [[package]] name = "data-url" version = "0.1.1" @@ -1076,7 +788,7 @@ checksum = "a604f7a68fbf8103337523b1fadc8ade7361ee3f112f7c680ad179651616aed5" dependencies = [ "futures-core", "lock_api", - "parking_lot 0.11.2", + "parking_lot", ] [[package]] @@ -1350,12 +1062,6 @@ dependencies = [ "cc", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "0.3.0" @@ -1501,12 +1207,6 @@ dependencies = [ "arrayvec 0.7.4", ] -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - [[package]] name = "lazy_static" version = "1.4.0" @@ -1581,23 +1281,6 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" -[[package]] -name = "local-channel" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a493488de5f18c8ffcba89eebb8532ffc562dc400490eb65b84893fae0b178" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - -[[package]] -name = "local-waker" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34f76eb3611940e0e7d53a9aaa4e6a3151f69541a282fd0dad5571420c53ff1" - [[package]] name = "lock_api" version = "0.4.10" @@ -1686,7 +1369,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", - "log", "wasi", "windows-sys", ] @@ -1883,17 +1565,7 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core 0.8.6", -] - -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core 0.9.8", + "parking_lot_core", ] [[package]] @@ -1910,25 +1582,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "parking_lot_core" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.3.5", - "smallvec", - "windows-targets", -] - -[[package]] -name = "parse-size" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae" - [[package]] name = "password-hash" version = "0.5.0" @@ -2610,15 +2263,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_plain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "0.6.3" @@ -2888,12 +2532,6 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "subtle" version = "2.5.0" @@ -3065,7 +2703,6 @@ dependencies = [ "libc", "mio", "num_cpus", - "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2 0.5.4", @@ -3176,9 +2813,6 @@ dependencies = [ name = "torrust-index-backend" version = "2.0.0-alpha.3" dependencies = [ - "actix-cors", - "actix-multipart", - "actix-web", "argon2", "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index e2214518..c632a817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,6 @@ default-run = "main" opt-level = 3 [dependencies] -actix-web = "4.3" -actix-multipart = "0.6" -actix-cors = "0.6" async-trait = "0.1" futures = "0.3" sqlx = { version = "0.6", features = [ From 7d50a171cfef486a1a9a3516d49a10af9b8b71a6 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 15:49:48 +0100 Subject: [PATCH 328/357] refactor: reorganize test mods for torrent contract --- .../web/api/v1/contexts/torrent/contract.rs | 232 +++++++++--------- 1 file changed, 121 insertions(+), 111 deletions(-) diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 3e577b37..e592ab76 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -451,154 +451,164 @@ mod for_authenticated_users { use torrust_index_backend::utils::parse_torrent::decode_torrent; use torrust_index_backend::web::api; - use uuid::Uuid; - use crate::common::asserts::assert_json_error_response; use crate::common::client::Client; - use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent}; - use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; - use crate::common::contexts::torrent::responses::UploadedTorrentResponse; use crate::e2e::environment::TestEnv; use crate::e2e::web::api::v1::contexts::torrent::asserts::{build_announce_url, get_user_tracker_key}; use crate::e2e::web::api::v1::contexts::torrent::steps::upload_random_torrent_to_index; use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; - #[tokio::test] - async fn it_should_allow_authenticated_users_to_upload_new_torrents() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; + mod uploading_a_torrent { - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; - } + use torrust_index_backend::web::api; + use uuid::Uuid; - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + use crate::common::asserts::assert_json_error_response; + use crate::common::client::Client; + use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent}; + use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; + use crate::common::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; - let test_torrent = random_torrent(); - let info_hash = test_torrent.file_info_hash().clone(); + #[tokio::test] + async fn it_should_allow_authenticated_users_to_upload_new_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; - let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } - let response = client.upload_torrent(form.into()).await; + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + let test_torrent = random_torrent(); + let info_hash = test_torrent.file_info_hash().clone(); - assert_eq!( - uploaded_torrent_response.data.info_hash.to_lowercase(), - info_hash.to_lowercase() - ); - assert!(response.is_json_and_ok()); - } + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; + let response = client.upload_torrent(form.into()).await; - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + let uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); - let mut test_torrent = random_torrent(); + assert_eq!( + uploaded_torrent_response.data.info_hash.to_lowercase(), + info_hash.to_lowercase() + ); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; - test_torrent.index_info.category = "non-existing-category".to_string(); + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + let mut test_torrent = random_torrent(); - let response = client.upload_torrent(form.into()).await; + test_torrent.index_info.category = "non-existing-category".to_string(); - assert_eq!(response.status, 400); - } + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; + let response = client.upload_torrent(form.into()).await; - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; + assert_eq!(response.status, 400); } - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; - // Upload the first torrent - let first_torrent = random_torrent(); - let first_torrent_title = first_torrent.index_info.title.clone(); - let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; - - // Upload the second torrent with the same title as the first one - let mut second_torrent = random_torrent(); - second_torrent.index_info.title = first_torrent_title; - let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); - let response = client.upload_torrent(form.into()).await; - - assert_json_error_response(&response, "This torrent title has already been used."); - } + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; + // Upload the first torrent + let first_torrent = random_torrent(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same title as the first one + let mut second_torrent = random_torrent(); + second_torrent.index_info.title = first_torrent_title; + let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_json_error_response(&response, "This torrent title has already been used."); } - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_with_a_info_hash_that_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; - // Upload the first torrent - let first_torrent = random_torrent(); - let mut first_torrent_clone = first_torrent.clone(); - let first_torrent_title = first_torrent.index_info.title.clone(); - let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; - - // Upload the second torrent with the same info-hash as the first one. - // We need to change the title otherwise the torrent will be rejected - // because of the duplicate title. - first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); - let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); - let response = client.upload_torrent(form.into()).await; - - assert_eq!(response.status, 400); - } + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } - #[tokio::test] - async fn it_should_not_allow_uploading_a_torrent_whose_canonical_info_hash_already_exists() { - let mut env = TestEnv::new(); - env.start(api::Version::V1).await; + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); - if !env.provides_a_tracker() { - println!("test skipped. It requires a tracker to be running."); - return; + // Upload the first torrent + let first_torrent = random_torrent(); + let mut first_torrent_clone = first_torrent.clone(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same info-hash as the first one. + // We need to change the title otherwise the torrent will be rejected + // because of the duplicate title. + first_torrent_clone.index_info.title = format!("{first_torrent_title}-clone"); + let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); } - let uploader = new_logged_in_user(&env).await; - let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + #[tokio::test] + async fn it_should_not_allow_uploading_a_torrent_whose_canonical_info_hash_already_exists() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; - let id1 = Uuid::new_v4(); + if !env.provides_a_tracker() { + println!("test skipped. It requires a tracker to be running."); + return; + } - // Upload the first torrent - let first_torrent = TestTorrent::with_custom_info_dict_field(id1, "data", "custom 01"); - let first_torrent_title = first_torrent.index_info.title.clone(); - let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; - - // Upload the second torrent with the same canonical info-hash as the first one. - // We need to change the title otherwise the torrent will be rejected - // because of the duplicate title. - let mut torrent_with_the_same_canonical_info_hash = TestTorrent::with_custom_info_dict_field(id1, "data", "custom 02"); - torrent_with_the_same_canonical_info_hash.index_info.title = format!("{first_torrent_title}-clone"); - let form: UploadTorrentMultipartForm = torrent_with_the_same_canonical_info_hash.index_info.into(); - let response = client.upload_torrent(form.into()).await; - - assert_eq!(response.status, 400); + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let id1 = Uuid::new_v4(); + + // Upload the first torrent + let first_torrent = TestTorrent::with_custom_info_dict_field(id1, "data", "custom 01"); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same canonical info-hash as the first one. + // We need to change the title otherwise the torrent will be rejected + // because of the duplicate title. + let mut torrent_with_the_same_canonical_info_hash = + TestTorrent::with_custom_info_dict_field(id1, "data", "custom 02"); + torrent_with_the_same_canonical_info_hash.index_info.title = format!("{first_torrent_title}-clone"); + let form: UploadTorrentMultipartForm = torrent_with_the_same_canonical_info_hash.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } } #[tokio::test] From a302c227fe585469fb39e9c92243809eba98dcf0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 19 Sep 2023 17:03:41 +0100 Subject: [PATCH 329/357] test: [#276] add more tests for torrent upload --- tests/common/client.rs | 4 +- tests/common/contexts/torrent/fixtures.rs | 2 + .../web/api/v1/contexts/torrent/contract.rs | 162 +++++++++++++++++- 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/tests/common/client.rs b/tests/common/client.rs index 05ffa17b..97216bfa 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -253,7 +253,7 @@ impl Http { .bearer_auth(token) .send() .await - .unwrap(), + .expect("failed to send multipart request with token"), None => reqwest::Client::builder() .build() .unwrap() @@ -261,7 +261,7 @@ impl Http { .multipart(form) .send() .await - .unwrap(), + .expect("failed to send multipart request without token"), }; TextResponse::from(response).await } diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index 3ee8c84a..223f8419 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -18,10 +18,12 @@ use crate::common::contexts::category::fixtures::software_category_name; /// Information about a torrent that is going to be added to the index. #[derive(Clone)] pub struct TorrentIndexInfo { + // Metadata pub title: String, pub description: String, pub category: String, pub tags: Option>, + // Other fields pub torrent_file: BinaryFile, pub name: String, } diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index e592ab76..a300cbc1 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -23,7 +23,8 @@ mod for_guests { use crate::common::client::Client; use crate::common::contexts::category::fixtures::software_predefined_category_id; use crate::common::contexts::torrent::asserts::assert_expected_torrent_details; - use crate::common::contexts::torrent::fixtures::TestTorrent; + use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent}; + use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; use crate::common::contexts::torrent::requests::InfoHash; use crate::common::contexts::torrent::responses::{ Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse, @@ -426,6 +427,22 @@ mod for_guests { assert_eq!(response.status, 404); } + #[tokio::test] + async fn it_should_not_allow_guests_to_upload_torrents() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let test_torrent = random_torrent(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 401); + } + #[tokio::test] async fn it_should_not_allow_guests_to_delete_torrents() { let mut env = TestEnv::new(); @@ -500,6 +517,149 @@ mod for_authenticated_users { assert!(response.is_json_and_ok()); } + mod it_should_guard_that_torrent_metadata { + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::fixtures::random_torrent; + use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; + + #[tokio::test] + async fn contains_a_non_empty_category_name() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + test_torrent.index_info.category = String::new(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn contains_a_non_empty_title() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + test_torrent.index_info.title = String::new(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn title_has_at_least_3_chars() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + test_torrent.index_info.title = "12".to_string(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + } + + mod it_should_guard_that_the_torrent_file { + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::torrent::fixtures::random_torrent; + use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm; + use crate::e2e::environment::TestEnv; + use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; + + #[tokio::test] + #[should_panic] + async fn contains_a_bencoded_dictionary_with_the_info_key_in_order_to_calculate_the_original_info_hash() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + // Make the random torrent invalid by changing the bytes of the torrent file + let minimal_bencoded_value = b"de".to_vec(); + test_torrent.index_info.torrent_file.contents = minimal_bencoded_value; + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let _response = client.upload_torrent(form.into()).await; + } + + #[tokio::test] + async fn contains_a_valid_metainfo_file() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + // Make the random torrent invalid by changing the bytes of the torrent file. + // It's a valid bencoded format but an invalid torrent. It contains + // a `info` otherwise the test to validate the `info` key would fail. + // cspell:disable-next-line + let minimal_bencoded_value_with_info_key = b"d4:infod6:custom6:customee".to_vec(); + test_torrent.index_info.torrent_file.contents = minimal_bencoded_value_with_info_key; + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + async fn pieces_key_has_a_length_that_is_a_multiple_of_20() { + let mut env = TestEnv::new(); + env.start(api::Version::V1).await; + + let uploader = new_logged_in_user(&env).await; + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token); + + let mut test_torrent = random_torrent(); + + // cspell:disable-next-line + let torrent_with_19_pieces = b"d4:infod6:lengthi2e4:name42:torrent-with-invalid-pieces-key-length.txt12:piece lengthi16384e6:pieces19:\x3F\x78\x68\x50\xE3\x87\x55\x0F\xDA\xB8\x36\xED\x7E\x6D\xC8\x81\xDE\x23\x00ee"; + test_torrent.index_info.torrent_file.contents = torrent_with_19_pieces.to_vec(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + } + #[tokio::test] async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { let mut env = TestEnv::new(); From ca6e97cdcef954100d3288403493a6f19f36c4da Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 13:31:42 +0100 Subject: [PATCH 330/357] refactor: [#276] move metadata format validation to Metadata struct And other minor changes. --- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/errors.rs | 13 ++++ src/models/torrent.rs | 62 ++++++++++++++-- src/models/torrent_file.rs | 18 ++--- src/services/torrent.rs | 73 +++++++++++-------- src/utils/parse_torrent.rs | 2 +- src/web/api/v1/contexts/torrent/handlers.rs | 12 ++- .../web/api/v1/contexts/torrent/contract.rs | 5 +- 9 files changed, 134 insertions(+), 55 deletions(-) diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index f793e5cb..f7dff1ff 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -436,7 +436,7 @@ impl Database for Mysql { title: &str, description: &str, ) -> Result { - let info_hash = torrent.info_hash_hex(); + let info_hash = torrent.canonical_info_hash_hex(); let canonical_info_hash = torrent.canonical_info_hash(); // open pool connection diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 980e7a3b..ec8d2c01 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -426,7 +426,7 @@ impl Database for Sqlite { title: &str, description: &str, ) -> Result { - let info_hash = torrent.info_hash_hex(); + let info_hash = torrent.canonical_info_hash_hex(); let canonical_info_hash = torrent.canonical_info_hash(); // open pool connection diff --git a/src/errors.rs b/src/errors.rs index 25ea007d..29eb12f0 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,6 +5,7 @@ use derive_more::{Display, Error}; use hyper::StatusCode; use crate::databases::database; +use crate::models::torrent::MetadataError; pub type ServiceResult = Result; @@ -204,6 +205,18 @@ impl From for ServiceError { } } +impl From for ServiceError { + fn from(e: MetadataError) -> Self { + eprintln!("{e}"); + match e { + MetadataError::MissingTorrentTitle | MetadataError::MissingTorrentCategoryName => { + ServiceError::MissingMandatoryMetadataFields + } + MetadataError::InvalidTorrentTitleLength => ServiceError::InvalidTorrentTitleLength, + } + } +} + #[must_use] pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { #[allow(clippy::match_same_arms)] diff --git a/src/models/torrent.rs b/src/models/torrent.rs index 150d2bba..b86eb519 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -1,7 +1,9 @@ +use derive_more::{Display, Error}; use serde::{Deserialize, Serialize}; use super::torrent_tag::TagId; -use crate::errors::ServiceError; + +const MIN_TORRENT_TITLE_LENGTH: usize = 3; #[allow(clippy::module_name_repetitions)] pub type TorrentId = i64; @@ -24,6 +26,18 @@ pub struct TorrentListing { pub comment: Option, } +#[derive(Debug, Display, PartialEq, Eq, Error)] +pub enum MetadataError { + #[display(fmt = "Missing mandatory torrent title")] + MissingTorrentTitle, + + #[display(fmt = "Missing mandatory torrent category name")] + MissingTorrentCategoryName, + + #[display(fmt = "Torrent title is too short.")] + InvalidTorrentTitleLength, +} + #[derive(Debug, Deserialize)] pub struct Metadata { pub title: String, @@ -33,16 +47,48 @@ pub struct Metadata { } impl Metadata { - /// Returns the verify of this [`Metadata`]. + /// Create a new struct. + /// + /// # Errors + /// + /// This function will return an error if the metadata fields do not have a + /// valid format. + pub fn new(title: &str, description: &str, category: &str, tag_ids: &[TagId]) -> Result { + Self::validate_format(title, description, category, tag_ids)?; + + Ok(Self { + title: title.to_owned(), + description: description.to_owned(), + category: category.to_owned(), + tags: tag_ids.to_vec(), + }) + } + + /// It validates the format of the metadata fields. + /// + /// It does not validate domain rules, like: + /// + /// - Duplicate titles. + /// - Non-existing categories. + /// - ... /// /// # Errors /// - /// This function will return an error if the any of the mandatory metadata fields are missing. - pub fn verify(&self) -> Result<(), ServiceError> { - if self.title.is_empty() || self.category.is_empty() { - Err(ServiceError::MissingMandatoryMetadataFields) - } else { - Ok(()) + /// This function will return an error if any of the metadata fields does + /// not have a valid format. + fn validate_format(title: &str, _description: &str, category: &str, _tag_ids: &[TagId]) -> Result<(), MetadataError> { + if title.is_empty() { + return Err(MetadataError::MissingTorrentTitle); } + + if category.is_empty() { + return Err(MetadataError::MissingTorrentCategoryName); + } + + if title.len() < MIN_TORRENT_TITLE_LENGTH { + return Err(MetadataError::InvalidTorrentTitleLength); + } + + Ok(()) } } diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index aba633c5..7045c147 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -133,13 +133,13 @@ impl Torrent { } #[must_use] - pub fn info_hash_hex(&self) -> String { - from_bytes(&self.calculate_info_hash_as_bytes()).to_lowercase() + pub fn canonical_info_hash(&self) -> InfoHash { + self.calculate_info_hash_as_bytes().into() } #[must_use] - pub fn canonical_info_hash(&self) -> InfoHash { - self.calculate_info_hash_as_bytes().into() + pub fn canonical_info_hash_hex(&self) -> String { + self.canonical_info_hash().to_hex_string() } #[must_use] @@ -389,7 +389,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + assert_eq!(torrent.canonical_info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); } mod infohash_should_be_calculated_for { @@ -430,7 +430,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + assert_eq!(torrent.canonical_info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); } #[test] @@ -469,7 +469,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash_hex(), "aa2aca91ab650c4d249c475ca3fa604f2ccb0d2a"); + assert_eq!(torrent.canonical_info_hash_hex(), "aa2aca91ab650c4d249c475ca3fa604f2ccb0d2a"); } #[test] @@ -504,7 +504,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash_hex(), "ccc1cf4feb59f3fa85c96c9be1ebbafcfe8a9cc8"); + assert_eq!(torrent.canonical_info_hash_hex(), "ccc1cf4feb59f3fa85c96c9be1ebbafcfe8a9cc8"); } #[test] @@ -539,7 +539,7 @@ mod tests { httpseeds: None, }; - assert_eq!(torrent.info_hash_hex(), "d3a558d0a19aaa23ba6f9f430f40924d10fefa86"); + assert_eq!(torrent.canonical_info_hash_hex(), "d3a558d0a19aaa23ba6f9f430f40924d10fefa86"); } } } diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 71c8fb48..f6864c06 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -20,8 +20,6 @@ use crate::tracker::statistics_importer::StatisticsImporter; use crate::utils::parse_torrent; use crate::{tracker, AsCSV}; -const MIN_TORRENT_TITLE_LENGTH: usize = 3; - pub struct Index { configuration: Arc, tracker_statistics_importer: Arc, @@ -128,22 +126,34 @@ impl Index { /// * Unable to parse the torrent info-hash. pub async fn add_torrent( &self, - add_torrent_form: AddTorrentRequest, + add_torrent_req: AddTorrentRequest, user_id: UserId, ) -> Result { - let metadata = Metadata { - title: add_torrent_form.title, - description: add_torrent_form.description, - category: add_torrent_form.category, - tags: add_torrent_form.tags, - }; + // Authorization: only authenticated users ere allowed to upload torrents + + let _user = self.user_repository.get_compact(&user_id).await?; + + // Validate and build metadata + + let metadata = Metadata::new( + &add_torrent_req.title, + &add_torrent_req.description, + &add_torrent_req.category, + &add_torrent_req.tags, + )?; + + let category = self + .category_repository + .get_by_name(&metadata.category) + .await + .map_err(|_| ServiceError::InvalidCategory)?; - metadata.verify()?; + // Validate and build torrent file - let original_info_hash = parse_torrent::calculate_info_hash(&add_torrent_form.torrent_buffer); + let original_info_hash = parse_torrent::calculate_info_hash(&add_torrent_req.torrent_buffer); let mut torrent = - parse_torrent::decode_torrent(&add_torrent_form.torrent_buffer).map_err(|_| ServiceError::InvalidTorrentFile)?; + parse_torrent::decode_torrent(&add_torrent_req.torrent_buffer).map_err(|_| ServiceError::InvalidTorrentFile)?; // Make sure that the pieces key has a length that is a multiple of 20 if let Some(pieces) = torrent.info.pieces.as_ref() { @@ -152,22 +162,12 @@ impl Index { } } - let _user = self.user_repository.get_compact(&user_id).await?; - torrent.set_announce_urls(&self.configuration).await; - if metadata.title.len() < MIN_TORRENT_TITLE_LENGTH { - return Err(ServiceError::InvalidTorrentTitleLength); - } - - let category = self - .category_repository - .get_by_name(&metadata.category) - .await - .map_err(|_| ServiceError::InvalidCategory)?; - let canonical_info_hash = torrent.canonical_info_hash(); + // Canonical InfoHash Group checks + let original_info_hashes = self .torrent_info_hash_repository .get_canonical_info_hash_group(&canonical_info_hash) @@ -194,35 +194,44 @@ impl Index { return Err(ServiceError::CanonicalInfoHashAlreadyExists); } - // First time a torrent with this original infohash is uploaded. + // Store the torrent into the database let torrent_id = self .torrent_repository .add(&original_info_hash, &torrent, &metadata, user_id, category) .await?; - let info_hash = torrent.canonical_info_hash(); + + self.torrent_tag_repository + .link_torrent_to_tags(&torrent_id, &metadata.tags) + .await?; + + // Secondary task: import torrent statistics from the tracker drop( self.tracker_statistics_importer - .import_torrent_statistics(torrent_id, &torrent.info_hash_hex()) + .import_torrent_statistics(torrent_id, &torrent.canonical_info_hash_hex()) .await, ); + // Secondary task: whitelist torrent on the tracker + // We always whitelist the torrent on the tracker because even if the tracker mode is `public` // it could be changed to `private` later on. - if let Err(e) = self.tracker_service.whitelist_info_hash(torrent.info_hash_hex()).await { + if let Err(e) = self + .tracker_service + .whitelist_info_hash(torrent.canonical_info_hash_hex()) + .await + { // If the torrent can't be whitelisted somehow, remove the torrent from database drop(self.torrent_repository.delete(&torrent_id).await); return Err(e); } - self.torrent_tag_repository - .link_torrent_to_tags(&torrent_id, &metadata.tags) - .await?; + // Build response Ok(AddTorrentResponse { torrent_id, - info_hash: info_hash.to_string(), + info_hash: torrent.canonical_info_hash_hex(), original_info_hash: original_info_hash.to_string(), }) } diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 21a219d5..5644cd8d 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -99,7 +99,7 @@ mod tests { // The infohash is not the original infohash of the torrent file, // but the infohash of the info dictionary without the custom keys. assert_eq!( - torrent.info_hash_hex(), + torrent.canonical_info_hash_hex(), "8aa01a4c816332045ffec83247ccbc654547fedf".to_string() ); } diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 6c0754f0..9ddf345d 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -100,7 +100,11 @@ pub async fn download_torrent_handler( return ServiceError::InternalServerError.into_response(); }; - torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash_hex()) + torrent_file_response( + bytes, + &format!("{}.torrent", torrent.info.name), + &torrent.canonical_info_hash_hex(), + ) } } @@ -307,7 +311,11 @@ pub async fn create_random_torrent_handler(State(_app_data): State> return ServiceError::InternalServerError.into_response(); }; - torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name), &torrent.info_hash_hex()) + torrent_file_response( + bytes, + &format!("{}.torrent", torrent.info.name), + &torrent.canonical_info_hash_hex(), + ) } /// Extracts the [`TorrentRequest`] from the multipart form payload. diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index a300cbc1..5bd59d2f 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -396,7 +396,10 @@ mod for_guests { // The returned torrent info-hash should be the same as the first torrent assert_eq!(response.status, 200); - assert_eq!(torrent.info_hash_hex(), first_torrent_canonical_info_hash.to_hex_string()); + assert_eq!( + torrent.canonical_info_hash_hex(), + first_torrent_canonical_info_hash.to_hex_string() + ); } #[tokio::test] From a46d3007f08342196c55b7738ae759da5dbfeb22 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 14:10:37 +0100 Subject: [PATCH 331/357] refactor: [#276] extract function Torrent::validate_and_build_metadata --- src/databases/database.rs | 2 +- src/databases/mysql.rs | 2 +- src/databases/sqlite.rs | 2 +- src/errors.rs | 4 +- src/models/torrent.rs | 25 ++++++----- src/services/torrent.rs | 47 ++++++++++++--------- src/web/api/v1/contexts/torrent/handlers.rs | 2 +- 7 files changed, 44 insertions(+), 40 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 9205d843..462e722b 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -196,7 +196,7 @@ pub trait Database: Sync + Send { original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, - category_id: i64, + category_id: CategoryId, title: &str, description: &str, ) -> Result; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index f7dff1ff..9eaef8a7 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -432,7 +432,7 @@ impl Database for Mysql { original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, - category_id: i64, + category_id: CategoryId, title: &str, description: &str, ) -> Result { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index ec8d2c01..43d475ca 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -422,7 +422,7 @@ impl Database for Sqlite { original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, - category_id: i64, + category_id: CategoryId, title: &str, description: &str, ) -> Result { diff --git a/src/errors.rs b/src/errors.rs index 29eb12f0..e8ef6712 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -209,9 +209,7 @@ impl From for ServiceError { fn from(e: MetadataError) -> Self { eprintln!("{e}"); match e { - MetadataError::MissingTorrentTitle | MetadataError::MissingTorrentCategoryName => { - ServiceError::MissingMandatoryMetadataFields - } + MetadataError::MissingTorrentTitle => ServiceError::MissingMandatoryMetadataFields, MetadataError::InvalidTorrentTitleLength => ServiceError::InvalidTorrentTitleLength, } } diff --git a/src/models/torrent.rs b/src/models/torrent.rs index b86eb519..1c2d10cc 100644 --- a/src/models/torrent.rs +++ b/src/models/torrent.rs @@ -1,6 +1,7 @@ use derive_more::{Display, Error}; use serde::{Deserialize, Serialize}; +use super::category::CategoryId; use super::torrent_tag::TagId; const MIN_TORRENT_TITLE_LENGTH: usize = 3; @@ -28,12 +29,9 @@ pub struct TorrentListing { #[derive(Debug, Display, PartialEq, Eq, Error)] pub enum MetadataError { - #[display(fmt = "Missing mandatory torrent title")] + #[display(fmt = "Missing mandatory torrent title.")] MissingTorrentTitle, - #[display(fmt = "Missing mandatory torrent category name")] - MissingTorrentCategoryName, - #[display(fmt = "Torrent title is too short.")] InvalidTorrentTitleLength, } @@ -42,7 +40,7 @@ pub enum MetadataError { pub struct Metadata { pub title: String, pub description: String, - pub category: String, + pub category_id: CategoryId, pub tags: Vec, } @@ -53,13 +51,13 @@ impl Metadata { /// /// This function will return an error if the metadata fields do not have a /// valid format. - pub fn new(title: &str, description: &str, category: &str, tag_ids: &[TagId]) -> Result { - Self::validate_format(title, description, category, tag_ids)?; + pub fn new(title: &str, description: &str, category_id: CategoryId, tag_ids: &[TagId]) -> Result { + Self::validate_format(title, description, category_id, tag_ids)?; Ok(Self { title: title.to_owned(), description: description.to_owned(), - category: category.to_owned(), + category_id, tags: tag_ids.to_vec(), }) } @@ -76,15 +74,16 @@ impl Metadata { /// /// This function will return an error if any of the metadata fields does /// not have a valid format. - fn validate_format(title: &str, _description: &str, category: &str, _tag_ids: &[TagId]) -> Result<(), MetadataError> { + fn validate_format( + title: &str, + _description: &str, + _category_id: CategoryId, + _tag_ids: &[TagId], + ) -> Result<(), MetadataError> { if title.is_empty() { return Err(MetadataError::MissingTorrentTitle); } - if category.is_empty() { - return Err(MetadataError::MissingTorrentCategoryName); - } - if title.len() < MIN_TORRENT_TITLE_LENGTH { return Err(MetadataError::InvalidTorrentTitleLength); } diff --git a/src/services/torrent.rs b/src/services/torrent.rs index f6864c06..7ae61848 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -7,7 +7,7 @@ use serde_derive::{Deserialize, Serialize}; use super::category::DbCategoryRepository; use super::user::DbUserRepository; use crate::config::Configuration; -use crate::databases::database::{Category, Database, Error, Sorting}; +use crate::databases::database::{Database, Error, Sorting}; use crate::errors::ServiceError; use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; @@ -38,7 +38,7 @@ pub struct Index { pub struct AddTorrentRequest { pub title: String, pub description: String, - pub category: String, + pub category_name: String, pub tags: Vec, pub torrent_buffer: Vec, } @@ -130,23 +130,9 @@ impl Index { user_id: UserId, ) -> Result { // Authorization: only authenticated users ere allowed to upload torrents - let _user = self.user_repository.get_compact(&user_id).await?; - // Validate and build metadata - - let metadata = Metadata::new( - &add_torrent_req.title, - &add_torrent_req.description, - &add_torrent_req.category, - &add_torrent_req.tags, - )?; - - let category = self - .category_repository - .get_by_name(&metadata.category) - .await - .map_err(|_| ServiceError::InvalidCategory)?; + let metadata = self.validate_and_build_metadata(&add_torrent_req).await?; // Validate and build torrent file @@ -198,7 +184,7 @@ impl Index { let torrent_id = self .torrent_repository - .add(&original_info_hash, &torrent, &metadata, user_id, category) + .add(&original_info_hash, &torrent, &metadata, user_id, metadata.category_id) .await?; self.torrent_tag_repository @@ -236,6 +222,27 @@ impl Index { }) } + async fn validate_and_build_metadata(&self, add_torrent_req: &AddTorrentRequest) -> Result { + if add_torrent_req.category_name.is_empty() { + return Err(ServiceError::MissingMandatoryMetadataFields); + } + + let category = self + .category_repository + .get_by_name(&add_torrent_req.category_name) + .await + .map_err(|_| ServiceError::InvalidCategory)?; + + let metadata = Metadata::new( + &add_torrent_req.title, + &add_torrent_req.description, + category.category_id, + &add_torrent_req.tags, + )?; + + Ok(metadata) + } + /// Gets a torrent from the Index. /// /// # Errors @@ -533,14 +540,14 @@ impl DbTorrentRepository { torrent: &Torrent, metadata: &Metadata, user_id: UserId, - category: Category, + category_id: CategoryId, ) -> Result { self.database .insert_torrent_and_get_id( original_info_hash, torrent, user_id, - category.category_id, + category_id, &metadata.title, &metadata.description, ) diff --git a/src/web/api/v1/contexts/torrent/handlers.rs b/src/web/api/v1/contexts/torrent/handlers.rs index 9ddf345d..bab51443 100644 --- a/src/web/api/v1/contexts/torrent/handlers.rs +++ b/src/web/api/v1/contexts/torrent/handlers.rs @@ -396,7 +396,7 @@ async fn build_add_torrent_request_from_payload(mut payload: Multipart) -> Resul Ok(AddTorrentRequest { title, description, - category, + category_name: category, tags, torrent_buffer: torrent_cursor.into_inner(), }) From 329485fe748d6c6f0740805efa2032cbd977a0db Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 14:36:54 +0100 Subject: [PATCH 332/357] refactor: [#276] extract fn parse_torrent::decode_and_validate_torrent_file --- src/errors.rs | 11 +++++++++++ src/services/torrent.rs | 18 ++++-------------- src/utils/parse_torrent.rs | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index e8ef6712..b6f3385d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,6 +6,7 @@ use hyper::StatusCode; use crate::databases::database; use crate::models::torrent::MetadataError; +use crate::utils::parse_torrent::MetainfoFileDataError; pub type ServiceResult = Result; @@ -215,6 +216,16 @@ impl From for ServiceError { } } +impl From for ServiceError { + fn from(e: MetainfoFileDataError) -> Self { + eprintln!("{e}"); + match e { + MetainfoFileDataError::InvalidBencodeData => ServiceError::InvalidTorrentFile, + MetainfoFileDataError::InvalidTorrentPiecesLength => ServiceError::InvalidTorrentTitleLength, + } + } +} + #[must_use] pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { #[allow(clippy::match_same_arms)] diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 7ae61848..dd13331f 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -17,7 +17,7 @@ use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::user::UserId; use crate::tracker::statistics_importer::StatisticsImporter; -use crate::utils::parse_torrent; +use crate::utils::parse_torrent::decode_and_validate_torrent_file; use crate::{tracker, AsCSV}; pub struct Index { @@ -134,20 +134,10 @@ impl Index { let metadata = self.validate_and_build_metadata(&add_torrent_req).await?; - // Validate and build torrent file - - let original_info_hash = parse_torrent::calculate_info_hash(&add_torrent_req.torrent_buffer); - - let mut torrent = - parse_torrent::decode_torrent(&add_torrent_req.torrent_buffer).map_err(|_| ServiceError::InvalidTorrentFile)?; - - // Make sure that the pieces key has a length that is a multiple of 20 - if let Some(pieces) = torrent.info.pieces.as_ref() { - if pieces.as_ref().len() % 20 != 0 { - return Err(ServiceError::InvalidTorrentPiecesLength); - } - } + let (mut torrent, original_info_hash) = decode_and_validate_torrent_file(&add_torrent_req.torrent_buffer)?; + // Customize the announce URLs with the linked tracker URL + // and remove others if the torrent is private. torrent.set_announce_urls(&self.configuration).await; let canonical_info_hash = torrent.canonical_info_hash(); diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 5644cd8d..e04c2549 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -1,5 +1,6 @@ use std::error; +use derive_more::{Display, Error}; use serde::{self, Deserialize, Serialize}; use serde_bencode::value::Value; use serde_bencode::{de, Error}; @@ -8,6 +9,43 @@ use sha1::{Digest, Sha1}; use crate::models::info_hash::InfoHash; use crate::models::torrent_file::Torrent; +#[derive(Debug, Display, PartialEq, Eq, Error)] +pub enum MetainfoFileDataError { + #[display(fmt = "Torrent data could not be decoded from the bencoded format.")] + InvalidBencodeData, + + #[display(fmt = "Torrent has an invalid pieces key length. It should be a multiple of 20.")] + InvalidTorrentPiecesLength, +} + +/// It decodes and validate an array of bytes containing a torrent file. +/// +/// It returns a tuple containing the decoded torrent and the original info hash. +/// The original info-hash migth not match the new one in the `Torrent` because +/// the info dictionary might have been modified. For example, ignoring some +/// non-standard fields. +/// +/// # Errors +/// +/// This function will return an error if +/// +/// - The torrent file is not a valid bencoded file. +/// - The pieces key has a length that is not a multiple of 20. +pub fn decode_and_validate_torrent_file(bytes: &[u8]) -> Result<(Torrent, InfoHash), MetainfoFileDataError> { + let original_info_hash = calculate_info_hash(bytes); + + let torrent = decode_torrent(bytes).map_err(|_| MetainfoFileDataError::InvalidBencodeData)?; + + // Make sure that the pieces key has a length that is a multiple of 20 + if let Some(pieces) = torrent.info.pieces.as_ref() { + if pieces.as_ref().len() % 20 != 0 { + return Err(MetainfoFileDataError::InvalidTorrentPiecesLength); + } + } + + Ok((torrent, original_info_hash)) +} + /// Decode a Torrent from Bencoded Bytes. /// /// # Errors From 4cc97c78214db737cd4ac662b9bc501761eae83e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 16:05:13 +0100 Subject: [PATCH 333/357] feat!: [#276] upload torrent returns 400 instead of empty response when the original info-hash of the upload torrent cannot be calculated. The API should not return empty responses, so "panic" should not be use for common problems that we should notify to the user. --- src/errors.rs | 12 +++--- src/services/torrent.rs | 2 +- src/utils/parse_torrent.rs | 40 ++++++++++++------- .../web/api/v1/contexts/torrent/contract.rs | 8 ++-- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index b6f3385d..c92a0361 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,7 +6,7 @@ use hyper::StatusCode; use crate::databases::database; use crate::models::torrent::MetadataError; -use crate::utils::parse_torrent::MetainfoFileDataError; +use crate::utils::parse_torrent::DecodeTorrentFileError; pub type ServiceResult = Result; @@ -216,12 +216,14 @@ impl From for ServiceError { } } -impl From for ServiceError { - fn from(e: MetainfoFileDataError) -> Self { +impl From for ServiceError { + fn from(e: DecodeTorrentFileError) -> Self { eprintln!("{e}"); match e { - MetainfoFileDataError::InvalidBencodeData => ServiceError::InvalidTorrentFile, - MetainfoFileDataError::InvalidTorrentPiecesLength => ServiceError::InvalidTorrentTitleLength, + DecodeTorrentFileError::InvalidTorrentPiecesLength => ServiceError::InvalidTorrentTitleLength, + DecodeTorrentFileError::CannotBencodeInfoDict + | DecodeTorrentFileError::InvalidInfoDictionary + | DecodeTorrentFileError::InvalidBencodeData => ServiceError::InvalidTorrentFile, } } } diff --git a/src/services/torrent.rs b/src/services/torrent.rs index dd13331f..cb40de57 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -129,7 +129,7 @@ impl Index { add_torrent_req: AddTorrentRequest, user_id: UserId, ) -> Result { - // Authorization: only authenticated users ere allowed to upload torrents + // Guard that the users exists let _user = self.user_repository.get_compact(&user_id).await?; let metadata = self.validate_and_build_metadata(&add_torrent_req).await?; diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index e04c2549..69e69011 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -10,12 +10,18 @@ use crate::models::info_hash::InfoHash; use crate::models::torrent_file::Torrent; #[derive(Debug, Display, PartialEq, Eq, Error)] -pub enum MetainfoFileDataError { +pub enum DecodeTorrentFileError { #[display(fmt = "Torrent data could not be decoded from the bencoded format.")] InvalidBencodeData, + #[display(fmt = "Torrent info dictionary key could not be decoded from the bencoded format.")] + InvalidInfoDictionary, + #[display(fmt = "Torrent has an invalid pieces key length. It should be a multiple of 20.")] InvalidTorrentPiecesLength, + + #[display(fmt = "Cannot bencode the parsed `info` dictionary again to generate the info-hash.")] + CannotBencodeInfoDict, } /// It decodes and validate an array of bytes containing a torrent file. @@ -31,15 +37,15 @@ pub enum MetainfoFileDataError { /// /// - The torrent file is not a valid bencoded file. /// - The pieces key has a length that is not a multiple of 20. -pub fn decode_and_validate_torrent_file(bytes: &[u8]) -> Result<(Torrent, InfoHash), MetainfoFileDataError> { - let original_info_hash = calculate_info_hash(bytes); +pub fn decode_and_validate_torrent_file(bytes: &[u8]) -> Result<(Torrent, InfoHash), DecodeTorrentFileError> { + let original_info_hash = calculate_info_hash(bytes)?; - let torrent = decode_torrent(bytes).map_err(|_| MetainfoFileDataError::InvalidBencodeData)?; + let torrent = decode_torrent(bytes).map_err(|_| DecodeTorrentFileError::InvalidBencodeData)?; // Make sure that the pieces key has a length that is a multiple of 20 if let Some(pieces) = torrent.info.pieces.as_ref() { if pieces.as_ref().len() % 20 != 0 { - return Err(MetainfoFileDataError::InvalidTorrentPiecesLength); + return Err(DecodeTorrentFileError::InvalidTorrentPiecesLength); } } @@ -77,30 +83,34 @@ pub fn encode_torrent(torrent: &Torrent) -> Result, Error> { } #[derive(Serialize, Deserialize, Debug, PartialEq)] -struct MetainfoFile { +struct ParsedInfoDictFromMetainfoFile { pub info: Value, } /// Calculates the `InfoHash` from a the torrent file binary data. /// -/// # Panics +/// # Errors +/// +/// This function will return an error if: /// -/// This function will panic if the torrent file is not a valid bencoded file -/// or if the info dictionary cannot be bencoded. -#[must_use] -pub fn calculate_info_hash(bytes: &[u8]) -> InfoHash { +/// - The torrent file is not a valid bencoded torrent file containing an `info` +/// dictionary key. +/// - The original torrent info-hash cannot be bencoded from the parsed `info` +/// dictionary is not a valid bencoded dictionary. +pub fn calculate_info_hash(bytes: &[u8]) -> Result { // Extract the info dictionary - let metainfo: MetainfoFile = serde_bencode::from_bytes(bytes).expect("Torrent file cannot be parsed from bencoded format"); + let metainfo: ParsedInfoDictFromMetainfoFile = + serde_bencode::from_bytes(bytes).map_err(|_| DecodeTorrentFileError::InvalidInfoDictionary)?; // Bencode the info dictionary - let info_dict_bytes = serde_bencode::to_bytes(&metainfo.info).expect("Info dictionary cannot by bencoded"); + let info_dict_bytes = serde_bencode::to_bytes(&metainfo.info).map_err(|_| DecodeTorrentFileError::CannotBencodeInfoDict)?; // Calculate the SHA-1 hash of the bencoded info dictionary let mut hasher = Sha1::new(); hasher.update(&info_dict_bytes); let result = hasher.finalize(); - InfoHash::from_bytes(&result) + Ok(InfoHash::from_bytes(&result)) } #[cfg(test)] @@ -117,7 +127,7 @@ mod tests { "tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent", ); - let original_info_hash = super::calculate_info_hash(&std::fs::read(torrent_path).unwrap()); + let original_info_hash = super::calculate_info_hash(&std::fs::read(torrent_path).unwrap()).unwrap(); assert_eq!( original_info_hash, diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 5bd59d2f..601e0478 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -317,7 +317,8 @@ mod for_guests { // Download let response = client.download_torrent(&test_torrent.file_info_hash()).await; - let downloaded_torrent_info_hash = calculate_info_hash(&response.bytes); + let downloaded_torrent_info_hash = + calculate_info_hash(&response.bytes).expect("failed to calculate info-hash of the downloaded torrent"); assert_eq!( downloaded_torrent_info_hash.to_hex_string(), @@ -598,7 +599,6 @@ mod for_authenticated_users { use crate::e2e::web::api::v1::contexts::user::steps::new_logged_in_user; #[tokio::test] - #[should_panic] async fn contains_a_bencoded_dictionary_with_the_info_key_in_order_to_calculate_the_original_info_hash() { let mut env = TestEnv::new(); env.start(api::Version::V1).await; @@ -614,7 +614,9 @@ mod for_authenticated_users { let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); - let _response = client.upload_torrent(form.into()).await; + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); } #[tokio::test] From bbadd59a4af9139cbe32f5b3465d94add5bfdd2c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 16:24:27 +0100 Subject: [PATCH 334/357] refactor: [#276] extract fn Torrent::customize_announcement_info_for --- src/models/torrent_file.rs | 21 +++++++++++++-------- src/services/torrent.rs | 11 ++++++++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 7045c147..98e57b92 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -4,7 +4,6 @@ use serde_bytes::ByteBuf; use sha1::{Digest, Sha1}; use super::info_hash::InfoHash; -use crate::config::Configuration; use crate::utils::hex::{from_bytes, into_bytes}; #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] @@ -101,19 +100,25 @@ impl Torrent { } } - /// Sets the announce url to the tracker url and removes all other trackers - /// if the torrent is private. - pub async fn set_announce_urls(&mut self, cfg: &Configuration) { - let settings = cfg.settings.read().await; + /// Sets the announce url to the tracker url. + pub fn set_announce_to(&mut self, tracker_url: &str) { + self.announce = Some(tracker_url.to_owned()); + } - self.announce = Some(settings.tracker.url.clone()); + /// Removes all other trackers if the torrent is private. + pub fn reset_announce_list_if_private(&mut self) { + if self.is_private() { + self.announce_list = None; + } + } - // if torrent is private, remove all other trackers + fn is_private(&self) -> bool { if let Some(private) = self.info.private { if private == 1 { - self.announce_list = None; + return true; } } + false } /// It calculates the info hash of the torrent file. diff --git a/src/services/torrent.rs b/src/services/torrent.rs index cb40de57..7d59320b 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -136,9 +136,7 @@ impl Index { let (mut torrent, original_info_hash) = decode_and_validate_torrent_file(&add_torrent_req.torrent_buffer)?; - // Customize the announce URLs with the linked tracker URL - // and remove others if the torrent is private. - torrent.set_announce_urls(&self.configuration).await; + self.customize_announcement_info_for(&mut torrent).await; let canonical_info_hash = torrent.canonical_info_hash(); @@ -233,6 +231,13 @@ impl Index { Ok(metadata) } + async fn customize_announcement_info_for(&self, torrent: &mut Torrent) { + let settings = self.configuration.settings.read().await; + let tracker_url = settings.tracker.url.clone(); + torrent.set_announce_to(&tracker_url); + torrent.reset_announce_list_if_private(); + } + /// Gets a torrent from the Index. /// /// # Errors From 6a75c54b3b73f58d54f0aa93100769bd5b5385a4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 16:33:24 +0100 Subject: [PATCH 335/357] refactor: [#276] extract fn Torrent::canonical_info_hash_group_checks --- src/services/torrent.rs | 61 +++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 7d59320b..429fea38 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -140,34 +140,9 @@ impl Index { let canonical_info_hash = torrent.canonical_info_hash(); - // Canonical InfoHash Group checks - - let original_info_hashes = self - .torrent_info_hash_repository - .get_canonical_info_hash_group(&canonical_info_hash) + self.canonical_info_hash_group_checks(&original_info_hash, &canonical_info_hash) .await?; - if !original_info_hashes.is_empty() { - // Torrent with the same canonical infohash was already uploaded - debug!("Canonical infohash found: {:?}", canonical_info_hash.to_hex_string()); - - if let Some(original_info_hash) = original_info_hashes.find(&original_info_hash) { - // The exact original infohash was already uploaded - debug!("Original infohash found: {:?}", original_info_hash.to_hex_string()); - - return Err(ServiceError::InfoHashAlreadyExists); - } - - // A new original infohash is being uploaded with a canonical infohash that already exists. - debug!("Original infohash not found: {:?}", original_info_hash.to_hex_string()); - - // Add the new associated original infohash to the canonical one. - self.torrent_info_hash_repository - .add_info_hash_to_canonical_info_hash_group(&original_info_hash, &canonical_info_hash) - .await?; - return Err(ServiceError::CanonicalInfoHashAlreadyExists); - } - // Store the torrent into the database let torrent_id = self @@ -231,6 +206,40 @@ impl Index { Ok(metadata) } + async fn canonical_info_hash_group_checks( + &self, + original_info_hash: &InfoHash, + canonical_info_hash: &InfoHash, + ) -> Result<(), ServiceError> { + let original_info_hashes = self + .torrent_info_hash_repository + .get_canonical_info_hash_group(canonical_info_hash) + .await?; + + if !original_info_hashes.is_empty() { + // Torrent with the same canonical infohash was already uploaded + debug!("Canonical infohash found: {:?}", canonical_info_hash.to_hex_string()); + + if let Some(original_info_hash) = original_info_hashes.find(original_info_hash) { + // The exact original infohash was already uploaded + debug!("Original infohash found: {:?}", original_info_hash.to_hex_string()); + + return Err(ServiceError::InfoHashAlreadyExists); + } + + // A new original infohash is being uploaded with a canonical infohash that already exists. + debug!("Original infohash not found: {:?}", original_info_hash.to_hex_string()); + + // Add the new associated original infohash to the canonical one. + self.torrent_info_hash_repository + .add_info_hash_to_canonical_info_hash_group(original_info_hash, canonical_info_hash) + .await?; + return Err(ServiceError::CanonicalInfoHashAlreadyExists); + } + + Ok(()) + } + async fn customize_announcement_info_for(&self, torrent: &mut Torrent) { let settings = self.configuration.settings.read().await; let tracker_url = settings.tracker.url.clone(); From fbb42adcba97e1f7928031d5e73f58237712c52b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 16:50:57 +0100 Subject: [PATCH 336/357] feat!: [#276] do not persist uploaded torrent if it cannot persit tags When you upload a torrent the transaction wraps all the persisted data. Before this change, if tags can't be stored in the database the rest of the indexed data is stored anyway. --- src/databases/database.rs | 6 ++---- src/databases/mysql.rs | 29 ++++++++++++++++++++++------- src/databases/sqlite.rs | 29 ++++++++++++++++++++++------- src/services/torrent.rs | 22 +++------------------- 4 files changed, 49 insertions(+), 37 deletions(-) diff --git a/src/databases/database.rs b/src/databases/database.rs index 462e722b..0d6e8c3e 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -7,7 +7,7 @@ use crate::databases::sqlite::Sqlite; use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; -use crate::models::torrent::TorrentListing; +use crate::models::torrent::{Metadata, TorrentListing}; use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; @@ -196,9 +196,7 @@ pub trait Database: Sync + Send { original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, - category_id: CategoryId, - title: &str, - description: &str, + metadata: &Metadata, ) -> Result; /// Get `Torrent` from `InfoHash`. diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 9eaef8a7..b57ad077 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -12,7 +12,7 @@ use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCom use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; -use crate::models::torrent::TorrentListing; +use crate::models::torrent::{Metadata, TorrentListing}; use crate::models::torrent_file::{DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; @@ -432,9 +432,7 @@ impl Database for Mysql { original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, - category_id: CategoryId, - title: &str, - description: &str, + metadata: &Metadata, ) -> Result { let info_hash = torrent.canonical_info_hash_hex(); let canonical_info_hash = torrent.canonical_info_hash(); @@ -471,7 +469,7 @@ impl Database for Mysql { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())", ) .bind(uploader_id) - .bind(category_id) + .bind(metadata.category_id) .bind(info_hash.to_lowercase()) .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) @@ -585,11 +583,28 @@ impl Database for Mysql { return Err(e); } + // Insert tags + + for tag_id in &metadata.tags { + let insert_torrent_tag_result = query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") + .bind(torrent_id) + .bind(tag_id) + .execute(&mut tx) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())); + + // rollback transaction on error + if let Err(e) = insert_torrent_tag_result { + drop(tx.rollback().await); + return Err(e); + } + } + let insert_torrent_info_result = query(r#"INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, NULLIF(?, ""))"#) .bind(torrent_id) - .bind(title) - .bind(description) + .bind(metadata.title.clone()) + .bind(metadata.description.clone()) .execute(&mut tx) .await .map_err(|e| match e { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 43d475ca..8e12f9be 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -12,7 +12,7 @@ use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCom use crate::models::category::CategoryId; use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; -use crate::models::torrent::TorrentListing; +use crate::models::torrent::{Metadata, TorrentListing}; use crate::models::torrent_file::{DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, Torrent, TorrentFile}; use crate::models::torrent_tag::{TagId, TorrentTag}; use crate::models::tracker_key::TrackerKey; @@ -422,9 +422,7 @@ impl Database for Sqlite { original_info_hash: &InfoHash, torrent: &Torrent, uploader_id: UserId, - category_id: CategoryId, - title: &str, - description: &str, + metadata: &Metadata, ) -> Result { let info_hash = torrent.canonical_info_hash_hex(); let canonical_info_hash = torrent.canonical_info_hash(); @@ -461,7 +459,7 @@ impl Database for Sqlite { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))", ) .bind(uploader_id) - .bind(category_id) + .bind(metadata.category_id) .bind(info_hash.to_lowercase()) .bind(torrent.file_size()) .bind(torrent.info.name.to_string()) @@ -575,11 +573,28 @@ impl Database for Sqlite { return Err(e); } + // Insert tags + + for tag_id in &metadata.tags { + let insert_torrent_tag_result = query("INSERT INTO torrust_torrent_tag_links (torrent_id, tag_id) VALUES (?, ?)") + .bind(torrent_id) + .bind(tag_id) + .execute(&mut tx) + .await + .map_err(|err| database::Error::ErrorWithText(err.to_string())); + + // rollback transaction on error + if let Err(e) = insert_torrent_tag_result { + drop(tx.rollback().await); + return Err(e); + } + } + let insert_torrent_info_result = query(r#"INSERT INTO torrust_torrent_info (torrent_id, title, description) VALUES (?, ?, NULLIF(?, ""))"#) .bind(torrent_id) - .bind(title) - .bind(description) + .bind(metadata.title.clone()) + .bind(metadata.description.clone()) .execute(&mut tx) .await .map_err(|e| match e { diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 429fea38..7419cfd9 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -138,20 +138,12 @@ impl Index { self.customize_announcement_info_for(&mut torrent).await; - let canonical_info_hash = torrent.canonical_info_hash(); - - self.canonical_info_hash_group_checks(&original_info_hash, &canonical_info_hash) + self.canonical_info_hash_group_checks(&original_info_hash, &torrent.canonical_info_hash()) .await?; - // Store the torrent into the database - let torrent_id = self .torrent_repository - .add(&original_info_hash, &torrent, &metadata, user_id, metadata.category_id) - .await?; - - self.torrent_tag_repository - .link_torrent_to_tags(&torrent_id, &metadata.tags) + .add(&original_info_hash, &torrent, &metadata, user_id) .await?; // Secondary task: import torrent statistics from the tracker @@ -544,17 +536,9 @@ impl DbTorrentRepository { torrent: &Torrent, metadata: &Metadata, user_id: UserId, - category_id: CategoryId, ) -> Result { self.database - .insert_torrent_and_get_id( - original_info_hash, - torrent, - user_id, - category_id, - &metadata.title, - &metadata.description, - ) + .insert_torrent_and_get_id(original_info_hash, torrent, user_id, metadata) .await } From f19e801c66a4f7da2e636edcff0419453e6aa701 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 17:25:41 +0100 Subject: [PATCH 337/357] refactor: [#276] extract fn Torrent::import_torrent_statistics_from_tracker --- src/services/torrent.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 7419cfd9..40a42179 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -146,13 +146,10 @@ impl Index { .add(&original_info_hash, &torrent, &metadata, user_id) .await?; - // Secondary task: import torrent statistics from the tracker + // Synchronous secondary tasks - drop( - self.tracker_statistics_importer - .import_torrent_statistics(torrent_id, &torrent.canonical_info_hash_hex()) - .await, - ); + self.import_torrent_statistics_from_tracker(torrent_id, &torrent.canonical_info_hash()) + .await; // Secondary task: whitelist torrent on the tracker @@ -239,6 +236,14 @@ impl Index { torrent.reset_announce_list_if_private(); } + async fn import_torrent_statistics_from_tracker(&self, torrent_id: TorrentId, canonical_info_hash: &InfoHash) { + drop( + self.tracker_statistics_importer + .import_torrent_statistics(torrent_id, &canonical_info_hash.to_hex_string()) + .await, + ); + } + /// Gets a torrent from the Index. /// /// # Errors From 275231a6a9b9b82279c035a32ac0b2ec2c6f6d50 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 20 Sep 2023 17:31:55 +0100 Subject: [PATCH 338/357] doc: [#276] add comment for upload torrent secondary tasks --- src/services/torrent.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/services/torrent.rs b/src/services/torrent.rs index 40a42179..0a1f6b94 100644 --- a/src/services/torrent.rs +++ b/src/services/torrent.rs @@ -148,13 +148,16 @@ impl Index { // Synchronous secondary tasks + // code-review: consider moving this to a background task self.import_torrent_statistics_from_tracker(torrent_id, &torrent.canonical_info_hash()) .await; - // Secondary task: whitelist torrent on the tracker - - // We always whitelist the torrent on the tracker because even if the tracker mode is `public` - // it could be changed to `private` later on. + // We always whitelist the torrent on the tracker because + // even if the tracker mode is `public` it could be changed to `private` + // later on. + // + // code-review: maybe we should consider adding a new feature to + // whitelist all torrents from the admin panel if that change happens. if let Err(e) = self .tracker_service .whitelist_info_hash(torrent.canonical_info_hash_hex()) From 8304bf61a0cc1a1889d218232b4aa655a2353f9c Mon Sep 17 00:00:00 2001 From: postmeback Date: Wed, 27 Sep 2023 14:23:10 +0100 Subject: [PATCH 339/357] chore: [#247] rename yml extension to yaml --- .github/workflows/{publish_crate.yml => publish_crate.yaml} | 0 .../{publish_docker_image.yml => publish_docker_image.yaml} | 0 .github/workflows/{release.yml => release.yaml} | 0 .github/workflows/{test_docker.yml => test_docker.yaml} | 0 codecov.yml => codecov.yaml | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{publish_crate.yml => publish_crate.yaml} (100%) rename .github/workflows/{publish_docker_image.yml => publish_docker_image.yaml} (100%) rename .github/workflows/{release.yml => release.yaml} (100%) rename .github/workflows/{test_docker.yml => test_docker.yaml} (100%) rename codecov.yml => codecov.yaml (100%) diff --git a/.github/workflows/publish_crate.yml b/.github/workflows/publish_crate.yaml similarity index 100% rename from .github/workflows/publish_crate.yml rename to .github/workflows/publish_crate.yaml diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yaml similarity index 100% rename from .github/workflows/publish_docker_image.yml rename to .github/workflows/publish_docker_image.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yaml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/release.yaml diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yaml similarity index 100% rename from .github/workflows/test_docker.yml rename to .github/workflows/test_docker.yaml diff --git a/codecov.yml b/codecov.yaml similarity index 100% rename from codecov.yml rename to codecov.yaml From e6dcbb1c4e339dfbb4da225e5c934772161ffbcb Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 3 Oct 2023 13:54:49 +0200 Subject: [PATCH 340/357] chore: update dependencies --- Cargo.lock | 160 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 91 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f981ccf1..1fa4f698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2135563fb5c609d2b2b87c1e8ce7bc41b0b45430fa9661f457981503dd5bf0" +checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab" dependencies = [ "memchr", ] @@ -277,9 +277,9 @@ dependencies = [ [[package]] name = "brotli" -version = "3.3.4" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" +checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -288,9 +288,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "2.3.4" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6561fd3f895a11e8f72af2cb7d22e08366bebc2b6b57f7744c4bda27034744" +checksum = "da74e2b81409b1b743f8f0c62cc6254afefb8b8e50bbfe3735550f7aeefa3448" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -593,9 +593,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" +checksum = "add4f07d43996f76ef320709726a556a9d4f965d9410d8d0271132d2f8293480" dependencies = [ "errno-dragonfly", "libc", @@ -629,9 +629,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fdeflate" @@ -895,9 +895,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12" dependencies = [ "ahash 0.8.3", "allocator-api2", @@ -909,7 +909,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" dependencies = [ - "hashbrown 0.14.0", + "hashbrown 0.14.1", ] [[package]] @@ -923,9 +923,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" [[package]] name = "hex" @@ -1094,12 +1094,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.0.0" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" dependencies = [ "equivalent", - "hashbrown 0.14.0", + "hashbrown 0.14.1", ] [[package]] @@ -1277,9 +1277,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" +checksum = "3852614a3bd9ca9804678ba6be5e3b8ce76dfc902cae004e3e0c44051b6e88db" [[package]] name = "lock_api" @@ -1311,15 +1311,15 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "matchit" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.6.3" +version = "2.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" [[package]] name = "memmap2" @@ -1643,9 +1643,9 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "pest" -version = "2.7.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a4d085fd991ac8d5b05a147b437791b4260b76326baf0fc60cf7c9c27ecd33" +checksum = "c022f1e7b65d6a24c0dbbd5fb344c66881bc01f3e5ae74a1c8100f2f985d98a4" dependencies = [ "memchr", "thiserror", @@ -1654,9 +1654,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bee7be22ce7918f641a33f08e3f43388c7656772244e2bbb2477f44cc9021a" +checksum = "35513f630d46400a977c4cb58f78e1bfbe01434316e60c37d27b9ad6139c66d8" dependencies = [ "pest", "pest_generator", @@ -1664,9 +1664,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1511785c5e98d79a05e8a6bc34b4ac2168a0e3e92161862030ad84daa223141" +checksum = "bc9fc1b9e7057baba189b5c626e2d6f40681ae5b6eb064dc7c7834101ec8123a" dependencies = [ "pest", "pest_meta", @@ -1677,9 +1677,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42f0394d3123e33353ca5e1e89092e533d2cc490389f2bd6131c43c634ebc5f" +checksum = "1df74e9e7ec4053ceb980e7c0c8bd3594e977fde1af91daba9c928e8e8c6708d" dependencies = [ "once_cell", "pest", @@ -1851,9 +1851,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.5" +version = "1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" +checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff" dependencies = [ "aho-corasick", "memchr", @@ -1863,9 +1863,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" +checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" dependencies = [ "aho-corasick", "memchr", @@ -1880,9 +1880,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "reqwest" -version = "0.11.20" +version = "0.11.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1" +checksum = "78fdbab6a7e1d7b13cc8ff10197f47986b41c639300cc3c8158cac7847c9bbef" dependencies = [ "base64 0.21.4", "bytes", @@ -1906,6 +1906,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "system-configuration", "tokio", "tokio-native-tls", "tower-service", @@ -2022,9 +2023,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.13" +version = "0.38.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662" +checksum = "d2f9da0cbd88f9f09e7814e388301c8414c51c62aa6ce1e4b5c551d49d96e531" dependencies = [ "bitflags 2.4.0", "errno", @@ -2041,7 +2042,7 @@ checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ "log", "ring", - "rustls-webpki 0.101.5", + "rustls-webpki 0.101.6", "sct", ] @@ -2066,9 +2067,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.5" +version = "0.101.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a27e3b59326c16e23d30aeb7a36a24cc0d29e71d68ff611cdfb4a01d013bed" +checksum = "3c7d5dece342910d9ba34d259310cae3e0154b873b35408b787b59bce53d34fe" dependencies = [ "ring", "untrusted", @@ -2199,9 +2200,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" +checksum = "ad977052201c6de01a8ef2aa3378c4bd23217a056337d1d6da40468d267a4fb0" [[package]] name = "serde" @@ -2297,9 +2298,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", @@ -2308,9 +2309,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.7" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", @@ -2370,9 +2371,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" +checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" [[package]] name = "socket2" @@ -2575,6 +2576,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.8.0" @@ -2582,7 +2604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" dependencies = [ "cfg-if", - "fastrand 2.0.0", + "fastrand 2.0.1", "redox_syscall 0.3.5", "rustix", "windows-sys", @@ -2617,18 +2639,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.48" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7" +checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.48" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" +checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" dependencies = [ "proc-macro2", "quote", @@ -2637,9 +2659,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" +checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe" dependencies = [ "deranged", "itoa", @@ -2650,15 +2672,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" dependencies = [ "time-core", ] @@ -2754,9 +2776,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +checksum = "1d68074620f57a0b21594d9735eb2e98ab38b17f80d3fcb189fca266771ca60d" dependencies = [ "bytes", "futures-core", @@ -2802,7 +2824,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.0.0", + "indexmap 2.0.2", "serde", "serde_spanned", "toml_datetime", @@ -3322,15 +3344,15 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab77e97b50aee93da431f2cee7cd0f43b4d1da3c408042f2d7d164187774f0a" +checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" [[package]] name = "xmlparser" -version = "0.13.5" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] name = "xmlwriter" From 41be37aea6547c023d4baa3c326266fcec8c5052 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 12 Oct 2023 08:02:43 +0200 Subject: [PATCH 341/357] github: add codeowners file --- .github/CODEOWNERS | 1 + CODEOWNERS | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS delete mode 100644 CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..2ae8963e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +/.github/**/* @torrust/maintainers diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index b6221300..00000000 --- a/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -/.github/ @da2ce7 @josecelano @WarmBeer From 4100d8d241d19d8c9a8ab03bd703acd33228d397 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 12 Oct 2023 08:03:21 +0200 Subject: [PATCH 342/357] ci: add dependabot file --- .github/dependabot.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/dependabot.yaml diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 00000000..becfbc1d --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + target-branch: "develop" + labels: + - "Continuous Integration" + - "Dependencies" + + - package-ecosystem: cargo + directory: / + schedule: + interval: daily + target-branch: "develop" + labels: + - "Build | Project System" + - "Dependencies" From ca050347959690f66742ef68091ceb2c1f073460 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 12 Oct 2023 08:03:59 +0200 Subject: [PATCH 343/357] ci: add labels sync workflow --- .github/labels.json | 254 ++++++++++++++++++++++++++++++++++ .github/workflows/labels.yaml | 36 +++++ 2 files changed, 290 insertions(+) create mode 100644 .github/labels.json create mode 100644 .github/workflows/labels.yaml diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 00000000..6ccbeab1 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,254 @@ +[ + { + "name": "- Admin -", + "color": "FFFFFF", + "description": "Enjoyable to Install and Setup our Software", + "aliases": [] + }, + { + "name": "- Contributor -", + "color": "FFFFFF", + "description": "Nice to support Torrust", + "aliases": [] + }, + { + "name": "- Developer -", + "color": "FFFFFF", + "description": "Torrust Improvement Experience", + "aliases": [] + }, + { + "name": "- User -", + "color": "FFFFFF", + "description": "Enjoyable to Use our Software", + "aliases": [] + }, + { + "name": "Blocked", + "color": "000000", + "description": "Has Unsatisfied Dependency", + "aliases": [] + }, + { + "name": "Bug", + "color": "a80506", + "description": "Incorrect Behavior", + "aliases": ["bug"] + }, + { + "name": "Build | Project System", + "color": "99AAAA", + "description": "Compiling and Packaging", + "aliases": ["Rust"] + }, + { + "name": "Cannot Reproduce", + "color": "D3D3D3", + "description": "Inconsistent Observations", + "aliases": [] + }, + { + "name": "Code Cleanup / Refactoring", + "color": "055a8b", + "description": "Tidying and Making Neat", + "aliases": ["refactoring", "tidying"] + }, + { + "name": "Continuous Integration", + "color": "41c6b3", + "description": "Workflows and Automation", + "aliases": ["workflow"] + }, + { + "name": "Dependencies", + "color": "d4f8f6", + "description": "Related to Dependencies", + "aliases": ["dependencies"] + }, + { + "name": "Documentation", + "color": "3d2133", + "description": "Improves Instructions, Guides, and Notices", + "aliases": [] + }, + { + "name": "Duplicate", + "color": "cfd3d7", + "description": "Not Unique", + "aliases": [] + }, + { + "name": "Easy", + "color": "f0cff0", + "description": "Good for Newcomers", + "aliases": ["good first issue"] + }, + { + "name": "Enhancement / Feature Request", + "color": "c9ecbf", + "description": "Something New", + "aliases": ["enhancement"] + }, + { + "name": "External Tools", + "color": "a6006b", + "description": "3rd Party Systems", + "aliases": [] + }, + { + "name": "First Time Contribution", + "color": "f1e0e6", + "description": "Welcome to Torrust", + "aliases": [] + }, + { + "name": "Fixed", + "color": "8e4c42", + "description": "Not a Concern Anymore", + "aliases": [] + }, + { + "name": "Hard", + "color": "2c2c2c", + "description": "Non-Trivial", + "aliases": [] + }, + { + "name": "Help Wanted", + "color": "00896b", + "description": "More Contributions are Appreciated", + "aliases": [] + }, + { + "name": "High Priority", + "color": "ba3fbc", + "description": "Focus Required", + "aliases": [] + }, + { + "name": "Hold Merge", + "color": "9aafbe", + "description": "We are not Ready Yet", + "aliases": [] + }, + { + "name": "Installer | Package", + "color": "ed8b24", + "description": "Distribution to Users", + "aliases": [] + }, + { + "name": "Invalid", + "color": "c1c1c1", + "description": "This doesn't seem right", + "aliases": [] + }, + { + "name": "Legal", + "color": "463e60", + "description": "Licenses and other Official Documents", + "aliases": [] + }, + { + "name": "Low Priority", + "color": "43536b", + "description": "Not our Focus Now", + "aliases": [] + }, + { + "name": "Needs Feedback", + "color": "d6946c", + "description": "What dose the Community Think?", + "aliases": ["waiting for feedback"] + }, + { + "name": "Needs Rebase", + "color": "FBC002", + "description": "Base Branch has Incompatibilities", + "aliases": [] + }, + { + "name": "Needs Research", + "color": "4bc021", + "description": "We Need to Know More About This", + "aliases": [] + }, + { + "name": "Optimization", + "color": "faeba8", + "description": "Make it Faster", + "aliases": [] + }, + { + "name": "Portability", + "color": "95de82", + "description": "Distribution to More Places", + "aliases": [] + }, + { + "name": "Postponed", + "color": "dadada", + "description": "For Later", + "aliases": [] + }, + { + "name": "Quality & Assurance", + "color": "eea2e8", + "description": "Relates to QA, Testing, and CI", + "aliases": [] + }, + { + "name": "Question / Discussion", + "color": "f89d00", + "description": "Community Feedback", + "aliases": ["code question"] + }, + { + "name": "Regression", + "color": "d10588", + "description": "It dose not work anymore", + "aliases": [] + }, + { + "name": "Reviewed", + "color": "f4f4ea", + "description": "This Looks Good", + "aliases": [] + }, + { + "name": "Security", + "color": "650606", + "description": "Publicly Connected to Security", + "aliases": ["security"] + }, + { + "name": "Testing", + "color": "c5def5", + "description": "Checking Torrust", + "aliases": [] + }, + { + "name": "Translations", + "color": "0c86af", + "description": "Localization and Cultural Adaptions", + "aliases": [] + }, + { + "name": "Trivial", + "color": "5f9685", + "description": "Something Easy", + "aliases": [] + }, + { + "name": "Won't Fix", + "color": "070003", + "description": "Something Not Relevant", + "aliases": [] + }, + { + "name": "Workaround Possible", + "color": "eae3e7", + "description": "You can still do it another way", + "aliases": [] + } +] \ No newline at end of file diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml new file mode 100644 index 00000000..4d049eb0 --- /dev/null +++ b/.github/workflows/labels.yaml @@ -0,0 +1,36 @@ +name: Labels +on: + workflow_dispatch: + push: + branches: + - develop + paths: + - "/.github/labels.json" + +jobs: + export: + name: Export Existing Labels + runs-on: ubuntu-latest + + steps: + - id: backup + name: Export to Workflow Artifact + uses: EndBug/export-label-config@v1 + + sync: + name: Synchronize Labels from Repo + needs: export + runs-on: ubuntu-latest + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v4 + + - id: sync + name: Apply Labels from File + uses: EndBug/label-sync@v2 + with: + config-file: .github/labels.json + delete-other-labels: true + token: ${{ secrets.UPDATE_ISSUES }} From 549565844f0050f5b0a4942a8f6c2ce39e5f4b14 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 12 Oct 2023 13:51:21 +0200 Subject: [PATCH 344/357] ci: small fixes --- .github/workflows/coverage.yaml | 2 +- .github/workflows/labels.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 2bc0b3e4..a032bb59 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -92,4 +92,4 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ steps.coverage.outputs.report }} verbose: true - fail_ci_if_error: true \ No newline at end of file + fail_ci_if_error: true diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml index 4d049eb0..bb8283f3 100644 --- a/.github/workflows/labels.yaml +++ b/.github/workflows/labels.yaml @@ -33,4 +33,4 @@ jobs: with: config-file: .github/labels.json delete-other-labels: true - token: ${{ secrets.UPDATE_ISSUES }} + token: ${{ secrets.UPDATE_LABELS }} From 4dc795f7fb7e798fcaca4ffcc3b39dc40fd9e19f Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 12 Oct 2023 13:52:53 +0200 Subject: [PATCH 345/357] chore: update cargo lockfile --- Cargo.lock | 114 ++++++++++++++++++++++++----------------------------- 1 file changed, 51 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fa4f698..c18bf250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" dependencies = [ "memchr", ] @@ -138,7 +138,7 @@ checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -310,9 +310,9 @@ checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" [[package]] name = "byteorder" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" @@ -593,25 +593,14 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add4f07d43996f76ef320709726a556a9d4f965d9410d8d0271132d2f8293480" +checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" dependencies = [ - "errno-dragonfly", "libc", "windows-sys", ] -[[package]] -name = "errno-dragonfly" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "event-listener" version = "2.5.3" @@ -805,7 +794,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -1151,9 +1140,9 @@ checksum = "9028f49264629065d057f340a86acb84867925865f73bbf8d47b4d149a7e88b8" [[package]] name = "jobserver" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" dependencies = [ "libc", ] @@ -1248,15 +1237,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.148" +version = "0.2.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" +checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" [[package]] name = "libm" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" [[package]] name = "libsqlite3-sys" @@ -1277,9 +1266,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.8" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3852614a3bd9ca9804678ba6be5e3b8ce76dfc902cae004e3e0c44051b6e88db" +checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" [[package]] name = "lock_api" @@ -1470,9 +1459,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" dependencies = [ "autocfg", "libm", @@ -1526,7 +1515,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -1672,7 +1661,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -1709,7 +1698,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -1773,9 +1762,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.67" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" +checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" dependencies = [ "unicode-ident", ] @@ -1851,9 +1840,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.6" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff" +checksum = "d119d7c7ca818f8a53c300863d4f87566aac09943aef5b355bb83969dae75d87" dependencies = [ "aho-corasick", "memchr", @@ -1863,9 +1852,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.9" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" +checksum = "465c6fc0621e4abc4187a2bda0937bfd4f722c2730b29562e19689ea796c9a4b" dependencies = [ "aho-corasick", "memchr", @@ -1874,15 +1863,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" +checksum = "56d84fdd47036b038fc80dd333d10b6aab10d5d31f4a366e20014def75328d33" [[package]] name = "reqwest" -version = "0.11.21" +version = "0.11.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78fdbab6a7e1d7b13cc8ff10197f47986b41c639300cc3c8158cac7847c9bbef" +checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" dependencies = [ "base64 0.21.4", "bytes", @@ -2023,9 +2012,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.15" +version = "0.38.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f9da0cbd88f9f09e7814e388301c8414c51c62aa6ce1e4b5c551d49d96e531" +checksum = "5a74ee2d7c2581cd139b42447d7d9389b889bdaad3a73f1ebb16f2a3237bb19c" dependencies = [ "bitflags 2.4.0", "errno", @@ -2136,7 +2125,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.37", + "syn 2.0.38", "toml 0.7.8", ] @@ -2200,9 +2189,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad977052201c6de01a8ef2aa3378c4bd23217a056337d1d6da40468d267a4fb0" +checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" [[package]] name = "serde" @@ -2215,9 +2204,9 @@ dependencies = [ [[package]] name = "serde_bencode" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "934d8bdbaa0126dafaea9a8833424a211d9661897717846c6bb782349ca1c30d" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" dependencies = [ "serde", "serde_bytes", @@ -2240,7 +2229,7 @@ checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -2561,9 +2550,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.37" +version = "2.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8" +checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" dependencies = [ "proc-macro2", "quote", @@ -2654,7 +2643,7 @@ checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -2716,9 +2705,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.32.0" +version = "1.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" dependencies = [ "backtrace", "bytes", @@ -2740,7 +2729,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", ] [[package]] @@ -3150,7 +3139,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", "wasm-bindgen-shared", ] @@ -3184,7 +3173,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.37", + "syn 2.0.38", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3325,9 +3314,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2e3184b9c4e92ad5167ca73039d0c42476302ab603e2fec4487511f38ccefc" +checksum = "037711d82167854aff2018dfd193aa0fef5370f456732f0d5a0c59b0f1b4b907" dependencies = [ "memchr", ] @@ -3396,11 +3385,10 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.8+zstd.1.5.5" +version = "2.0.9+zstd.1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" dependencies = [ "cc", - "libc", "pkg-config", ] From f59b16e971734c506daf0b9bce5a2cad4f746d59 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Thu, 12 Oct 2023 13:54:34 +0200 Subject: [PATCH 346/357] ci: update testing workflow --- .github/workflows/testing.yaml | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 19dcc84d..edfbe5c7 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -15,7 +15,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - id: setup name: Setup Toolchain @@ -44,13 +44,13 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - id: setup name: Setup Toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: nightly + toolchain: ${{ matrix.toolchain }} components: clippy - id: cache @@ -65,14 +65,10 @@ jobs: name: Run Lint Checks run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic - - id: testdoc - name: Run Documentation Tests + - id: doc + name: Run Documentation Checks run: cargo test --doc - - id: builddoc - name: Build Documentation - run: cargo doc --no-deps --bins --examples --workspace --all-features - unit: name: Units runs-on: ubuntu-latest @@ -85,7 +81,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - id: setup name: Setup Toolchain @@ -123,7 +119,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - id: setup name: Setup Toolchain @@ -131,7 +127,7 @@ jobs: - id: cache name: Enable Job Cache - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2 - id: test name: Run Integration Tests From b6cfcc352addcf1eeef38d2f26bd86ea7cd8a49b Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Fri, 13 Oct 2023 16:50:51 +0200 Subject: [PATCH 347/357] various: change name to `torrust-index` small changes included: - move to deployment workflow - move copyright to readme - remove common license files --- .github/workflows/coverage.yaml | 18 - .github/workflows/deployment.yaml | 54 ++ .github/workflows/labels.yaml | 2 +- .github/workflows/publish_crate.yaml | 54 -- .github/workflows/publish_docker_image.yaml | 2 +- .github/workflows/release.yaml | 97 --- .github/workflows/testing.yaml | 12 +- .vscode/launch.json | 24 +- COPYRIGHT | 11 - Cargo.lock | 33 +- Cargo.toml | 38 +- LICENSE | 661 ++++++++++++++++++ README.md | 75 +- bin/install.sh | 2 +- compose.yaml | 2 +- config-idx-back.mysql.local.toml | 2 +- config-idx-back.sqlite.local.toml | 2 +- docker/README.md | 8 +- docker/bin/build.sh | 2 +- docker/bin/e2e/mysql/e2e-env-reset.sh | 6 +- docker/bin/e2e/mysql/e2e-env-up.sh | 2 +- docker/bin/e2e/run-e2e-tests.sh | 8 +- docker/bin/e2e/sqlite/e2e-env-reset.sh | 8 +- docker/bin/e2e/sqlite/e2e-env-up.sh | 2 +- docker/bin/run.sh | 2 +- .../licenses/LICENSE-AGPL_3_0 | 0 LICENSE-MIT_0 => docs/licenses/LICENSE-MIT_0 | 0 licensing/agpl-3.0.md | 660 ----------------- licensing/cc-by-sa.md | 175 ----- licensing/contributor_agreement_v01.md | 103 --- licensing/file_header_agplv3.txt | 21 - licensing/old_commits/cc0.md | 45 -- licensing/old_commits/mit-0.md | 14 - src/bin/import_tracker_statistics.rs | 2 +- src/bin/main.rs | 6 +- src/bin/parse_torrent.rs | 2 +- src/bin/upgrade.rs | 2 +- src/config.rs | 16 +- src/lib.rs | 46 +- src/models/info_hash.rs | 2 +- src/services/about.rs | 12 +- src/services/settings.rs | 4 +- src/tracker/statistics_importer.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 2 +- src/web/api/mod.rs | 2 +- src/web/api/v1/contexts/about/mod.rs | 12 +- src/web/api/v1/contexts/proxy/handlers.rs | 2 +- src/web/api/v1/contexts/proxy/mod.rs | 10 +- src/web/api/v1/contexts/settings/mod.rs | 6 +- src/web/api/v1/contexts/torrent/mod.rs | 4 +- src/web/api/v1/contexts/user/handlers.rs | 2 +- src/web/api/v1/mod.rs | 2 +- src/web/mod.rs | 2 +- tests/common/asserts.rs | 2 +- tests/common/contexts/settings/mod.rs | 4 +- tests/common/contexts/tag/asserts.rs | 2 +- tests/common/contexts/torrent/fixtures.rs | 4 +- tests/e2e/config.rs | 2 +- tests/e2e/environment.rs | 12 +- tests/e2e/mod.rs | 2 +- .../e2e/web/api/v1/contexts/about/contract.rs | 2 +- .../web/api/v1/contexts/category/contract.rs | 2 +- .../e2e/web/api/v1/contexts/root/contract.rs | 2 +- .../web/api/v1/contexts/settings/contract.rs | 2 +- tests/e2e/web/api/v1/contexts/tag/contract.rs | 2 +- .../web/api/v1/contexts/torrent/asserts.rs | 14 +- .../web/api/v1/contexts/torrent/contract.rs | 24 +- .../e2e/web/api/v1/contexts/torrent/steps.rs | 4 +- .../e2e/web/api/v1/contexts/user/contract.rs | 6 +- tests/e2e/web/api/v1/contexts/user/steps.rs | 2 +- tests/environments/app_starter.rs | 12 +- tests/environments/isolated.rs | 14 +- .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 2 +- .../from_v1_0_0_to_v2_0_0/sqlite_v2_0_0.rs | 2 +- .../category_transferrer_tester.rs | 2 +- .../torrent_transferrer_tester.rs | 8 +- .../tracker_key_transferrer_tester.rs | 2 +- .../user_transferrer_tester.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 2 +- 79 files changed, 999 insertions(+), 1418 deletions(-) create mode 100644 .github/workflows/deployment.yaml delete mode 100644 .github/workflows/publish_crate.yaml delete mode 100644 .github/workflows/release.yaml delete mode 100644 COPYRIGHT create mode 100644 LICENSE rename LICENSE-AGPL_3_0 => docs/licenses/LICENSE-AGPL_3_0 (100%) rename LICENSE-MIT_0 => docs/licenses/LICENSE-MIT_0 (100%) delete mode 100644 licensing/agpl-3.0.md delete mode 100644 licensing/cc-by-sa.md delete mode 100644 licensing/contributor_agreement_v01.md delete mode 100644 licensing/file_header_agplv3.txt delete mode 100644 licensing/old_commits/cc0.md delete mode 100644 licensing/old_commits/mit-0.md diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index a032bb59..2212f9be 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -12,27 +12,9 @@ env: CARGO_TERM_COLOR: always jobs: - secrets: - name: Secrets - environment: coverage - runs-on: ubuntu-latest - - outputs: - continue: ${{ steps.check.outputs.continue }} - - steps: - - id: check - name: Check - env: - CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}" - if: "${{ env.CODECOV_TOKEN != '' }}" - run: echo "continue=true" >> $GITHUB_OUTPUT - report: name: Report environment: coverage - needs: secrets - if: needs.secrets.outputs.continue == 'true' runs-on: ubuntu-latest env: CARGO_INCREMENTAL: "0" diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml new file mode 100644 index 00000000..59688c2e --- /dev/null +++ b/.github/workflows/deployment.yaml @@ -0,0 +1,54 @@ +name: Deployment + +on: + push: + branches: + - "releases/**/*" + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + strategy: + matrix: + toolchain: [stable, nightly] + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v4 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.toolchain }} + + - id: test + name: Run Unit Tests + run: cargo test --tests --benches --examples --workspace --all-targets --all-features + + publish: + name: Publish + environment: deployment + needs: test + runs-on: ubuntu-latest + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v4 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - id: publish + name: Publish Crates + env: + CARGO_REGISTRY_TOKEN: "${{ secrets.TORRUST_UPDATE_CARGO_REGISTRY_TOKEN }}" + run: | + cargo publish -p torrust-index diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml index bb8283f3..97aaa030 100644 --- a/.github/workflows/labels.yaml +++ b/.github/workflows/labels.yaml @@ -29,7 +29,7 @@ jobs: - id: sync name: Apply Labels from File - uses: EndBug/label-sync@v2 + uses: EndBug/label-sync@da00f2c11fdb78e4fae44adac2fdd713778ea3e8 with: config-file: .github/labels.json delete-other-labels: true diff --git a/.github/workflows/publish_crate.yaml b/.github/workflows/publish_crate.yaml deleted file mode 100644 index ae7547f6..00000000 --- a/.github/workflows/publish_crate.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: Publish Crate - -on: - push: - tags: - - "v*" - -jobs: - check-secret: - runs-on: ubuntu-latest - environment: crates-io-torrust - outputs: - publish: ${{ steps.check.outputs.publish }} - steps: - - id: check - env: - CRATES_TOKEN: "${{ secrets.CRATES_TOKEN }}" - if: "${{ env.CRATES_TOKEN != '' }}" - run: echo "publish=true" >> $GITHUB_OUTPUT - - test: - needs: check-secret - if: needs.check-secret.outputs.publish == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 - - name: Install torrent edition tool (needed for testing) - run: cargo install imdl - - name: Run Tests - run: cargo test - - publish: - needs: test - if: needs.check-secret.outputs.publish == 'true' - runs-on: ubuntu-latest - environment: crates-io-torrust - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Install stable toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - - - name: Publish workspace packages - run: cargo publish - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_TOKEN }} diff --git a/.github/workflows/publish_docker_image.yaml b/.github/workflows/publish_docker_image.yaml index 5317a4f4..9f8ee15e 100644 --- a/.github/workflows/publish_docker_image.yaml +++ b/.github/workflows/publish_docker_image.yaml @@ -54,7 +54,7 @@ jobs: uses: docker/metadata-action@v4 with: images: | - # For example: torrust/index-backend + # For example: torrust/index "${{ secrets.DOCKER_HUB_USERNAME }}/${{secrets.DOCKER_HUB_REPOSITORY_NAME }}" tags: | type=ref,event=branch diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 87f43d34..00000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,97 +0,0 @@ -name: Publish Github Release - -on: - push: - branches: - - main - -jobs: - test: - runs-on: ubuntu-latest - env: - CARGO_TERM_COLOR: always - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - - name: Run databases - working-directory: ./tests - run: docker-compose up -d - - name: Wait for databases to start - run: sleep 15s - shell: bash - - uses: Swatinem/rust-cache@v1 - - name: Install torrent edition tool (needed for testing) - run: cargo install imdl - - name: Run tests - run: cargo test - - name: Stop databases - working-directory: ./tests - run: docker-compose down - - tag: - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Determine tag - id: tag - run: echo "::set-output name=release_tag::v$(grep -m 1 'version' Cargo.toml | awk '{print $3}' | tr -d '/"')" - outputs: - release_tag: ${{ steps.tag.outputs.release_tag }} - - build: - needs: tag - name: Build ${{ matrix.target }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-pc-windows-gnu - archive: zip - name: ${{ github.event.repository.name }}_${{ needs.tag.outputs.release_tag }}_x86_64-pc-windows-gnu - - target: x86_64-unknown-linux-musl - archive: tar.gz tar.xz - name: ${{ github.event.repository.name }}_${{ needs.tag.outputs.release_tag }}_x86_64-unknown-linux-musl - - target: x86_64-apple-darwin - archive: zip - name: ${{ github.event.repository.name }}_${{ needs.tag.outputs.release_tag }}_x86_64-apple-darwin - steps: - - uses: actions/checkout@master - - name: Compile builds - id: compile - uses: rust-build/rust-build.action@v1.3.2 - with: - RUSTTARGET: ${{ matrix.target }} - ARCHIVE_TYPES: ${{ matrix.archive }} - ARCHIVE_NAME: ${{ matrix.name }} - UPLOAD_MODE: none - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: torrust-index-backend - path: | - ${{ steps.compile.outputs.BUILT_ARCHIVE }} - ${{ steps.compile.outputs.BUILT_CHECKSUM }} - - release: - needs: [tag, build] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Download builds - uses: actions/download-artifact@v2 - with: - name: torrust-index-backend - path: torrust-index-backend - - name: Release - uses: softprops/action-gh-release@v1 - with: - generate_release_notes: true - tag_name: ${{ needs.tag.outputs.release_tag }} - files: | - torrust-index-backend/* - LICENSE diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index edfbe5c7..ccc5b4c3 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -65,9 +65,11 @@ jobs: name: Run Lint Checks run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic - - id: doc - name: Run Documentation Checks - run: cargo test --doc + - id: docs + name: Lint Documentation + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --no-deps --bins --examples --workspace --all-features unit: name: Units @@ -104,6 +106,10 @@ jobs: name: Install Intermodal run: cargo install imdl + - id: test-docs + name: Run Documentation Tests + run: cargo test --doc + - id: test name: Run Unit Tests run: cargo test --tests --benches --examples --workspace --all-targets --all-features diff --git a/.vscode/launch.json b/.vscode/launch.json index 4f1a4c0b..e3ede37f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,16 +7,16 @@ { "type": "lldb", "request": "launch", - "name": "Debug unit tests in library 'torrust-index-backend'", + "name": "Debug unit tests in library 'torrust-index'", "cargo": { "args": [ "test", "--no-run", "--lib", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { - "name": "torrust-index-backend", + "name": "torrust-index", "kind": "lib" } }, @@ -31,7 +31,7 @@ "args": [ "build", "--bin=main", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "main", @@ -50,7 +50,7 @@ "test", "--no-run", "--bin=main", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "main", @@ -68,7 +68,7 @@ "args": [ "build", "--bin=import_tracker_statistics", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "import_tracker_statistics", @@ -87,7 +87,7 @@ "test", "--no-run", "--bin=import_tracker_statistics", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "import_tracker_statistics", @@ -105,7 +105,7 @@ "args": [ "build", "--bin=parse_torrent", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "parse_torrent", @@ -124,7 +124,7 @@ "test", "--no-run", "--bin=parse_torrent", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "parse_torrent", @@ -142,7 +142,7 @@ "args": [ "build", "--bin=upgrade", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "upgrade", @@ -161,7 +161,7 @@ "test", "--no-run", "--bin=upgrade", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "upgrade", @@ -180,7 +180,7 @@ "test", "--no-run", "--test=mod", - "--package=torrust-index-backend" + "--package=torrust-index" ], "filter": { "name": "mod", diff --git a/COPYRIGHT b/COPYRIGHT deleted file mode 100644 index ec00ae29..00000000 --- a/COPYRIGHT +++ /dev/null @@ -1,11 +0,0 @@ -Copyright 2023 in the Torrust-Index-Backend project are retained by their contributors. No -copyright assignment is required to contribute to the Torrust-Index-Backend project. - -Some files include explicit copyright notices and/or license notices. - -Except as otherwise noted (below and/or in individual files), Torrust-Index-Backend is -licensed under the GNU Affero General Public License, Version 3.0 . This license applies to all files in the Torrust-Index-Backend project, except as noted below. - -Except as otherwise noted (below and/or in individual files), Torrust-Index-Backend is licensed under the MIT-0 license for all commits made after 5 years of merging. This license applies to the version of the files merged into the Torrust-Index-Backend project at the time of merging, and does not apply to subsequent updates or revisions to those files. - -The contributors to the Torrust-Index-Backend project disclaim all liability for any damages or losses that may arise from the use of the project. diff --git a/Cargo.lock b/Cargo.lock index c18bf250..4cc90f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,9 +116,9 @@ checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "async-compression" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb42b2197bf15ccb092b62c74515dbd8b86d0effd934795f6687c93b6e679a2c" +checksum = "f658e2baef915ba0f26f1f7c42bfb8e12f532a01f449a090ded75ae7a07e9ba2" dependencies = [ "brotli", "flate2", @@ -660,9 +660,9 @@ checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" [[package]] name = "flate2" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" dependencies = [ "crc32fast", "miniz_oxide", @@ -2012,9 +2012,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.18" +version = "0.38.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a74ee2d7c2581cd139b42447d7d9389b889bdaad3a73f1ebb16f2a3237bb19c" +checksum = "745ecfa778e66b2b63c88a61cb36e0eea109e803b0b86bf9879fbc77c70e86ed" dependencies = [ "bitflags 2.4.0", "errno", @@ -2195,9 +2195,9 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" [[package]] name = "serde" -version = "1.0.188" +version = "1.0.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" +checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537" dependencies = [ "serde_derive", ] @@ -2223,9 +2223,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.188" +version = "1.0.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" +checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5" dependencies = [ "proc-macro2", "quote", @@ -2821,8 +2821,8 @@ dependencies = [ ] [[package]] -name = "torrust-index-backend" -version = "2.0.0-alpha.3" +name = "torrust-index" +version = "3.0.0-alpha.1-develop" dependencies = [ "argon2", "async-trait", @@ -3366,20 +3366,19 @@ checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" [[package]] name = "zstd" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" +checksum = "bffb3309596d527cfcba7dfc6ed6052f1d39dfbd7c867aa2e865e4a449c10110" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "6.0.6" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" +checksum = "43747c7422e2924c11144d5229878b98180ef8b06cca4ab5af37afc8a8d8ea3e" dependencies = [ - "libc", "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index c632a817..c16b39e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,34 @@ [package] -name = "torrust-index-backend" -description = "The backend (API) for the Torrust Index project." -license-file = "COPYRIGHT" -version = "2.0.0-alpha.3" -authors = [ - "Mick van Dijke ", - "Wesley Bijleveld ", -] -repository = "https://github.com/torrust/torrust-index-backend" -edition = "2021" +name = "torrust-index" +readme = "README.md" default-run = "main" +authors.workspace = true +description.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[workspace.package] +authors = ["Nautilus Cyberneering , Mick van Dijke "] +categories = ["network-programming", "web-programming"] +description = "A BitTorrent Index" +documentation = "https://docs.rs/crate/torrust-tracker/" +edition = "2021" +homepage = "https://torrust.com/" +keywords = ["bittorrent", "file-sharing", "peer-to-peer", "torrent", "index"] +license = "AGPL-3.0-only" +publish = true +repository = "https://github.com/torrust/torrust-tracker" +rust-version = "1.72" +version = "3.0.0-alpha.1-develop" + [profile.dev.package.sqlx-macros] opt-level = 3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index f2a878d5..fdc43212 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Torrust Index Backend +# Torrust Index -[![Testing](https://github.com/torrust/torrust-index-backend/actions/workflows/testing.yaml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/testing.yaml) [![Publish crate](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_crate.yml) [![Publish Docker Image](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/publish_docker_image.yml) [![Publish Github Release](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/release.yml) [![Test Docker build](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml/badge.svg)](https://github.com/torrust/torrust-index-backend/actions/workflows/test_docker.yml) +[![coverage_wf_b]][coverage_wf] [![deployment_wf_b]][deployment_wf] [![testing_wf_b]][testing_wf] -This repository serves as the backend for the [Torrust Index](https://github.com/torrust/torrust-index) project, which implements the [Torrust Index Application Interface](https://github.com/torrust/torrust-index-api-lib). +This repository serves the [Torrust Index](https://github.com/torrust/torrust-index) project, which implements the [Torrust Index Application Interface](https://github.com/torrust/torrust-index-api-lib). -We also provide the [Torrust Index Frontend](https://github.com/torrust/torrust-index-frontend) project, which is our reference web application that consumes the API provided here. +We also provide the [Torrust Index Gui](https://github.com/torrust/torrust-index-gui) project, which is our reference web application that consumes the API provided here. ![Torrust Architecture](https://raw.githubusercontent.com/torrust/.github/main/img/torrust-architecture.webp) @@ -18,39 +18,80 @@ We also provide the [Torrust Index Frontend](https://github.com/torrust/torrust- Requirements: -* Rust Stable `1.68` +* Rust Stable `1.72` -You can follow the [documentation](https://docs.rs/torrust-index-backend) to install and use Torrust Index Backend in different ways, but if you want to give it a quick try, you can use the following commands: +You can follow the [documentation](https://docs.rs/torrust-index) to install and use Torrust Index in different ways, but if you want to give it a quick try, you can use the following commands: ```s -git clone https://github.com/torrust/torrust-index-backend.git \ - && cd torrust-index-backend \ +git clone https://github.com/torrust/torrust-index.git \ + && cd torrust-index \ && cargo build --release ``` -And then run `cargo run` twice. The first time to generate the `config.toml` file and the second time to run the backend with the default configuration. +And then run `cargo run` twice. The first time to generate the `config.toml` file and the second time to run the index with the default configuration. -After running the tracker the API will be available at . +After running the index the API will be available at . ## Documentation -The technical documentation is available at [docs.rs](https://docs.rs/torrust-index-backend). +The technical documentation is available at [docs.rs](https://docs.rs/torrust-index). ## Contributing +This is an open-source community supported project.
We welcome contributions from the community! -How can you contribute? +__How can you contribute?__ -* Bug reports and feature requests. -* Code contributions. You can start by looking at the issues labeled ["good first issues"](https://github.com/torrust/torrust-index-backend/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). -* Documentation improvements. Check the [documentation](torrust-index-backend) for typos, errors, or missing information. -* Participation in the community. You can help by answering questions in the [discussions](https://github.com/torrust/torrust-index-backend/discussions). +- Bug reports and feature requests. +- Code contributions. You can start by looking at the issues labeled "[good first issues]". +- Documentation improvements. Check the [documentation] and [API documentation] for typos, errors, or missing information. +- Participation in the community. You can help by answering questions in the [discussions]. ## License -The project is licensed under a dual license. See [COPYRIGHT](./COPYRIGHT). +**Copyright (c) 2023 The Torrust Developers.** + +This program is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License][AGPL_3_0] as published by the [Free Software Foundation][FSF], version 3. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Affero General Public License][AGPL_3_0] for more details. + +You should have received a copy of the *GNU Affero General Public License* along with this program. If not, see . + +Some files include explicit copyright notices and/or license notices. + +### Legacy Exception + +For prosperity, versions of Torrust Index that are older than five years are automatically granted the [MIT-0][MIT_0] license in addition to the existing [AGPL-3.0-only][AGPL_3_0] license. + +## Contributions + +The copyright of the Torrust Index is retained by the respective authors. + +**Contributors agree:** +- That all their contributions be granted a license(s) **compatible** with the [Torrust Index License](#License). +- That all contributors signal **clearly** and **explicitly** any other compilable licenses if they are not: *[AGPL-3.0-only with the legacy MIT-0 exception](#License)*. + +**The Torrust-Index project has no copyright assignment agreement.** ## Acknowledgments This project was a joint effort by [Nautilus Cyberneering GmbH](https://nautilus-cyberneering.de/), [Dutch Bits](https://dutchbits.nl) and collaborators. Thank you to you all! + + +[coverage_wf]: ../../actions/workflows/coverage.yaml +[coverage_wf_b]: ../../actions/workflows/coverage.yaml/badge.svg +[deployment_wf]: ../../actions/workflows/deployment.yaml +[deployment_wf_b]: ../../actions/workflows/deployment.yaml/badge.svg +[testing_wf]: ../../actions/workflows/testing.yaml +[testing_wf_b]: ../../actions/workflows/testing.yaml/badge.svg + + +[good first issues]: https://github.com/torrust/torrust-index/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 +[documentation]: https://docs.rs/torrust-index/ +[API documentation]: https://docs.rs/torrust-index/latest/torrust_index/servers/apis/v1 +[discussions]: https://github.com/torrust/torrust-index/discussions + +[AGPL_3_0]: ./docs/licenses/LICENSE-AGPL_3_0 +[MIT_0]: ./docs/licenses/LICENSE-MIT_0 +[FSF]: https://www.fsf.org/ diff --git a/bin/install.sh b/bin/install.sh index 9700d421..c8fa79ae 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -8,7 +8,7 @@ fi # Generate storage directory if it does not exist mkdir -p "./storage/database" -# Generate the sqlite database for the index backend if it does not exist +# Generate the sqlite database for the index if it does not exist if ! [ -f "./storage/database/data.db" ]; then sqlite3 ./storage/database/data.db "VACUUM;" fi diff --git a/compose.yaml b/compose.yaml index 09956826..ad7b0dc7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -79,7 +79,7 @@ services: environment: - MYSQL_ROOT_HOST=% - MYSQL_ROOT_PASSWORD=root_secret_password - - MYSQL_DATABASE=${TORRUST_IDX_BACK_MYSQL_DATABASE:-torrust_index_backend_e2e_testing} + - MYSQL_DATABASE=${TORRUST_IDX_BACK_MYSQL_DATABASE:-torrust_index_e2e_testing} - MYSQL_USER=db_user - MYSQL_PASSWORD=db_user_secret_password networks: diff --git a/config-idx-back.mysql.local.toml b/config-idx-back.mysql.local.toml index beeda6e8..0e8bd2a8 100644 --- a/config-idx-back.mysql.local.toml +++ b/config-idx-back.mysql.local.toml @@ -20,7 +20,7 @@ max_password_length = 64 secret_key = "MaxVerstappenWC2021" [database] -connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend_e2e_testing" +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing" [mail] email_verification_enabled = false diff --git a/config-idx-back.sqlite.local.toml b/config-idx-back.sqlite.local.toml index f050b4fb..12dcbab0 100644 --- a/config-idx-back.sqlite.local.toml +++ b/config-idx-back.sqlite.local.toml @@ -20,7 +20,7 @@ max_password_length = 64 secret_key = "MaxVerstappenWC2021" [database] -connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc" +connect_url = "sqlite://storage/database/torrust_index_e2e_testing.db?mode=rwc" [mail] email_verification_enabled = false diff --git a/docker/README.md b/docker/README.md index cd17789d..73e50d08 100644 --- a/docker/README.md +++ b/docker/README.md @@ -35,12 +35,12 @@ docker run -it \ --user="$TORRUST_IDX_BACK_USER_UID" \ --publish 3001:3001/tcp \ --volume "$(pwd)/storage":"/app/storage" \ - torrust/index-backend + torrust/index ``` > NOTES: > -> - You have to create the SQLite DB (`data.db`) and configuration (`config.toml`) before running the index backend. See `bin/install.sh`. +> - You have to create the SQLite DB (`data.db`) and configuration (`config.toml`) before running the index. See `bin/install.sh`. > - You have to replace the user UID (`1000`) with yours. > - Remember to switch to your default docker context `docker context use default`. @@ -55,7 +55,7 @@ connect_url = "sqlite://storage/database/data.db?mode=rwc" to: ```toml -connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" +connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index" ``` If you want to inject an environment variable into docker-compose you can use the file `.env`. There is a template `.env.local`. @@ -148,7 +148,7 @@ mysql> show databases; | mysql | | performance_schema | | sys | -| torrust_index_backend | +| torrust_index | | torrust_tracker | +-----------------------+ 6 rows in set (0,00 sec) diff --git a/docker/bin/build.sh b/docker/bin/build.sh index 96766624..21be00a3 100755 --- a/docker/bin/build.sh +++ b/docker/bin/build.sh @@ -10,4 +10,4 @@ echo "TORRUST_IDX_BACK_RUN_AS_USER: $TORRUST_IDX_BACK_RUN_AS_USER" docker build \ --build-arg UID="$TORRUST_IDX_BACK_USER_UID" \ --build-arg RUN_AS_USER="$TORRUST_IDX_BACK_RUN_AS_USER" \ - -t torrust-index-backend . + -t torrust-index . diff --git a/docker/bin/e2e/mysql/e2e-env-reset.sh b/docker/bin/e2e/mysql/e2e-env-reset.sh index 9fb88f86..afe138ac 100755 --- a/docker/bin/e2e/mysql/e2e-env-reset.sh +++ b/docker/bin/e2e/mysql/e2e-env-reset.sh @@ -4,15 +4,15 @@ docker compose down -# Index Backend +# Index # Database credentials MYSQL_USER="root" MYSQL_PASSWORD="root_secret_password" MYSQL_HOST="localhost" -MYSQL_DATABASE="torrust_index_backend_e2e_testing" +MYSQL_DATABASE="torrust_index_e2e_testing" -# Create the MySQL database for the index backend. Assumes MySQL client is installed. +# Create the MySQL database for the index. Assumes MySQL client is installed. echo "Creating MySQL database $MYSQL_DATABASE for E2E testing ..." mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "DROP DATABASE IF EXISTS $MYSQL_DATABASE; CREATE DATABASE $MYSQL_DATABASE;" diff --git a/docker/bin/e2e/mysql/e2e-env-up.sh b/docker/bin/e2e/mysql/e2e-env-up.sh index 4bbbd9f7..9b83c782 100755 --- a/docker/bin/e2e/mysql/e2e-env-up.sh +++ b/docker/bin/e2e/mysql/e2e-env-up.sh @@ -5,7 +5,7 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.mysql.local.toml) \ - TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_backend_e2e_testing" \ + TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_e2e_testing" \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-mysql} \ TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ diff --git a/docker/bin/e2e/run-e2e-tests.sh b/docker/bin/e2e/run-e2e-tests.sh index a55b6315..cca2640a 100755 --- a/docker/bin/e2e/run-e2e-tests.sh +++ b/docker/bin/e2e/run-e2e-tests.sh @@ -51,7 +51,7 @@ echo "Running E2E tests using SQLite ..." ./docker/bin/e2e/sqlite/e2e-env-up.sh || exit 1 wait_for_container_to_be_healthy torrust-mysql-1 10 3 -# todo: implement healthchecks for tracker and backend and wait until they are healthy +# todo: implement healthchecks for tracker and index and wait until they are healthy #wait_for_container torrust-tracker-1 10 3 #wait_for_container torrust-idx-back-1 10 3 sleep 20s @@ -72,7 +72,7 @@ echo "Running E2E tests using MySQL ..." ./docker/bin/e2e/mysql/e2e-env-up.sh || exit 1 wait_for_container_to_be_healthy torrust-mysql-1 10 3 -# todo: implement healthchecks for tracker and backend and wait until they are healthy +# todo: implement healthchecks for tracker and index and wait until they are healthy #wait_for_container torrust-tracker-1 10 3 #wait_for_container torrust-idx-back-1 10 3 sleep 20s @@ -84,9 +84,9 @@ docker ps MYSQL_USER="root" MYSQL_PASSWORD="root_secret_password" MYSQL_HOST="localhost" -MYSQL_DATABASE="torrust_index_backend_e2e_testing" +MYSQL_DATABASE="torrust_index_e2e_testing" -# Create the MySQL database for the index backend. Assumes MySQL client is installed. +# Create the MySQL database for the index. Assumes MySQL client is installed. echo "Creating MySQL database $MYSQL_DATABASE for for E2E testing ..." mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE;" diff --git a/docker/bin/e2e/sqlite/e2e-env-reset.sh b/docker/bin/e2e/sqlite/e2e-env-reset.sh index e6d995a3..f0ff3a2d 100755 --- a/docker/bin/e2e/sqlite/e2e-env-reset.sh +++ b/docker/bin/e2e/sqlite/e2e-env-reset.sh @@ -4,15 +4,15 @@ docker compose down -rm -f ./storage/database/torrust_index_backend_e2e_testing.db +rm -f ./storage/database/torrust_index_e2e_testing.db rm -f ./storage/tracker/lib/database/torrust_tracker_e2e_testing.db # Generate storage directory if it does not exist mkdir -p "./storage/database" -# Generate the sqlite database for the index backend if it does not exist -if ! [ -f "./storage/database/torrust_index_backend_e2e_testing.db" ]; then - sqlite3 ./storage/database/torrust_index_backend_e2e_testing.db "VACUUM;" +# Generate the sqlite database for the index if it does not exist +if ! [ -f "./storage/database/torrust_index_e2e_testing.db" ]; then + sqlite3 ./storage/database/torrust_index_e2e_testing.db "VACUUM;" fi # Generate the sqlite database for the tracker if it does not exist diff --git a/docker/bin/e2e/sqlite/e2e-env-up.sh b/docker/bin/e2e/sqlite/e2e-env-up.sh index 8deca42f..b55cd564 100755 --- a/docker/bin/e2e/sqlite/e2e-env-up.sh +++ b/docker/bin/e2e/sqlite/e2e-env-up.sh @@ -5,7 +5,7 @@ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_USER_UID=${TORRUST_IDX_BACK_USER_UID:-1000} \ TORRUST_IDX_BACK_CONFIG=$(cat config-idx-back.sqlite.local.toml) \ - TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_backend_e2e_testing" \ + TORRUST_IDX_BACK_MYSQL_DATABASE="torrust_index_e2e_testing" \ TORRUST_TRACKER_CONFIG=$(cat config-tracker.local.toml) \ TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER:-sqlite3} \ TORRUST_TRACKER_API_ADMIN_TOKEN=${TORRUST_TRACKER_API_ADMIN_TOKEN:-MyAccessToken} \ diff --git a/docker/bin/run.sh b/docker/bin/run.sh index 48b86f02..19df5d3a 100755 --- a/docker/bin/run.sh +++ b/docker/bin/run.sh @@ -8,4 +8,4 @@ docker run -it \ --publish 3001:3001/tcp \ --env TORRUST_IDX_BACK_CONFIG="$TORRUST_IDX_BACK_CONFIG" \ --volume "$(pwd)/storage":"/app/storage" \ - torrust-index-backend + torrust-index diff --git a/LICENSE-AGPL_3_0 b/docs/licenses/LICENSE-AGPL_3_0 similarity index 100% rename from LICENSE-AGPL_3_0 rename to docs/licenses/LICENSE-AGPL_3_0 diff --git a/LICENSE-MIT_0 b/docs/licenses/LICENSE-MIT_0 similarity index 100% rename from LICENSE-MIT_0 rename to docs/licenses/LICENSE-MIT_0 diff --git a/licensing/agpl-3.0.md b/licensing/agpl-3.0.md deleted file mode 100644 index f2a1b1b6..00000000 --- a/licensing/agpl-3.0.md +++ /dev/null @@ -1,660 +0,0 @@ -# GNU AFFERO GENERAL PUBLIC LICENSE - -Version 3, 19 November 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. - - -Everyone is permitted to copy and distribute verbatim copies of this -license document, but changing it is not allowed. - -## Preamble - -The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains -free software for all its users. - -When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - -Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - -A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - -The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - -An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing -under this license. - -The precise terms and conditions for copying, distribution and -modification follow. - -## TERMS AND CONDITIONS - -### 0. Definitions - -"This License" refers to version 3 of the GNU Affero General Public -License. - -"Copyright" also means copyright-like laws that apply to other kinds -of works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of -an exact copy. The resulting work is called a "modified version" of -the earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based -on the Program. - -To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user -through a computer network, with no transfer of a copy, is not -conveying. - -An interactive user interface displays "Appropriate Legal Notices" to -the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -### 1. Source Code - -The "source code" for a work means the preferred form of the work for -making modifications to it. "Object code" means any non-source form of -a work. - -A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users can -regenerate automatically from other parts of the Corresponding Source. - -The Corresponding Source for a work in source code form is that same -work. - -### 2. Basic Permissions - -All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not convey, -without conditions so long as your license otherwise remains in force. -You may convey covered works to others for the sole purpose of having -them make modifications exclusively for you, or provide you with -facilities for running those works, provided that you comply with the -terms of this License in conveying all material for which you do not -control copyright. Those thus making or running the covered works for -you must do so exclusively on your behalf, under your direction and -control, on terms that prohibit them from making any copies of your -copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under the -conditions stated below. Sublicensing is not allowed; section 10 makes -it unnecessary. - -### 3. Protecting Users' Legal Rights From Anti-Circumvention Law - -No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such -circumvention is effected by exercising rights under this License with -respect to the covered work, and you disclaim any intention to limit -operation or modification of the work as a means of enforcing, against -the work's users, your or third parties' legal rights to forbid -circumvention of technological measures. - -### 4. Conveying Verbatim Copies - -You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - -### 5. Conveying Modified Source Versions - -You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these -conditions: - -- a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. -- b) The work must carry prominent notices stating that it is - released under this License and any conditions added under - section 7. This requirement modifies the requirement in section 4 - to "keep intact all notices". -- c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. -- d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - -A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - -### 6. Conveying Non-Source Forms - -You may convey a covered work in object code form under the terms of -sections 4 and 5, provided that you also convey the machine-readable -Corresponding Source under the terms of this License, in one of these -ways: - -- a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. -- b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the Corresponding - Source from a network server at no charge. -- c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. -- d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. -- e) Convey the object code using peer-to-peer transmission, - provided you inform other peers where the object code and - Corresponding Source of the work are being offered to the general - public at no charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, -family, or household purposes, or (2) anything designed or sold for -incorporation into a dwelling. In determining whether a product is a -consumer product, doubtful cases shall be resolved in favor of -coverage. For a particular product received by a particular user, -"normally used" refers to a typical or common use of that class of -product, regardless of the status of the particular user or of the way -in which the particular user actually uses, or expects or is expected -to use, the product. A product is a consumer product regardless of -whether the product has substantial commercial, industrial or -non-consumer uses, unless such uses represent the only significant -mode of use of the product. - -"Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to -install and execute modified versions of a covered work in that User -Product from a modified version of its Corresponding Source. The -information must suffice to ensure that the continued functioning of -the modified object code is in no case prevented or interfered with -solely because modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or -updates for a work that has been modified or installed by the -recipient, or for the User Product in which it has been modified or -installed. Access to a network may be denied when the modification -itself materially and adversely affects the operation of the network -or violates the rules and protocols for communication across the -network. - -Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - -### 7. Additional Terms - -"Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders -of that material) supplement the terms of this License with terms: - -- a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or -- b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or -- c) Prohibiting misrepresentation of the origin of that material, - or requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or -- d) Limiting the use for publicity purposes of names of licensors - or authors of the material; or -- e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or -- f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions - of it) with contractual assumptions of liability to the recipient, - for any liability that these contractual assumptions directly - impose on those licensors and authors. - -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; the -above requirements apply either way. - -### 8. Termination - -You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - -However, if you cease all violation of this License, then your license -from a particular copyright holder is reinstated (a) provisionally, -unless and until the copyright holder explicitly and finally -terminates your license, and (b) permanently, if the copyright holder -fails to notify you of the violation by some reasonable means prior to -60 days after the cessation. - -Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - -### 9. Acceptance Not Required for Having Copies - -You are not required to accept this License in order to receive or run -a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - -### 10. Automatic Licensing of Downstream Recipients - -Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - -### 11. Patents - -A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims owned -or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - -If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - -A patent license is "discriminatory" if it does not include within the -scope of its coverage, prohibits the exercise of, or is conditioned on -the non-exercise of one or more of the rights that are specifically -granted under this License. You may not convey a covered work if you -are a party to an arrangement with a third party that is in the -business of distributing software, under which you make payment to the -third party based on the extent of your activity of conveying the -work, and under which the third party grants, to any of the parties -who would receive the covered work from you, a discriminatory patent -license (a) in connection with copies of the covered work conveyed by -you (or copies made from those copies), or (b) primarily for and in -connection with specific products or compilations that contain the -covered work, unless you entered into that arrangement, or that patent -license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - -### 12. No Surrender of Others' Freedom - -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under -this License and any other pertinent obligations, then as a -consequence you may not convey it at all. For example, if you agree to -terms that obligate you to collect a royalty for further conveying -from those to whom you convey the Program, the only way you could -satisfy both those terms and this License would be to refrain entirely -from conveying the Program. - -### 13. Remote Network Interaction; Use with the GNU General Public License - -Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your -version supports such interaction) an opportunity to receive the -Corresponding Source of your version by providing access to the -Corresponding Source from a network server at no charge, through some -standard or customary means of facilitating copying of software. This -Corresponding Source shall include the Corresponding Source for any -work covered by version 3 of the GNU General Public License that is -incorporated pursuant to the following paragraph. - -Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - -### 14. Revised Versions of this License - -The Free Software Foundation may publish revised and/or new versions -of the GNU Affero General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever -published by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future versions -of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - -Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - -### 15. Disclaimer of Warranty - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT -WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND -PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE -DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR -CORRECTION. - -### 16. Limitation of Liability - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR -CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES -ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT -NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR -LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM -TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER -PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -### 17. Interpretation of Sections 15 and 16 - -If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - -END OF TERMS AND CONDITIONS - -## How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these -terms. - -To do so, attach the following notices to the program. It is safest to -attach them to the start of each source file to most effectively state -the exclusion of warranty; and each file should have at least the -"copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as - published by the Free Software Foundation, either version 3 of the - License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper -mail. - -If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for -the specific requirements. - -You should also get your employer (if you work as a programmer) or -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. For more information on this, and how to apply and follow -the GNU AGPL, see . diff --git a/licensing/cc-by-sa.md b/licensing/cc-by-sa.md deleted file mode 100644 index d9eb1cc2..00000000 --- a/licensing/cc-by-sa.md +++ /dev/null @@ -1,175 +0,0 @@ -# Creative Commons Attribution-ShareAlike 4.0 International - - - -Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. - -## Using Creative Commons Public Licenses - -Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. - -* __Considerations for licensors:__ Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors). - -* __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees). - -## Creative Commons Attribution-ShareAlike 4.0 International Public License - -By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. - -### Section 1 – Definitions - -a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. - -b. __Adapter's License__ means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. - -c. __BY-SA Compatible License__ means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License. - -d. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. - -e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. - -f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. - -g. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike. - -h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License. - -i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. - -j. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License. - -k. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. - -l. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. - -m. __You__ means the individual or entity exercising the Licensed Rights under this Public License. __Your__ has a corresponding meaning. - -### Section 2 – Scope - -a. ___License grant.___ - - 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: - - A. reproduce and Share the Licensed Material, in whole or in part; and - - B. produce, reproduce, and Share Adapted Material. - - 2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. - - 3. __Term.__ The term of this Public License is specified in Section 6(a). - - 4. __Media and formats; technical modifications allowed.__ The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. - - 5. __Downstream recipients.__ - - A. __Offer from the Licensor – Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. - - B. __Additional offer from the Licensor – Adapted Material.__ Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply. - - C. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. - - 6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). - -b. ___Other rights.___ - - 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. - - 2. Patent and trademark rights are not licensed under this Public License. - - 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. - -### Section 3 – License Conditions - -Your exercise of the Licensed Rights is expressly made subject to the following conditions. - -a. ___Attribution.___ - - 1. If You Share the Licensed Material (including in modified form), You must: - - A. retain the following if it is supplied by the Licensor with the Licensed Material: - - i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); - - ii. a copyright notice; - - iii. a notice that refers to this Public License; - - iv. a notice that refers to the disclaimer of warranties; - - v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; - - B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and - - C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. - - 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. - - 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. - -b. ___ShareAlike.___ - -In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. - -1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License. - -2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. - -3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. - -### Section 4 – Sui Generis Database Rights - -Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: - -a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; - -b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and - -c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. - -For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. - -### Section 5 – Disclaimer of Warranties and Limitation of Liability - -a. __Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.__ - -b. __To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.__ - -c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. - -### Section 6 – Term and Termination - -a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. - -b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: - - 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or - - 2. upon express reinstatement by the Licensor. - - For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. - -c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. - -d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. - -### Section 7 – Other Terms and Conditions - -a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. - -b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. - -### Section 8 – Interpretation - -a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. - -b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. - -c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. - -d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. - -> Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” The text of the Creative Commons public licenses is dedicated to the public domain under the [CC0 Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0/legalcode). Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. -> -> Creative Commons may be contacted at creativecommons.org. - - diff --git a/licensing/contributor_agreement_v01.md b/licensing/contributor_agreement_v01.md deleted file mode 100644 index 13397c61..00000000 --- a/licensing/contributor_agreement_v01.md +++ /dev/null @@ -1,103 +0,0 @@ -# The Torrust Contributor Agreement - - - -[Version 2021-11-10] - -## Goal - -We require that contributors to Torrust (as defined below) agree to this Torrust Contributor Agreement (NCCA) to ensure that contributions to Torrust have acceptable licensing terms. - -## Non-Goals - -The NCCA is _not_ a copyright assignment agreement. - -The NCCA does _not_ somehow supersede the existing licensing terms that apply to Torrust contributions. There are two important subpoints here. First, the NCCA does not apply to upstream code (or other material) that you didn't write; indeed, it would be preposterous for it to attempt to do so. Note the narrow way in which we have defined capital-c "Contribution". - -Second, the main provision of the NCCA specifies that a default license will apply to code that you wrote, but only to the extent that you have not bothered to put an explicit license on it. Therefore, the NCCA is _not_ some sort of special permissive license granted to any party, despite the explicit choice of a more restrictive license by you or by upstream developers. - -## Terms - -### Section 1 – Definitions - -__"Acceptable License For Torrust"__ means a license selected from the appropriate categorical sublist of the full list of acceptable licenses for Torrust, currently located at , as that list may be revised from time to time by Torrust. "Acceptable Licenses For Torrust" means that full list. - -__"AGPLv3"__ means the license identified as "Affero General Public License 3.0", as published at . - -__"CC-0"__ means the Creative Commons 1.0 Universal license, as published at . - -__"CC-BY-SA"__ means the Creative Commons Attribution-ShareAlike 4.0 International license, as published at . - -__"Code"__ means (i) software code, (ii) any other functional material whose principal purpose is to control or facilitate the building of packages, such as an RPM spec file, (iii) font files, and (iv) other kinds of copyrightable material that the Torrust has classified as "code" rather than "content". - -__"Content"__ means any copyrightable material that is not Code, such as, without limitation, (i) non-functional data sets, (ii) documentation, (iii) wiki edits, (iv) music files, (v) graphic image files, (vi) help files, and (vii) other kinds of copyrightable material that the Torrust Council has classified as "content" rather than "code". - -__"Contribution"__ means a Work that You created, excluding any portion that was created by someone else. (For example, if You Submit a package to Torrust, the spec file You write may be a Contribution, but all the upstream code in the associated Package that You did not write is not a Contribution for purposes of this NCCA.) A Contribution consists either of Code or of Content. - -__"Current Default License"__, with respect to a Contribution, means (i) if the Contribution is Code, the AGPLv3 License, and (ii) if the Contribution is Content, CC-BY-SA. - -__"Future Public Domain License"__, with respect to a Contribution, means (i) if the Contribution is Code, the MIT-0 License, and (ii) if the Contribution is Content, CC-0. - -__"Licensed"__ means covered by explicit licensing terms that are conspicuous and readily discernible to recipients. -"Submit" means to use some mode of digital communication (for example, without limitation, mailing lists, bug tracking systems, and source code version control systems administered by Torrust) to voluntarily provide a Contribution to Torrust. -"Unlicensed" means not Licensed. - -__"MIT-0"__ means the license identified as "MIT No Attribution", as published at . - -__"Torrust"__ means the community project led by the Torrust . -"Torrust Community" means (i) all Torrust participants, and (ii) all persons receiving Contributions directly or indirectly from or through Torrust. - -__"Work"__ means a copyrightable work of authorship. A Work may be a portion of a larger Work, and a Work may be a modification of or addition to another Work. "You" means the individual accepting this instance of the NCCA. - -### Section 2 – List of Acceptable Licenses for Torrust - -- CC-BY-SA -- CC-0 -- AGPLv3 -- MIT-0 - -### Section 3 – Copyright Permission Required for All Contributions - -If You are not the copyright holder of a given Contribution that You wish to Submit to Torrust (for example, if Your employer or university holds copyright in it), it is Your responsibility to first obtain authorization from the copyright holder to Submit the Contribution under the terms of this NCCA on behalf of, or otherwise with the permission of, that copyright holder. One form of such authorization is for the copyright holder to place, or permit You to place, an Acceptable License For Torrust on the Contribution. - -### Section 4 – Licensed Contributions - -If Your Contribution is Licensed, Your Contribution will be governed by the terms under which it has been licensed. - -### Section 5 – Default Licensing of Unlicensed Contributions - -If You Submit an Unlicensed Contribution to Torrust, the license to the Torrust Community for that Contribution shall be the Current Default License. - -The Torrust may, by public announcement, subsequently designate an additional or alternative default license for a given category of Contribution (a "Later Default License"). A Later Default License shall be chosen from the appropriate categorical sublist of Acceptable Licenses For Torrust. -Once a Later Default License has been designated, Your Unlicensed Contribution shall also be licensed to the Torrust Community under that Later Default License. Such designation shall not affect the continuing applicability of the Current Default License to Your Contribution. - -You consent to having Torrust provide reasonable notice of Your licensing of Your Contribution under the Current Default License (and, if applicable, a Later Default License) in a manner determined by Torrust. - -### Section 6 – Automatic Future Public Domain License - -You consent that your contribution under the Current Default License is granted the Future Public Domain License __automatically__ after 5 years of submission. - -### Section 7 – Public Domain United States Government Works - -Sections 3 through 6 of this NCCA do not apply to any Contribution to the extent that it is a work of the United States Government for which copyright is unavailable under 17 U.S.C. 105. - -### Section 8 – Acceptance - -You must signify Your assent to the terms of this NCCA through specific electronic means established by Torrust. - -You may also, at Your option, and without eliminating the requirement set forth in the preceding paragraph, send a copy of this NCCA, bearing Your written signature indicating Your acceptance of its terms, by email to legal@torrust.com, or by postal mail to: - - Torrust Legal - c/o Nautilus Cyberneering GmbH - Oberhachingerstr. 46B - 2031 Grünwald - Germany - -### Section 9 – Notes - -This document is based upon: - -[The Fedora Project Contributor Agreement](https://fedoraproject.org/w/index.php?title=Legal:Fedora_Project_Contributor_Agreement&oldid=629385). -[Version 2021-05-04] - - diff --git a/licensing/file_header_agplv3.txt b/licensing/file_header_agplv3.txt deleted file mode 100644 index 0fe415a5..00000000 --- a/licensing/file_header_agplv3.txt +++ /dev/null @@ -1,21 +0,0 @@ - Torrust Index - - Project owner: Nautilus Cyberneering GmbH. - Github repository: https://github.com/torrust/torrust - Project description: - Torrust is a suite of client-server software for hosting online torrent indexes. - - Copyright (C) 2021 Nautilus Cyberneering GmbH - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as - published by the Free Software Foundation, either version 3 of the - License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . diff --git a/licensing/old_commits/cc0.md b/licensing/old_commits/cc0.md deleted file mode 100644 index 2b04180a..00000000 --- a/licensing/old_commits/cc0.md +++ /dev/null @@ -1,45 +0,0 @@ -# Creative Commons CC0 1.0 Universal - - - -CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. - -## Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. - -For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. - -1. __Copyright and Related Rights.__ A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; - - ii. moral rights retained by the original author(s) and/or performer(s); - - iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; - - iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; - - v. rights protecting the extraction, dissemination, use and reuse of data in a Work; - - vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and - - vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. - -2. __Waiver.__ To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. - -3. __Public License Fallback.__ Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. - -4. __Limitations and Disclaimers.__ - - a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. - - b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. - - c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. - - d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. - - diff --git a/licensing/old_commits/mit-0.md b/licensing/old_commits/mit-0.md deleted file mode 100644 index e08ee6e9..00000000 --- a/licensing/old_commits/mit-0.md +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright 2021 Nautilus Cyberneering GmbH - -Permission is hereby granted, free of charge, to any person obtaining a copy of this -software and associated documentation files (the "Software"), to deal in the Software -without restriction, including without limitation the rights to use, copy, modify, -merge, publish, distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index 894863fa..a405248b 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -3,7 +3,7 @@ //! It imports the number of seeders and leechers for all torrent from the linked tracker. //! //! You can execute it with: `cargo run --bin import_tracker_statistics` -use torrust_index_backend::console::commands::import_tracker_statistics::run_importer; +use torrust_index::console::commands::import_tracker_statistics::run_importer; #[tokio::main] async fn main() { diff --git a/src/bin/main.rs b/src/bin/main.rs index 5660be68..68d0b3ea 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,6 +1,6 @@ -use torrust_index_backend::app; -use torrust_index_backend::bootstrap::config::init_configuration; -use torrust_index_backend::web::api::Version; +use torrust_index::app; +use torrust_index::bootstrap::config::init_configuration; +use torrust_index::web::api::Version; #[tokio::main] async fn main() -> Result<(), std::io::Error> { diff --git a/src/bin/parse_torrent.rs b/src/bin/parse_torrent.rs index ccef09a9..693d9249 100644 --- a/src/bin/parse_torrent.rs +++ b/src/bin/parse_torrent.rs @@ -7,7 +7,7 @@ use std::io::{self, Read}; use serde_bencode::de::from_bytes; use serde_bencode::value::Value as BValue; -use torrust_index_backend::utils::parse_torrent; +use torrust_index::utils::parse_torrent; fn main() -> io::Result<()> { let args: Vec = env::args().collect(); diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index 486bde93..fd072f4f 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -2,7 +2,7 @@ //! It updates the application from version v1.0.0 to v2.0.0. //! You can execute it with: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads` -use torrust_index_backend::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run; +use torrust_index::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run; #[tokio::main] async fn main() { diff --git a/src/config.rs b/src/config.rs index abf596d3..86d7810f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -254,9 +254,9 @@ impl Default for ImageCache { } } -/// The whole configuration for the backend. +/// The whole configuration for the index. #[derive(Debug, Default, Clone, Serialize, Deserialize)] -pub struct TorrustBackend { +pub struct TorrustIndex { /// Logging level. Possible values are: `Off`, `Error`, `Warn`, `Info`, /// `Debug` and `Trace`. Default is `Info`. pub log_level: Option, @@ -284,7 +284,7 @@ pub struct TorrustBackend { #[derive(Debug)] pub struct Configuration { /// The state of the configuration. - pub settings: RwLock, + pub settings: RwLock, /// The path to the configuration file. This is `None` if the configuration /// was loaded from the environment. pub config_path: Option, @@ -293,7 +293,7 @@ pub struct Configuration { impl Default for Configuration { fn default() -> Configuration { Configuration { - settings: RwLock::new(TorrustBackend::default()), + settings: RwLock::new(TorrustIndex::default()), config_path: None, } } @@ -325,7 +325,7 @@ impl Configuration { ))); } - let torrust_config: TorrustBackend = match config.try_deserialize() { + let torrust_config: TorrustIndex = match config.try_deserialize() { Ok(data) => Ok(data), Err(e) => Err(ConfigError::Message(format!("Errors while processing config: {e}."))), }?; @@ -349,7 +349,7 @@ impl Configuration { let config_builder = Config::builder() .add_source(File::from_str(&config_toml, FileFormat::Toml)) .build()?; - let torrust_config: TorrustBackend = config_builder.try_deserialize()?; + let torrust_config: TorrustIndex = config_builder.try_deserialize()?; Ok(Configuration { settings: RwLock::new(torrust_config), config_path: None, @@ -376,7 +376,7 @@ impl Configuration { fs::write(config_path, toml_string).expect("Could not write to file!"); } - pub async fn get_all(&self) -> TorrustBackend { + pub async fn get_all(&self) -> TorrustIndex { let settings_lock = self.settings.read().await; settings_lock.clone() @@ -406,7 +406,7 @@ impl Configuration { } } -/// The public backend configuration. +/// The public index configuration. /// There is an endpoint to get this configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigurationPublic { diff --git a/src/lib.rs b/src/lib.rs index 8712093f..397dc04f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,9 @@ -//! Documentation for [Torrust Tracker Index Backend](https://github.com/torrust/torrust-index-backend) API. +//! Documentation for [Torrust Tracker Index](https://github.com/torrust/torrust-index) API. //! -//! This is the backend API for [Torrust Tracker Index](https://github.com/torrust/torrust-index). +//! This is the index API for [Torrust Tracker Index](https://github.com/torrust/torrust-index). //! //! It is written in Rust and uses the [Axum](https://github.com/tokio-rs/axum) framework. It is designed to be -//! used with by the [Torrust Tracker Index Frontend](https://github.com/torrust/torrust-index-frontend). +//! used with by the [Torrust Tracker Index Gui](https://github.com/torrust/torrust-index-gui). //! //! If you are looking for information on how to use the API, please see the //! [API v1](crate::web::api::v1) section of the documentation. @@ -39,7 +39,7 @@ //! //! - A REST [API](crate::web::api::v1) //! -//! From the administrator perspective, the Torrust Index Backend exposes: +//! From the administrator perspective, the Torrust Index exposes: //! //! - A console command to update torrents statistics from the associated tracker //! - A console command to upgrade the database schema from version `1.0.0` to `2.0.0` @@ -52,8 +52,8 @@ //! //! ## Prerequisites //! -//! In order the run the backend you will need a running torrust tracker. In the -//! configuration you need to fill the `backend` section with the following: +//! In order the run the index you will need a running torrust tracker. In the +//! configuration you need to fill the `index` section with the following: //! //! ```toml //! [tracker] @@ -67,7 +67,7 @@ //! Refer to the [`config::tracker`](crate::config::Tracker) documentation for more information. //! //! You can follow the tracker installation instructions [here](https://docs.rs/torrust-tracker) -//! or you can use the docker to run both the tracker and the backend. Refer to the +//! or you can use the docker to run both the tracker and the index. Refer to the //! [Run with docker](#run-with-docker) section for more information. //! //! If you are using `SQLite3` as database driver, you will need to install the @@ -91,13 +91,13 @@ //! //! The default configuration expects a directory `./storage/database` to be writable by the app process. //! -//! By default the backend uses `SQLite` and the database file name `data.db`. +//! By default the index uses `SQLite` and the database file name `data.db`. //! //! ## Install from sources //! //! ```text -//! git clone git@github.com:torrust/torrust-index-backend.git \ -//! && cd torrust-index-backend \ +//! git clone git@github.com:torrust/torrust-index.git \ +//! && cd torrust-index \ //! && cargo build --release \ //! && mkdir -p ./storage/database //! ``` @@ -106,7 +106,7 @@ //! //! ## Run with docker //! -//! You can run the backend with a pre-built docker image: +//! You can run the index with a pre-built docker image: //! //! ```text //! mkdir -p ./storage/database \ @@ -115,10 +115,10 @@ //! --user="$TORRUST_IDX_BACK_USER_UID" \ //! --publish 3001:3001/tcp \ //! --volume "$(pwd)/storage":"/app/storage" \ -//! torrust/index-backend +//! torrust/index //! ``` //! -//! For more information about using docker visit the [tracker docker documentation](https://github.com/torrust/torrust-index-backend/tree/develop/docker). +//! For more information about using docker visit the [tracker docker documentation](https://github.com/torrust/torrust-index/tree/develop/docker). //! //! ## Development //! @@ -146,10 +146,10 @@ //! > **NOTICE**: Refer to the [sqlx-cli](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli) //! documentation for other commands to create new migrations or run them. //! -//! > **NOTICE**: You can run the backend with [tmux](https://github.com/tmux/tmux/wiki) with `tmux new -s torrust-index-backend`. +//! > **NOTICE**: You can run the index with [tmux](https://github.com/tmux/tmux/wiki) with `tmux new -s torrust-index`. //! //! # Configuration -//! In order to run the backend you need to provide the configuration. If you run the backend without providing the configuration, +//! In order to run the index you need to provide the configuration. If you run the index without providing the configuration, //! the tracker will generate the default configuration the first time you run it. It will generate a `config.toml` file with //! in the root directory. //! @@ -213,7 +213,7 @@ //! //! In the previous example you are just setting the env var with the contents of the `config.toml` file. //! -//! The env var contains the same data as the `config.toml`. It's particularly useful in you are [running the backend with docker](https://github.com/torrust/torrust-index-backend/tree/develop/docker). +//! The env var contains the same data as the `config.toml`. It's particularly useful in you are [running the index with docker](https://github.com/torrust/torrust-index/tree/develop/docker). //! //! > **NOTICE**: The `TORRUST_IDX_BACK_CONFIG` env var has priority over the `config.toml` file. //! @@ -223,7 +223,7 @@ //! //! ## API //! -//! Running the backend with the default configuration will expose the REST API on port 3001: +//! Running the index with the default configuration will expose the REST API on port 3001: //! //! ## Tracker Statistics Importer //! @@ -244,18 +244,18 @@ //! //! If you want to contribute to this documentation you can: //! -//! - [Open a new discussion](https://github.com/torrust/torrust-index-backend/discussions) -//! - [Open a new issue](https://github.com/torrust/torrust-index-backend/issues). -//! - [Open a new pull request](https://github.com/torrust/torrust-index-backend/pulls). +//! - [Open a new discussion](https://github.com/torrust/torrust-index/discussions) +//! - [Open a new issue](https://github.com/torrust/torrust-index/issues). +//! - [Open a new pull request](https://github.com/torrust/torrust-index/pulls). //! //! # Documentation //! -//! You can find this documentation on [docs.rs](https://docs.rs/torrust-index-backend/). +//! You can find this documentation on [docs.rs](https://docs.rs/torrust-index/). //! -//! If you want to contribute to this documentation you can [open a new pull request](https://github.com/torrust/torrust-index-backend/pulls). +//! If you want to contribute to this documentation you can [open a new pull request](https://github.com/torrust/torrust-index/pulls). //! //! In addition to the production code documentation you can find a lot of -//! examples in the [tests](https://github.com/torrust/torrust-index-backend/tree/develop/tests/e2e/contexts) directory. +//! examples in the [tests](https://github.com/torrust/torrust-index/tree/develop/tests/e2e/contexts) directory. pub mod app; pub mod bootstrap; pub mod cache; diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs index 16b43bc3..0b111031 100644 --- a/src/models/info_hash.rs +++ b/src/models/info_hash.rs @@ -150,7 +150,7 @@ //! } //! ``` //! -//! Refer to the struct [`TorrentInfo`](crate::models::torrent_file::TorrentInfo) for more info. +//! Refer to the struct [`TorrentInfoDictionary`](crate::models::torrent_file::TorrentInfoDictionary) for more info. //! //! Regarding the `source` field, it is not clear was was the initial intention //! for that field. It could be an string to identify the source of the torrent. diff --git a/src/services/about.rs b/src/services/about.rs index 100822d8..82175bf6 100644 --- a/src/services/about.rs +++ b/src/services/about.rs @@ -15,11 +15,11 @@ pub fn page() -> String { About -

Torrust Index Backend

+

Torrust Index

About

-

Hi! This is a running torrust-index-backend.

+

Hi! This is a running torrust-index.