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 e7d0babb..041e059a 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; @@ -425,7 +426,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 +446,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,8 +457,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) + .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 { @@ -472,6 +473,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()) @@ -573,6 +595,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 = ?", diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index c300c188..b093952b 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; @@ -413,7 +414,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 +434,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,15 +445,15 @@ 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") { + } else if err.message().contains("UNIQUE") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { database::Error::Error @@ -460,6 +462,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 +560,10 @@ impl Database for Sqlite { .await .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") { + } else if err.message().contains("UNIQUE") && err.message().contains("title") { database::Error::TorrentTitleAlreadyExists } else { database::Error::Error @@ -561,6 +585,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 +724,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..1c6528ef 100644 --- a/src/tracker/service.rs +++ b/src/tracker/service.rs @@ -147,12 +147,18 @@ 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/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.