From 16bd04c763d9f056d132fe2b17d42b81186d3b28 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 5 May 2023 17:06:21 +0100 Subject: [PATCH] feat!: [#115] change endpoint GET /torrent/download/{id} to GET /torrent/download/{infohash} BREAKING CHANGE: you can not use the odl endpoint enaymore: `GET /torrent/download/{id}`. You have to use the torrent infohash. --- project-words.txt | 4 + src/databases/database.rs | 33 +- src/databases/mysql.rs | 27 +- src/databases/sqlite.rs | 27 +- src/models/info_hash.rs | 419 ++++++++++++++++++++++ src/models/mod.rs | 1 + src/models/torrent_file.rs | 2 + src/routes/torrent.rs | 25 +- tests/common/client.rs | 6 +- tests/common/contexts/torrent/fixtures.rs | 4 + tests/common/contexts/torrent/requests.rs | 1 + tests/e2e/contexts/torrent/contract.rs | 20 +- 12 files changed, 522 insertions(+), 47 deletions(-) create mode 100644 src/models/info_hash.rs diff --git a/project-words.txt b/project-words.txt index 3b609bb7..a76aa985 100644 --- a/project-words.txt +++ b/project-words.txt @@ -3,6 +3,7 @@ addrs AUTOINCREMENT bencode bencoded +binascii btih chrono compatiblelicenses @@ -16,6 +17,7 @@ Dont Grünwald hasher Hasher +hexlify httpseeds imagoodboy imdl @@ -27,6 +29,7 @@ LEECHERS lettre luckythelab mailcatcher +metainfo nanos NCCA nilm @@ -47,6 +50,7 @@ sublist subpoints tempdir tempfile +thiserror torrust Torrust upgrader diff --git a/src/databases/database.rs b/src/databases/database.rs index 0f06f702..17ce8743 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::databases::mysql::MysqlDatabase; use crate::databases::sqlite::SqliteDatabase; +use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentInfo, Torrent, TorrentFile}; @@ -154,12 +155,42 @@ pub trait Database: Sync + Send { description: &str, ) -> Result; + /// Get `Torrent` from `InfoHash`. + async fn get_torrent_from_info_hash(&self, info_hash: &InfoHash) -> Result { + let torrent_info = self.get_torrent_info_from_infohash(*info_hash).await?; + + let torrent_files = self.get_torrent_files_from_id(torrent_info.torrent_id).await?; + + let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_info.torrent_id).await?; + + Ok(Torrent::from_db_info_files_and_announce_urls( + torrent_info, + torrent_files, + torrent_announce_urls, + )) + } + /// Get `Torrent` from `torrent_id`. - async fn get_torrent_from_id(&self, torrent_id: i64) -> Result; + async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { + let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; + + let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; + + let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; + + Ok(Torrent::from_db_info_files_and_announce_urls( + torrent_info, + torrent_files, + torrent_announce_urls, + )) + } /// Get torrent's info as `DbTorrentInfo` from `torrent_id`. async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result; + /// Get torrent's info as `DbTorrentInfo` from torrent `InfoHash`. + async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result; + /// Get all torrent's files as `Vec` from `torrent_id`. async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 028eaf5b..252a10e2 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -4,6 +4,7 @@ use sqlx::mysql::MySqlPoolOptions; use sqlx::{query, query_as, Acquire, MySqlPool}; use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; +use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; @@ -528,23 +529,9 @@ impl Database for MysqlDatabase { } } - async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { - let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; - - let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; - - let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - - Ok(Torrent::from_db_info_files_and_announce_urls( - torrent_info, - torrent_files, - torrent_announce_urls, - )) - } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( - "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) .bind(torrent_id) .fetch_one(&self.pool) @@ -552,6 +539,16 @@ impl Database for MysqlDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } + async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result { + query_as::<_, DbTorrentInfo>( + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", + ) + .bind(info_hash.to_string()) + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) + } + async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { let db_torrent_files = query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 538bf378..ee7814c6 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -4,6 +4,7 @@ use sqlx::sqlite::SqlitePoolOptions; use sqlx::{query, query_as, Acquire, SqlitePool}; use crate::databases::database::{Category, Database, DatabaseDriver, DatabaseError, Sorting, TorrentCompact}; +use crate::models::info_hash::InfoHash; use crate::models::response::TorrentsResponse; use crate::models::torrent::TorrentListing; use crate::models::torrent_file::{DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentInfo, Torrent, TorrentFile}; @@ -523,23 +524,9 @@ impl Database for SqliteDatabase { } } - async fn get_torrent_from_id(&self, torrent_id: i64) -> Result { - let torrent_info = self.get_torrent_info_from_id(torrent_id).await?; - - let torrent_files = self.get_torrent_files_from_id(torrent_id).await?; - - let torrent_announce_urls = self.get_torrent_announce_urls_from_id(torrent_id).await?; - - Ok(Torrent::from_db_info_files_and_announce_urls( - torrent_info, - torrent_files, - torrent_announce_urls, - )) - } - async fn get_torrent_info_from_id(&self, torrent_id: i64) -> Result { query_as::<_, DbTorrentInfo>( - "SELECT name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE torrent_id = ?", ) .bind(torrent_id) .fetch_one(&self.pool) @@ -547,6 +534,16 @@ impl Database for SqliteDatabase { .map_err(|_| DatabaseError::TorrentNotFound) } + async fn get_torrent_info_from_infohash(&self, info_hash: InfoHash) -> Result { + query_as::<_, DbTorrentInfo>( + "SELECT torrent_id, info_hash, name, pieces, piece_length, private, root_hash FROM torrust_torrents WHERE info_hash = ?", + ) + .bind(info_hash.to_string().to_uppercase()) // info_hash is stored as uppercase + .fetch_one(&self.pool) + .await + .map_err(|_| DatabaseError::TorrentNotFound) + } + async fn get_torrent_files_from_id(&self, torrent_id: i64) -> Result, DatabaseError> { let db_torrent_files = query_as::<_, DbTorrentFile>("SELECT md5sum, length, path FROM torrust_torrent_files WHERE torrent_id = ?") diff --git a/src/models/info_hash.rs b/src/models/info_hash.rs new file mode 100644 index 00000000..7392c791 --- /dev/null +++ b/src/models/info_hash.rs @@ -0,0 +1,419 @@ +//! A `BitTorrent` `InfoHash`. It's a unique identifier for a `BitTorrent` torrent. +//! +//! "The 20-byte sha1 hash of the bencoded form of the info value +//! from the metainfo file." +//! +//! See [BEP 3. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) +//! for the official specification. +//! +//! This modules provides a type that can be used to represent infohashes. +//! +//! > **NOTICE**: It only supports Info Hash v1. +//! +//! Typically infohashes are represented as hex strings, but internally they are +//! a 20-byte array. +//! +//! # Calculating the info-hash of a torrent file +//! +//! A sample torrent: +//! +//! - Torrent file: `mandelbrot_2048x2048_infohash_v1.png.torrent` +//! - File: `mandelbrot_2048x2048.png` +//! - Info Hash v1: `5452869be36f9f3350ccee6b4544e7e76caaadab` +//! - Sha1 hash of the info dictionary: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +//! +//! A torrent file is a binary file encoded with [Bencode encoding](https://en.wikipedia.org/wiki/Bencode): +//! +//! ```text +//! 0000000: 6431 303a 6372 6561 7465 6420 6279 3138 d10:created by18 +//! 0000010: 3a71 4269 7474 6f72 7265 6e74 2076 342e :qBittorrent v4. +//! 0000020: 342e 3131 333a 6372 6561 7469 6f6e 2064 4.113:creation d +//! 0000030: 6174 6569 3136 3739 3637 3436 3238 6534 atei1679674628e4 +//! 0000040: 3a69 6e66 6f64 363a 6c65 6e67 7468 6931 :infod6:lengthi1 +//! 0000050: 3732 3230 3465 343a 6e61 6d65 3234 3a6d 72204e4:name24:m +//! 0000060: 616e 6465 6c62 726f 745f 3230 3438 7832 andelbrot_2048x2 +//! 0000070: 3034 382e 706e 6731 323a 7069 6563 6520 048.png12:piece +//! 0000080: 6c65 6e67 7468 6931 3633 3834 6536 3a70 lengthi16384e6:p +//! 0000090: 6965 6365 7332 3230 3a7d 9171 0d9d 4dba ieces220:}.q..M. +//! 00000a0: 889b 5420 54d5 2672 8d5a 863f e121 df77 ..T T.&r.Z.?.!.w +//! 00000b0: c7f7 bb6c 7796 2166 2538 c5d9 cdab 8b08 ...lw.!f%8...... +//! 00000c0: ef8c 249b b2f5 c4cd 2adf 0bc0 0cf0 addf ..$.....*....... +//! 00000d0: 7290 e5b6 414c 236c 479b 8e9f 46aa 0c0d r...AL#lG...F... +//! 00000e0: 8ed1 97ff ee68 8b5f 34a3 87d7 71c5 a6f9 .....h._4...q... +//! 00000f0: 8e2e a631 7cbd f0f9 e223 f9cc 80af 5400 ...1|....#....T. +//! 0000100: 04f9 8569 1c77 89c1 764e d6aa bf61 a6c2 ...i.w..vN...a.. +//! 0000110: 8099 abb6 5f60 2f40 a825 be32 a33d 9d07 ...._`/@.%.2.=.. +//! 0000120: 0c79 6898 d49d 6349 af20 5866 266f 986b .yh...cI. Xf&o.k +//! 0000130: 6d32 34cd 7d08 155e 1ad0 0009 57ab 303b m24.}..^....W.0; +//! 0000140: 2060 c1dc 1287 d6f3 e745 4f70 6709 3631 `.......EOpg.61 +//! 0000150: 55f2 20f6 6ca5 156f 2c89 9569 1653 817d U. .l..o,..i.S.} +//! 0000160: 31f1 b6bd 3742 cc11 0bb2 fc2b 49a5 85b6 1...7B.....+I... +//! 0000170: fc76 7444 9365 65 .vtD.ee +//! ``` +//! +//! You can generate that output with the command: +//! +//! ```text +//! xxd mandelbrot_2048x2048_infohash_v1.png.torrent +//! ``` +//! +//! And you can show only the bytes (hexadecimal): +//! +//! ```text +//! 6431303a6372656174656420627931383a71426974746f7272656e742076 +//! 342e342e3131333a6372656174696f6e2064617465693136373936373436 +//! 323865343a696e666f64363a6c656e6774686931373232303465343a6e61 +//! 6d6532343a6d616e64656c62726f745f3230343878323034382e706e6731 +//! 323a7069656365206c656e67746869313633383465363a70696563657332 +//! 32303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c +//! 779621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290 +//! e5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f9 +//! 8e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61 +//! a6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866 +//! 266f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e745 +//! 4f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc11 +//! 0bb2fc2b49a585b6fc767444936565 +//! ``` +//! +//! You can generate that output with the command: +//! +//! ```text +//! `xxd -ps mandelbrot_2048x2048_infohash_v1.png.torrent`. +//! ``` +//! +//! The same data can be represented in a JSON format: +//! +//! ```json +//! { +//! "created by": "qBittorrent v4.4.1", +//! "creation date": 1679674628, +//! "info": { +//! "length": 172204, +//! "name": "mandelbrot_2048x2048.png", +//! "piece length": 16384, +//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" +//! } +//! } +//! ``` +//! +//! The JSON object was generated with: +//! +//! As you can see, there is a `info` attribute: +//! +//! ```json +//! { +//! "length": 172204, +//! "name": "mandelbrot_2048x2048.png", +//! "piece length": 16384, +//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" +//! } +//! ``` +//! +//! The infohash is the [SHA1](https://en.wikipedia.org/wiki/SHA-1) hash +//! of the `info` attribute. That is, the SHA1 hash of: +//! +//! ```text +//! 64363a6c656e6774686931373232303465343a6e61 +//! d6532343a6d616e64656c62726f745f3230343878323034382e706e6731 +//! 23a7069656365206c656e67746869313633383465363a70696563657332 +//! 2303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c +//! 79621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290 +//! 5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f9 +//! e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61 +//! 6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866 +//! 66f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e745 +//! f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc11 +//! bb2fc2b49a585b6fc7674449365 +//! ``` +//! +//! You can hash that byte string with +//! +//! The result is a 20-char string: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` +use std::panic::Location; + +use thiserror::Error; + +/// `BitTorrent` Info Hash v1 +#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)] +pub struct InfoHash(pub [u8; 20]); + +const INFO_HASH_BYTES_LEN: usize = 20; + +impl InfoHash { + /// Create a new `InfoHash` from a byte slice. + /// + /// # Panics + /// + /// Will panic if byte slice does not contains the exact amount of bytes need for the `InfoHash`. + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Self { + assert_eq!(bytes.len(), INFO_HASH_BYTES_LEN); + let mut ret = Self([0u8; INFO_HASH_BYTES_LEN]); + ret.0.clone_from_slice(bytes); + ret + } + + /// Returns the `InfoHash` internal byte array. + #[must_use] + pub fn bytes(&self) -> [u8; 20] { + self.0 + } + + /// Returns the `InfoHash` as a hex string. + #[must_use] + pub fn to_hex_string(&self) -> String { + self.to_string() + } +} + +impl std::fmt::Display for InfoHash { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let mut chars = [0u8; 40]; + binascii::bin2hex(&self.0, &mut chars).expect("failed to hexlify"); + write!(f, "{}", std::str::from_utf8(&chars).unwrap()) + } +} + +impl std::str::FromStr for InfoHash { + type Err = binascii::ConvertError; + + fn from_str(s: &str) -> Result { + let mut i = Self([0u8; 20]); + if s.len() != 40 { + return Err(binascii::ConvertError::InvalidInputLength); + } + binascii::hex2bin(s.as_bytes(), &mut i.0)?; + Ok(i) + } +} + +impl Ord for InfoHash { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } +} + +impl std::cmp::PartialOrd for InfoHash { + fn partial_cmp(&self, other: &InfoHash) -> Option { + self.0.partial_cmp(&other.0) + } +} + +impl std::convert::From<&[u8]> for InfoHash { + fn from(data: &[u8]) -> InfoHash { + assert_eq!(data.len(), 20); + let mut ret = InfoHash([0u8; 20]); + ret.0.clone_from_slice(data); + ret + } +} + +impl std::convert::From<[u8; 20]> for InfoHash { + fn from(val: [u8; 20]) -> Self { + InfoHash(val) + } +} + +/// Errors that can occur when converting from a `Vec` to an `InfoHash`. +#[derive(Error, Debug)] +pub enum ConversionError { + /// Not enough bytes for infohash. An infohash is 20 bytes. + #[error("not enough bytes for infohash: {message} {location}")] + NotEnoughBytes { + location: &'static Location<'static>, + message: String, + }, + /// Too many bytes for infohash. An infohash is 20 bytes. + #[error("too many bytes for infohash: {message} {location}")] + TooManyBytes { + location: &'static Location<'static>, + message: String, + }, +} + +impl TryFrom> for InfoHash { + type Error = ConversionError; + + fn try_from(bytes: Vec) -> Result { + if bytes.len() < INFO_HASH_BYTES_LEN { + return Err(ConversionError::NotEnoughBytes { + location: Location::caller(), + message: format! {"got {} bytes, expected {}", bytes.len(), INFO_HASH_BYTES_LEN}, + }); + } + if bytes.len() > INFO_HASH_BYTES_LEN { + return Err(ConversionError::TooManyBytes { + location: Location::caller(), + message: format! {"got {} bytes, expected {}", bytes.len(), INFO_HASH_BYTES_LEN}, + }); + } + Ok(Self::from_bytes(&bytes)) + } +} + +impl serde::ser::Serialize for InfoHash { + fn serialize(&self, serializer: S) -> Result { + let mut buffer = [0u8; 40]; + let bytes_out = binascii::bin2hex(&self.0, &mut buffer).ok().unwrap(); + let str_out = std::str::from_utf8(bytes_out).unwrap(); + serializer.serialize_str(str_out) + } +} + +impl<'de> serde::de::Deserialize<'de> for InfoHash { + fn deserialize>(des: D) -> Result { + des.deserialize_str(InfoHashVisitor) + } +} + +struct InfoHashVisitor; + +impl<'v> serde::de::Visitor<'v> for InfoHashVisitor { + type Value = InfoHash; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a 40 character long hash") + } + + fn visit_str(self, v: &str) -> Result { + if v.len() != 40 { + return Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(v), + &"a 40 character long string", + )); + } + + let mut res = InfoHash([0u8; 20]); + + if binascii::hex2bin(v.as_bytes(), &mut res.0).is_err() { + return Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(v), + &"a hexadecimal string", + )); + }; + Ok(res) + } +} + +#[cfg(test)] +mod tests { + + use std::str::FromStr; + + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use super::InfoHash; + + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] + struct ContainingInfoHash { + pub info_hash: InfoHash, + } + + #[test] + fn an_info_hash_can_be_created_from_a_valid_40_utf8_char_string_representing_an_hexadecimal_value() { + let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); + assert!(info_hash.is_ok()); + } + + #[test] + fn an_info_hash_can_not_be_created_from_a_utf8_string_representing_a_not_valid_hexadecimal_value() { + let info_hash = InfoHash::from_str("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"); + assert!(info_hash.is_err()); + } + + #[test] + fn an_info_hash_can_only_be_created_from_a_40_utf8_char_string() { + let info_hash = InfoHash::from_str(&"F".repeat(39)); + assert!(info_hash.is_err()); + + let info_hash = InfoHash::from_str(&"F".repeat(41)); + assert!(info_hash.is_err()); + } + + #[test] + fn an_info_hash_should_by_displayed_like_a_40_utf8_lowercased_char_hex_string() { + let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(); + + let output = format!("{info_hash}"); + + assert_eq!(output, "ffffffffffffffffffffffffffffffffffffffff"); + } + + #[test] + fn an_info_hash_should_return_its_a_40_utf8_lowercased_char_hex_representations_as_string() { + let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(); + + assert_eq!(info_hash.to_hex_string(), "ffffffffffffffffffffffffffffffffffffffff"); + } + + #[test] + fn an_info_hash_can_be_created_from_a_valid_20_byte_array_slice() { + let info_hash: InfoHash = [255u8; 20].as_slice().into(); + + assert_eq!( + info_hash, + InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + ); + } + + #[test] + fn an_info_hash_can_be_created_from_a_valid_20_byte_array() { + let info_hash: InfoHash = [255u8; 20].into(); + + assert_eq!( + info_hash, + InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + ); + } + + #[test] + fn an_info_hash_can_be_created_from_a_byte_vector() { + let info_hash: InfoHash = [255u8; 20].to_vec().try_into().unwrap(); + + assert_eq!( + info_hash, + InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + ); + } + + #[test] + fn it_should_fail_trying_to_create_an_info_hash_from_a_byte_vector_with_less_than_20_bytes() { + assert!(InfoHash::try_from([255u8; 19].to_vec()).is_err()); + } + + #[test] + fn it_should_fail_trying_to_create_an_info_hash_from_a_byte_vector_with_more_than_20_bytes() { + assert!(InfoHash::try_from([255u8; 21].to_vec()).is_err()); + } + + #[test] + fn an_info_hash_can_be_serialized() { + let s = ContainingInfoHash { + info_hash: InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(), + }; + + let json_serialized_value = serde_json::to_string(&s).unwrap(); + + assert_eq!( + json_serialized_value, + r#"{"info_hash":"ffffffffffffffffffffffffffffffffffffffff"}"# + ); + } + + #[test] + fn an_info_hash_can_be_deserialized() { + let json = json!({ + "info_hash": "ffffffffffffffffffffffffffffffffffffffff", + }); + + let s: ContainingInfoHash = serde_json::from_value(json).unwrap(); + + assert_eq!( + s, + ContainingInfoHash { + info_hash: InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() + } + ); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index acfccf77..6a317c58 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod info_hash; pub mod response; pub mod torrent; pub mod torrent_file; diff --git a/src/models/torrent_file.rs b/src/models/torrent_file.rs index ff34be5e..be3b6101 100644 --- a/src/models/torrent_file.rs +++ b/src/models/torrent_file.rs @@ -233,6 +233,8 @@ pub struct DbTorrentFile { #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct DbTorrentInfo { + pub torrent_id: i64, + pub info_hash: String, pub name: String, pub pieces: String, pub piece_length: i64, diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 141be82f..10f5fdba 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -1,4 +1,5 @@ use std::io::{Cursor, Write}; +use std::str::FromStr; use actix_multipart::Multipart; use actix_web::web::Query; @@ -10,6 +11,7 @@ use sqlx::FromRow; use crate::common::WebAppData; use crate::databases::database::Sorting; use crate::errors::{ServiceError, ServiceResult}; +use crate::models::info_hash::InfoHash; use crate::models::response::{NewTorrentResponse, OkResponse, TorrentResponse}; use crate::models::torrent::TorrentRequest; use crate::utils::parse_torrent; @@ -19,7 +21,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/torrent") .service(web::resource("/upload").route(web::post().to(upload_torrent))) - .service(web::resource("/download/{id}").route(web::get().to(download_torrent))) + .service(web::resource("/download/{info_hash}").route(web::get().to(download_torrent_handler))) .service( web::resource("/{id}") .route(web::get().to(get_torrent)) @@ -121,13 +123,18 @@ pub async fn upload_torrent(req: HttpRequest, payload: Multipart, app_data: WebA })) } -pub async fn download_torrent(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let torrent_id = get_torrent_id_from_request(&req)?; +/// Returns the torrent as a byte stream `application/x-bittorrent`. +/// +/// # Errors +/// +/// Returns `ServiceError::BadRequest` if the torrent infohash is invalid. +pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { + let info_hash = get_torrent_info_hash_from_request(&req)?; // optional let user = app_data.auth.get_user_compact_from_request(&req).await; - let mut torrent = app_data.database.get_torrent_from_id(torrent_id).await?; + let mut torrent = app_data.database.get_torrent_from_info_hash(&info_hash).await?; let settings = app_data.cfg.settings.read().await; @@ -333,6 +340,16 @@ fn get_torrent_id_from_request(req: &HttpRequest) -> Result { } } +fn get_torrent_info_hash_from_request(req: &HttpRequest) -> Result { + match req.match_info().get("info_hash") { + None => Err(ServiceError::BadRequest), + Some(info_hash) => match InfoHash::from_str(info_hash) { + Err(_) => Err(ServiceError::BadRequest), + Ok(v) => Ok(v), + }, + } +} + async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result { let torrent_buffer = vec![0u8]; let mut torrent_cursor = Cursor::new(torrent_buffer); diff --git a/tests/common/client.rs b/tests/common/client.rs index d0f84991..c3501519 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -5,7 +5,7 @@ use super::connection_info::ConnectionInfo; use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::settings::form::UpdateSettings; use super::contexts::torrent::forms::UpdateTorrentFrom; -use super::contexts::torrent::requests::TorrentId; +use super::contexts::torrent::requests::{InfoHash, TorrentId}; use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; use super::http::{Query, ReqwestQuery}; use super::responses::{self, BinaryResponse, TextResponse}; @@ -109,9 +109,9 @@ impl Client { self.http_client.post_multipart("torrent/upload", form).await } - pub async fn download_torrent(&self, id: TorrentId) -> responses::BinaryResponse { + pub async fn download_torrent(&self, info_hash: InfoHash) -> responses::BinaryResponse { self.http_client - .get_binary(&format!("torrent/download/{id}"), Query::empty()) + .get_binary(&format!("torrent/download/{info_hash}"), Query::empty()) .await } diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index f2a46748..00e26ecf 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -90,6 +90,10 @@ impl TestTorrent { index_info: torrent_to_index, } } + + pub fn info_hash(&self) -> String { + self.file_info.info_hash.clone() + } } pub fn random_torrent() -> TestTorrent { diff --git a/tests/common/contexts/torrent/requests.rs b/tests/common/contexts/torrent/requests.rs index 259a2dae..946c475a 100644 --- a/tests/common/contexts/torrent/requests.rs +++ b/tests/common/contexts/torrent/requests.rs @@ -1 +1,2 @@ pub type TorrentId = i64; +pub type InfoHash = String; diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs index 4d477cc5..2620dd37 100644 --- a/tests/e2e/contexts/torrent/contract.rs +++ b/tests/e2e/contexts/torrent/contract.rs @@ -122,12 +122,13 @@ mod for_guests { let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); let uploader = new_logged_in_user(&env).await; - let (test_torrent, torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let response = client.download_torrent(torrent_listed_in_index.torrent_id).await; + let response = client.download_torrent(test_torrent.info_hash()).await; - let torrent = decode_torrent(&response.bytes).unwrap(); - let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); + let uploaded_torrent = + decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); let expected_torrent = expected_torrent(uploaded_torrent, &env, &None).await; assert_eq!(torrent, expected_torrent); assert!(response.is_bittorrent_and_ok()); @@ -287,17 +288,18 @@ mod for_authenticated_users { // Given a previously uploaded torrent let uploader = new_logged_in_user(&env).await; - let (test_torrent, torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; - let torrent_id = torrent_listed_in_index.torrent_id; - let uploaded_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + let (test_torrent, _torrent_listed_in_index) = upload_random_torrent_to_index(&uploader, &env).await; + let uploaded_torrent = + decode_torrent(&test_torrent.index_info.torrent_file.contents).expect("could not decode uploaded torrent"); // And a logged in user who is going to download the torrent let downloader = new_logged_in_user(&env).await; let client = Client::authenticated(&env.server_socket_addr().unwrap(), &downloader.token); // When the user downloads the torrent - let response = client.download_torrent(torrent_id).await; - let torrent = decode_torrent(&response.bytes).unwrap(); + let response = client.download_torrent(test_torrent.info_hash()).await; + + let torrent = decode_torrent(&response.bytes).expect("could not decode downloaded torrent"); // Then the torrent should have the personal announce URL let expected_torrent = expected_torrent(uploaded_torrent, &env, &Some(downloader)).await;