From 83d31f2f9fdeffe99989901a5f4223c6b2f557e5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 12 Sep 2023 17:01:13 +0100 Subject: [PATCH] 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()), } }