Skip to content

Commit

Permalink
Merge torrust#237: Endpoint to create random torrents
Browse files Browse the repository at this point in the history
0f163cf refactor: invert the dependency between Torrent named constructors (Jose Celano)
4b6f25c test: add test for random torrent file generator service (Jose Celano)
40c4df0 refactor: extract hasher service (Jose Celano)
b2870b9 refactor: extract torrent file service (Jose Celano)
dfa260e fix: clippy warnings alter updating clippy to clippy 0.1.73 (Jose Celano)
b269ecb feat!: change random torrent generator endpoint (Jose Celano)
30bf79e feat: new endpoint to generate random torrents (Jose Celano)
dd1dc0c chore: add dependencies: hex, uuid (Jose Celano)

Pull request description:

  Relates to: torrust/torrust-index-gui#185

  Sometimes we need to generate a random torrent for testing purposes.

  We need to generate test torrents for the frontend application. With this new endpoint, you can get a random torrent:

  http://0.0.0.0:3001/v1/torrents/random

  The torrent is a single-file torrent using a UUID for its name and the original data file contents.

  In this repo, we use the `imdl` command to generate random torrents but in the frontend we are using cypress, and it seems the best option is [to make a request to an endpoint](https://docs.cypress.io/api/commands/request) to obtain dynamic fixtures like random (or customized in the future) torrents.

  torrust/torrust-index-gui#185

  ### TODO

  - [x] Generate the random ID
  - [x] Calculate the correct value for the "pieces" field in the torrent file (sha1 hash of the contents).
  - [x] Refactor: extract service. Remove the domain code from the handler.
  - [ ] Refactor: add a new named the constructor for the `Torrent` instead of using the `from_db_info_files_and_announce_urls`. We do not need the `torrent_id`, for example.

  Other things that we could implement:

  - [ ] Add an env var to enable this endpoint only for development/testing environments.
  - [x] Add an URL parameter with the UUDI: `v1/torrents/random/:uuid`. We use the UUID for the torrent name (torrent file name: `name.torrent`).

Top commit has no ACKs.

Tree-SHA512: 3740a628b177303e60dac78969c786088c5c4391f7e1936fe6634031e533f38dea67a2aa7b876966456fb649f749773c43961d686c2c120ae62a2705be35c041
  • Loading branch information
josecelano committed Aug 1, 2023
2 parents 4415144 + 0f163cf commit e2a0ed4
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 19 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ axum = { version = "0.6.18", features = ["multipart"] }
hyper = "0.14.26"
tower-http = { version = "0.4.0", features = ["cors"] }
email_address = "0.2.4"
hex = "0.4.3"
uuid = { version = "1.3", features = ["v4"] }

[dev-dependencies]
rand = "0.8"
Expand Down
2 changes: 1 addition & 1 deletion src/models/info_hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ impl Ord for InfoHash {

impl std::cmp::PartialOrd<InfoHash> for InfoHash {
fn partial_cmp(&self, other: &InfoHash) -> Option<std::cmp::Ordering> {
self.0.partial_cmp(&other.0)
Some(self.cmp(other))
}
}

Expand Down
37 changes: 27 additions & 10 deletions src/models/torrent_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde_bytes::ByteBuf;
use sha1::{Digest, Sha1};

use crate::config::Configuration;
use crate::services::torrent_file::NewTorrentInfoRequest;
use crate::utils::hex::{from_bytes, into_bytes};

#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -102,17 +103,13 @@ pub struct Torrent {
}

impl Torrent {
/// It hydrates a `Torrent` struct from the database data.
/// It builds a `Torrent` from a `NewTorrentInfoRequest`.
///
/// # Panics
///
/// This function will panic if the `torrent_info.pieces` is not a valid hex string.
#[must_use]
pub fn from_db_info_files_and_announce_urls(
torrent_info: DbTorrentInfo,
torrent_files: Vec<TorrentFile>,
torrent_announce_urls: Vec<Vec<String>>,
) -> Self {
pub fn from_new_torrent_info_request(torrent_info: NewTorrentInfoRequest) -> Self {
let private = u8::try_from(torrent_info.private.unwrap_or(0)).ok();

// the info part of the torrent file
Expand All @@ -137,8 +134,9 @@ impl Torrent {
}

// either set the single file or the multiple files information
if torrent_files.len() == 1 {
let torrent_file = torrent_files
if torrent_info.files.len() == 1 {
let torrent_file = torrent_info
.files
.first()
.expect("vector `torrent_files` should have at least one element");

Expand All @@ -160,7 +158,7 @@ impl Torrent {

info.path = path;
} else {
info.files = Some(torrent_files);
info.files = Some(torrent_info.files);
}

Self {
Expand All @@ -169,13 +167,32 @@ impl Torrent {
nodes: None,
encoding: None,
httpseeds: None,
announce_list: Some(torrent_announce_urls),
announce_list: Some(torrent_info.announce_urls),
creation_date: None,
comment: None,
created_by: None,
}
}

/// It hydrates a `Torrent` struct from the database data.
#[must_use]
pub fn from_db_info_files_and_announce_urls(
torrent_info: DbTorrentInfo,
torrent_files: Vec<TorrentFile>,
torrent_announce_urls: Vec<Vec<String>>,
) -> Self {
let torrent_info_request = NewTorrentInfoRequest {
name: torrent_info.name,
pieces: torrent_info.pieces,
piece_length: torrent_info.piece_length,
private: torrent_info.private,
root_hash: torrent_info.root_hash,
files: torrent_files,
announce_urls: torrent_announce_urls,
};
Torrent::from_new_torrent_info_request(torrent_info_request)
}

/// Sets the announce url to the tracker url and removes all other trackers
/// if the torrent is private.
pub async fn set_announce_urls(&mut self, cfg: &Configuration) {
Expand Down
28 changes: 28 additions & 0 deletions src/services/hasher.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! Hashing service
use sha1::{Digest, Sha1};

// Calculate the sha1 hash of a string
#[must_use]
pub fn sha1(data: &str) -> String {
// Create a Sha1 object
let mut hasher = Sha1::new();

// Write input message
hasher.update(data.as_bytes());

// Read hash digest and consume hasher
let result = hasher.finalize();

// Convert the hash (a byte array) to a string of hex characters
hex::encode(result)
}

#[cfg(test)]
mod tests {
use crate::services::hasher::sha1;

#[test]
fn it_should_hash_an_string() {
assert_eq!(sha1("hello world"), "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
}
}
2 changes: 2 additions & 0 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
pub mod about;
pub mod authentication;
pub mod category;
pub mod hasher;
pub mod proxy;
pub mod settings;
pub mod tag;
pub mod torrent;
pub mod torrent_file;
pub mod user;
96 changes: 96 additions & 0 deletions src/services/torrent_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! This module contains the services related to torrent file management.
use uuid::Uuid;

use crate::models::torrent_file::{Torrent, TorrentFile};
use crate::services::hasher::sha1;

/// It contains the information required to create a new torrent file.
///
/// It's not the full in-memory representation of a torrent file. The full
/// in-memory representation is the `Torrent` struct.
pub struct NewTorrentInfoRequest {
pub name: String,
pub pieces: String,
pub piece_length: i64,
pub private: Option<i64>,
pub root_hash: i64,
pub files: Vec<TorrentFile>,
pub announce_urls: Vec<Vec<String>>,
}

/// It generates a random single-file torrent for testing purposes.
///
/// The torrent will contain a single text file with the UUID as its content.
///
/// # Panics
///
/// This function will panic if the sample file contents length in bytes is
/// greater than `i64::MAX`.
#[must_use]
pub fn generate_random_torrent(id: Uuid) -> Torrent {
// Content of the file from which the torrent will be generated.
// We use the UUID as the content of the file.
let file_contents = format!("{id}\n");

let torrent_files: Vec<TorrentFile> = vec![TorrentFile {
path: vec![String::new()],
length: i64::try_from(file_contents.len()).expect("file contents size in bytes cannot exceed i64::MAX"),
md5sum: None,
}];

let torrent_announce_urls: Vec<Vec<String>> = vec![];

let torrent_info_request = NewTorrentInfoRequest {
name: format!("file-{id}.txt"),
pieces: sha1(&file_contents),
piece_length: 16384,
private: None,
root_hash: 0,
files: torrent_files,
announce_urls: torrent_announce_urls,
};

Torrent::from_new_torrent_info_request(torrent_info_request)
}

#[cfg(test)]
mod tests {
use serde_bytes::ByteBuf;
use uuid::Uuid;

use crate::models::torrent_file::{Torrent, TorrentInfo};
use crate::services::torrent_file::generate_random_torrent;

#[test]
fn it_should_generate_a_random_meta_info_file() {
let uuid = Uuid::parse_str("d6170378-2c14-4ccc-870d-2a8e15195e23").unwrap();

let torrent = generate_random_torrent(uuid);

let expected_torrent = Torrent {
info: TorrentInfo {
name: "file-d6170378-2c14-4ccc-870d-2a8e15195e23.txt".to_string(),
pieces: Some(ByteBuf::from(vec![
62, 231, 243, 51, 234, 165, 204, 209, 51, 132, 163, 133, 249, 50, 107, 46, 24, 15, 251, 32,
])),
piece_length: 16384,
md5sum: None,
length: Some(37),
files: None,
private: Some(0),
path: None,
root_hash: None,
},
announce: None,
announce_list: Some(vec![]),
creation_date: None,
comment: None,
created_by: None,
nodes: None,
encoding: None,
httpseeds: None,
};

assert_eq!(torrent, expected_torrent);
}
}
39 changes: 36 additions & 3 deletions src/web/api/v1/contexts/torrent/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use axum::extract::{self, Multipart, Path, Query, State};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
use uuid::Uuid;

use super::forms::UpdateTorrentInfoForm;
use super::responses::{new_torrent_response, torrent_file_response};
Expand All @@ -17,6 +18,7 @@ use crate::models::info_hash::InfoHash;
use crate::models::torrent::{AddTorrentRequest, Metadata};
use crate::models::torrent_tag::TagId;
use crate::services::torrent::ListingRequest;
use crate::services::torrent_file::generate_random_torrent;
use crate::utils::parse_torrent;
use crate::web::api::v1::auth::get_optional_logged_in_user;
use crate::web::api::v1::extractors::bearer_token::Extract;
Expand Down Expand Up @@ -92,7 +94,7 @@ pub async fn download_torrent_handler(
return ServiceError::InternalServerError.into_response();
};

torrent_file_response(bytes)
torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name))
}

/// It returns a list of torrents matching the search criteria.
Expand Down Expand Up @@ -214,6 +216,37 @@ pub async fn delete_torrent_handler(
}
}

#[derive(Debug, Deserialize)]
pub struct UuidParam(pub String);

impl UuidParam {
fn value(&self) -> String {
self.0.to_lowercase()
}
}

/// Returns a random torrent file as a byte stream `application/x-bittorrent`.
///
/// This is useful for testing purposes.
///
/// # Errors
///
/// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid.
#[allow(clippy::unused_async)]
pub async fn create_random_torrent_handler(State(_app_data): State<Arc<AppData>>, Path(uuid): Path<UuidParam>) -> Response {
let Ok(uuid) = Uuid::parse_str(&uuid.value()) else {
return ServiceError::BadRequest.into_response();
};

let torrent = generate_random_torrent(uuid);

let Ok(bytes) = parse_torrent::encode_torrent(&torrent) else {
return ServiceError::InternalServerError.into_response();
};

torrent_file_response(bytes, &format!("{}.torrent", torrent.info.name))
}

/// Extracts the [`TorrentRequest`] from the multipart form payload.
///
/// # Errors
Expand All @@ -236,7 +269,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result<AddT
let mut tags: Vec<TagId> = vec![];

while let Some(mut field) = payload.next_field().await.unwrap() {
let name = field.name().unwrap().clone();
let name = field.name().unwrap();

match name {
"title" => {
Expand Down Expand Up @@ -269,7 +302,7 @@ async fn get_torrent_request_from_payload(mut payload: Multipart) -> Result<AddT
tags = serde_json::from_str(&string_data).map_err(|_| ServiceError::BadRequest)?;
}
"torrent" => {
let content_type = field.content_type().unwrap().clone();
let content_type = field.content_type().unwrap();

if content_type != "application/x-bittorrent" {
return Err(ServiceError::InvalidFileType);
Expand Down
12 changes: 10 additions & 2 deletions src/web/api/v1/contexts/torrent/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ pub fn new_torrent_response(torrent_id: TorrentId, info_hash: &str) -> Json<OkRe
}

#[must_use]
pub fn torrent_file_response(bytes: Vec<u8>) -> Response {
(StatusCode::OK, [(header::CONTENT_TYPE, "application/x-bittorrent")], bytes).into_response()
pub fn torrent_file_response(bytes: Vec<u8>, filename: &str) -> Response {
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/x-bittorrent"),
(header::CONTENT_DISPOSITION, &format!("attachment; filename={filename}")),
],
bytes,
)
.into_response()
}
13 changes: 10 additions & 3 deletions src/web/api/v1/contexts/torrent/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use axum::routing::{delete, get, post, put};
use axum::Router;

use super::handlers::{
delete_torrent_handler, download_torrent_handler, get_torrent_info_handler, get_torrents_handler,
update_torrent_info_handler, upload_torrent_handler,
create_random_torrent_handler, delete_torrent_handler, download_torrent_handler, get_torrent_info_handler,
get_torrents_handler, update_torrent_info_handler, upload_torrent_handler,
};
use crate::common::AppData;

Expand All @@ -21,7 +21,14 @@ pub fn router_for_single_resources(app_data: Arc<AppData>) -> Router {

Router::new()
.route("/upload", post(upload_torrent_handler).with_state(app_data.clone()))
.route("/download/:info_hash", get(download_torrent_handler).with_state(app_data))
.route(
"/download/:info_hash",
get(download_torrent_handler).with_state(app_data.clone()),
)
.route(
"/meta-info/random/:uuid",
get(create_random_torrent_handler).with_state(app_data),
)
.nest("/:info_hash", torrent_info_routes)
}

Expand Down

0 comments on commit e2a0ed4

Please sign in to comment.