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 {