diff --git a/migrations/mysql/20230803160604_torrust_torrents_add_source.sql b/migrations/mysql/20230803160604_torrust_torrents_add_source.sql new file mode 100644 index 00000000..5bee0b38 --- /dev/null +++ b/migrations/mysql/20230803160604_torrust_torrents_add_source.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN source TEXT DEFAULT NULL diff --git a/migrations/sqlite3/20230803160604_torrust_torrents_add_source.sql b/migrations/sqlite3/20230803160604_torrust_torrents_add_source.sql new file mode 100644 index 00000000..5bee0b38 --- /dev/null +++ b/migrations/sqlite3/20230803160604_torrust_torrents_add_source.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_torrents ADD COLUMN source TEXT DEFAULT NULL diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 95a77812..ecde3bf7 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -441,7 +441,7 @@ impl Database for Mysql { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -451,6 +451,7 @@ impl Database for Mysql { .bind(torrent.info.piece_length) .bind(private) .bind(root_hash) + .bind(torrent.info.source.clone()) .execute(&self.pool) .await .map(|v| i64::try_from(v.last_insert_id()).expect("last ID is larger than i64")) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 2c69169b..98cb836f 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -431,7 +431,7 @@ impl Database for Sqlite { let private = torrent.info.private.unwrap_or(0); // add torrent - let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") + let torrent_id = query("INSERT INTO torrust_torrents (uploader_id, category_id, info_hash, size, name, pieces, piece_length, private, root_hash, `source`, date_uploaded) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%S',DATETIME('now', 'utc')))") .bind(uploader_id) .bind(category_id) .bind(info_hash.to_lowercase()) @@ -441,6 +441,7 @@ impl Database for Sqlite { .bind(torrent.info.piece_length) .bind(private) .bind(root_hash) + .bind(torrent.info.source.clone()) .execute(&self.pool) .await .map(|v| v.last_insert_rowid()) diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs index 1908b674..16b43bc3 100644 --- a/src/models/info_hash.rs +++ b/src/models/info_hash.rs @@ -128,7 +128,75 @@ //! //! You can hash that byte string with //! +//! > NOTICE: you need to remove the line breaks from the byte string before hashing. +//! +//! ```text +//! 64363a6c656e6774686931373232303465343a6e616d6532343a6d616e64656c62726f745f3230343878323034382e706e6731323a7069656365206c656e67746869313633383465363a7069656365733232303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c779621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290e5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f98e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61a6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866266f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e7454f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc110bb2fc2b49a585b6fc7674449365 +//! ``` +//! //! The result is a 20-char string: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +//! +//! The `info` dictionary can contain more fields, like the following example: +//! +//! ```json +//! { +//! "length": 172204, +//! "name": "mandelbrot_2048x2048.png", +//! "piece length": 16384, +//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" +//! "private": 1, +//! "md5sum": "e2ea6317cbdf0f9e223f9cc80af54000 +//! "source": "GGn", +//! } +//! ``` +//! +//! Refer to the struct [`TorrentInfo`](crate::models::torrent_file::TorrentInfo) for more info. +//! +//! Regarding the `source` field, it is not clear was was the initial intention +//! for that field. It could be an string to identify the source of the torrent. +//! But it has been used by private trackers to identify the tracker that +//! created the torrent and it's usually a three-char string. Refer to +//! for more info. +//! +//! The `md5sum` field is a string with the MD5 hash of the file. It seems is +//! not used by the protocol. +//! +//! Some fields are exclusive to `BitTorrent` v2. +//! +//! For the [`]BitTorrent` Version 1 specification](https://www.bittorrent.org/beps/bep_0003.html) there are two types of torrent +//! files: single file and multiple files. Some fields are only valid for one +//! type of torrent file. +//! +//! An example for a single-file torrent info dictionary: +//! +//! ```json +//! { +//! "length": 11, +//! "name": "sample.txt", +//! "piece length": 16384, +//! "pieces": "D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A" +//! } +//! ``` +//! +//! An example for a multi-file torrent info dictionary: +//! +//! ```json +//! { +//! "files": [ +//! { +//! "length": 11, +//! "path": [ +//! "sample.txt" +//! ] +//! } +//! ], +//! "name": "sample", +//! "piece length": 16384, +//! "pieces": "D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A" +//! } +//! ``` +//! +//! An example torrent creator implementation can be found [here](https://www.bittorrent.org/beps/bep_0052_torrent_creator.py). use std::panic::Location; use thiserror::Error; diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index 8489d90b..16080481 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -38,6 +38,8 @@ pub struct TorrentInfo { #[serde(default)] #[serde(rename = "root hash")] pub root_hash: Option, + #[serde(default)] + pub source: Option, } impl TorrentInfo { @@ -123,6 +125,7 @@ impl Torrent { private, path: None, root_hash: None, + source: None, }; // a torrent file has a root hash or a pieces key, but not both. @@ -295,3 +298,231 @@ pub struct DbTorrentInfo { pub struct DbTorrentAnnounceUrl { pub tracker_url: String, } + +#[cfg(test)] +mod tests { + + mod info_hash_calculation_for_version_v1 { + + use serde_bytes::ByteBuf; + + use crate::models::torrent_file::{Torrent, TorrentInfo}; + + #[test] + fn the_parsed_torrent_file_should_calculated_the_torrent_info_hash() { + /* The sample.txt content (`mandelbrot`): + + ``` + 6d616e64656c62726f740a + ``` + + The sample.txt.torrent content: + + ``` + 6431303a6372656174656420627931383a71426974746f7272656e742076 + 342e352e3431333a6372656174696f6e2064617465693136393131343935 + 373265343a696e666f64363a6c656e67746869313165343a6e616d653130 + 3a73616d706c652e74787431323a7069656365206c656e67746869313633 + 383465363a70696563657332303ad491587f1c42dff0cb0ff5c2b8cefe22 + b3ad310a6565 + ``` + + ```json + { + "created by": "qBittorrent v4.5.4", + "creation date": 1691149572, + "info": { + "length": 11, + "name": "sample.txt", + "piece length": 16384, + "pieces": "D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A" + } + } + ``` + */ + + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: None, + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + } + + mod infohash_should_be_calculated_for { + + use serde_bytes::ByteBuf; + + use crate::models::torrent_file::{Torrent, TorrentFile, TorrentInfo}; + + #[test] + fn a_simple_single_file_torrent() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: None, + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca"); + } + + #[test] + fn a_simple_multi_file_torrent() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: None, + files: Some(vec![TorrentFile { + path: vec!["sample.txt".to_string()], + length: sample_data_in_txt_file.len().try_into().unwrap(), + md5sum: None, + }]), + private: None, + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "aa2aca91ab650c4d249c475ca3fa604f2ccb0d2a"); + } + + #[test] + fn a_simple_single_file_torrent_with_a_source() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: None, + path: None, + root_hash: None, + source: Some("ABC".to_string()), // The tracker three-letter code + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "ccc1cf4feb59f3fa85c96c9be1ebbafcfe8a9cc8"); + } + + #[test] + fn a_simple_single_file_private_torrent() { + let sample_data_in_txt_file = "mandelbrot\n"; + + let info = TorrentInfo { + name: "sample.txt".to_string(), + pieces: Some(ByteBuf::from(vec![ + // D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A // hex + 212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec + ])), + piece_length: 16384, + md5sum: None, + length: Some(sample_data_in_txt_file.len().try_into().unwrap()), + files: None, + private: Some(1), + path: None, + root_hash: None, + source: None, + }; + + let torrent = Torrent { + info: info.clone(), + announce: None, + announce_list: Some(vec![]), + creation_date: None, + comment: None, + created_by: None, + nodes: None, + encoding: None, + httpseeds: None, + }; + + assert_eq!(torrent.info_hash(), "d3a558d0a19aaa23ba6f9f430f40924d10fefa86"); + } + } + } +} diff --git a/src/services/torrent_file.rs b/src/services/torrent_file.rs index 5c119930..6e474d99 100644 --- a/src/services/torrent_file.rs +++ b/src/services/torrent_file.rs @@ -80,6 +80,7 @@ mod tests { private: Some(0), path: None, root_hash: None, + source: None, }, announce: None, announce_list: Some(vec![]), diff --git a/src/services/user.rs b/src/services/user.rs index b144241e..358e7431 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -129,20 +129,17 @@ impl RegistrationService { drop(self.user_repository.grant_admin_role(&user_id).await); } - if settings.mail.email_verification_enabled && opt_email.is_some() { - let mail_res = self - .mailer - .send_verification_mail( - &opt_email.expect("variable `email` is checked above"), - ®istration_form.username, - user_id, - api_base_url, - ) - .await; - - if mail_res.is_err() { - drop(self.user_repository.delete(&user_id).await); - return Err(ServiceError::FailedToSendVerificationEmail); + if settings.mail.email_verification_enabled { + if let Some(email) = opt_email { + let mail_res = self + .mailer + .send_verification_mail(&email, ®istration_form.username, user_id, api_base_url) + .await; + + if mail_res.is_err() { + drop(self.user_repository.delete(&user_id).await); + return Err(ServiceError::FailedToSendVerificationEmail); + } } }