Skip to content

Commit

Permalink
feat: [#453] new console command
Browse files Browse the repository at this point in the history
New console command to upload torrent to the Index remotely by using the
API client.

```console
cargo run --bin seeder -- --api-base-url <API_BASE_URL> --number-of-torrents <NUMBER_OF_TORRENTS> --user <USER> --password <PASSWORD> --interval <INTERVAL>
```

For example:

```console
cargo run --bin seeder -- --api-base-url "localhost:3001" --number-of-torrents 1000 --user admin --password 12345678 --interval 0
```

That command would upload 1000 random torrents to the Index using the user
account `admin` with password `123456` and waiting `1` second between uploads.
  • Loading branch information
josecelano committed Feb 6, 2024
1 parent df3a9be commit c94b566
Show file tree
Hide file tree
Showing 8 changed files with 242 additions and 39 deletions.
5 changes: 3 additions & 2 deletions src/bin/seeder.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Program to upload random torrents to a live Index API.
use torrust_index::console::commands::seeder::app;

fn main() -> anyhow::Result<()> {
app::run()
#[tokio::main]
async fn main() -> anyhow::Result<()> {
app::run().await
}
108 changes: 108 additions & 0 deletions src/console/commands/seeder/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use crate::web::api::client::v1::client::Client;
use crate::web::api::client::v1::contexts::category::forms::AddCategoryForm;
use crate::web::api::client::v1::contexts::category::responses::{ListItem, ListResponse};
use crate::web::api::client::v1::contexts::torrent::forms::UploadTorrentMultipartForm;
use crate::web::api::client::v1::contexts::torrent::responses::{UploadedTorrent, UploadedTorrentResponse};
use crate::web::api::client::v1::contexts::user::forms::LoginForm;
use crate::web::api::client::v1::contexts::user::responses::{LoggedInUserData, SuccessfulLoginResponse};
use crate::web::api::client::v1::responses::TextResponse;

use log::debug;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
#[error("Torrent with the same info-hash already exist in the database")]
TorrentInfoHashAlreadyExists,
#[error("Torrent with the same title already exist in the database")]
TorrentTitleAlreadyExists,
}

/// It uploads a torrent file to the Torrust Index.
///
/// # Errors
///
/// It returns an error if the torrent already exists in the database.
///
/// # Panics
///
/// Panics if the response body is not a valid JSON.
pub async fn upload_torrent(client: &Client, upload_torrent_form: UploadTorrentMultipartForm) -> Result<UploadedTorrent, Error> {
let categories = get_categories(client).await;

if !contains_category_with_name(&categories, &upload_torrent_form.category) {
add_category(client, &upload_torrent_form.category).await;
}

let response = client.upload_torrent(upload_torrent_form.into()).await;

debug!("response: {}", response.status);

if response.status == 400 {
if response.body.contains("This torrent already exists in our database") {
return Err(Error::TorrentInfoHashAlreadyExists);
}

if response.body.contains("This torrent title has already been used") {
return Err(Error::TorrentTitleAlreadyExists);
}
}

assert!(response.is_json_and_ok(), "Error uploading torrent: {}", response.body);

let uploaded_torrent_response: UploadedTorrentResponse =
serde_json::from_str(&response.body).expect("a valid JSON response should be returned from the Torrust Index API");

Ok(uploaded_torrent_response.data)
}

/// It logs in the user and returns the user data.
///
/// # Panics
///
/// Panics if the response body is not a valid JSON.
pub async fn login(client: &Client, username: &str, password: &str) -> LoggedInUserData {
let response = client
.login_user(LoginForm {
login: username.to_owned(),
password: password.to_owned(),
})
.await;

let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap_or_else(|_| {
panic!(
"a valid JSON response should be returned after login. Received: {}",
response.body
)
});

res.data
}

/// It returns all the index categories.
///
/// # Panics
///
/// Panics if the response body is not a valid JSON.
pub async fn get_categories(client: &Client) -> Vec<ListItem> {
let response = client.get_categories().await;

let res: ListResponse = serde_json::from_str(&response.body).unwrap();

res.data
}

/// It adds a new category.
pub async fn add_category(client: &Client, name: &str) -> TextResponse {
client
.add_category(AddCategoryForm {
name: name.to_owned(),
icon: None,
})
.await
}

/// It checks if the category list contains the given category.
fn contains_category_with_name(items: &[ListItem], category_name: &str) -> bool {
items.iter().any(|item| item.name == category_name)
}
121 changes: 91 additions & 30 deletions src/console/commands/seeder/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,50 @@
//! Run with:
//!
//! ```text
//! cargo run --bin seeder -- --number-of-torrents <NUMBER_OF_TORRENTS> --user <USER> --password <PASSWORD> --interval <INTERVAL>
//! cargo run --bin seeder -- --api-base-url <API_BASE_URL> --number-of-torrents <NUMBER_OF_TORRENTS> --user <USER> --password <PASSWORD> --interval <INTERVAL>
//! ```
//!
//! For example:
//!
//! ```text
//! cargo run --bin seeder -- --number-of-torrents 1000 --user admin --password 12345678 --interval 0
//! cargo run --bin seeder -- --api-base-url "localhost:3001" --number-of-torrents 1000 --user admin --password 12345678 --interval 0
//! ```
//!
//! That command would upload 100o random torrents to the Index using the user
//! That command would upload 1000 random torrents to the Index using the user
//! account admin with password 123456 and waiting 1 second between uploads.
use anyhow::Context;
use clap::Parser;
use log::{debug, LevelFilter};
use log::{debug, info, LevelFilter};
use text_colorizer::Colorize;
use uuid::Uuid;

use crate::{
console::commands::seeder::{
api::{login, upload_torrent},
logging,
},
services::torrent_file::generate_random_torrent,
utils::parse_torrent,
web::api::client::v1::{
client::Client,
contexts::{
torrent::{
forms::{BinaryFile, UploadTorrentMultipartForm},
responses::UploadedTorrent,
},
user::responses::LoggedInUserData,
},
},
};

use super::api::Error;

#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Args {
#[arg(short, long)]
api_base_url: String,

#[arg(short, long)]
number_of_torrents: i32,

Expand All @@ -36,40 +63,74 @@ struct Args {
/// # Errors
///
/// Will not return any errors for the time being.
pub fn run() -> anyhow::Result<()> {
setup_logging(LevelFilter::Info);
pub async fn run() -> anyhow::Result<()> {
logging::setup(LevelFilter::Info);

let args = Args::parse();

println!("Number of torrents: {}", args.number_of_torrents);
println!("User: {}", args.user);
println!("Password: {}", args.password);
println!("Interval: {:?}", args.interval);
let api_user = log_in_target_api(&args.api_base_url, &args.user, &args.password).await;

let api_client = Client::authenticated(&args.api_base_url, &api_user.token);

info!("Uploading random torrents to the Torrust Index ...");

/* todo:
- Use a client to upload a random torrent every "interval" seconds.
*/
match upload_random_torrent(&api_client).await {
Ok(uploaded_torrent) => {
debug!("Uploaded torrent {uploaded_torrent:?}");

let json = serde_json::to_string(&uploaded_torrent).context("failed to serialize upload response into JSON")?;

info!("{}", json);
}
Err(err) => print!("Error uploading torrent {err:?}"),
};

Ok(())
}

fn setup_logging(level: LevelFilter) {
if let Err(_err) = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"{} [{}][{}] {}",
chrono::Local::now().format("%+"),
record.target(),
record.level(),
message
));
})
.level(level)
.chain(std::io::stdout())
.apply()
{
panic!("Failed to initialize logging.")
/// It logs in a user in the Index API.
pub async fn log_in_target_api(api_url: &str, username: &str, password: &str) -> LoggedInUserData {
let unauthenticated_client = Client::unauthenticated(api_url);

info!("Trying to login with username: {} ...", username.yellow());

let user: LoggedInUserData = login(&unauthenticated_client, username, password).await;

if user.admin {
info!("Logged as admin with account: {} ", username.yellow());
} else {
info!("Logged as {} ", username.yellow());
}

debug!("logging initialized.");
user
}

async fn upload_random_torrent(api_client: &Client) -> Result<UploadedTorrent, Error> {
let uuid = Uuid::new_v4();

info!("Uploading new random torrent with uuid: {} ...", uuid.to_string().yellow());

// Generate random torrent meta file

let torrent = generate_random_torrent(uuid);

let bytes = parse_torrent::encode_torrent(&torrent).expect("msg:the torrent should be bencoded");

let torrent_file = BinaryFile::from_bytes(torrent.info.name, bytes);

// Generate random meta data for the indexed torrent

let upload_form = UploadTorrentMultipartForm {
title: format!("title-{uuid}"),
description: format!("description-{uuid}"),
category: "test".to_string(),
torrent_file,
};

// Upload the torrent file

match upload_torrent(api_client, upload_form).await {
Ok(uploaded_torrent) => Ok(uploaded_torrent),
Err(error) => Err(error),
}
}
25 changes: 25 additions & 0 deletions src/console/commands/seeder/logging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use log::{debug, LevelFilter};

/// # Panics
///
///
pub fn setup(level: LevelFilter) {
if let Err(_err) = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"{} [{}][{}] {}",
chrono::Local::now().format("%+"),
record.target(),
record.level(),
message
));
})
.level(level)
.chain(std::io::stdout())
.apply()
{
panic!("Failed to initialize logging.")
}

debug!("logging initialized.");
}
2 changes: 2 additions & 0 deletions src/console/commands/seeder/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
pub mod api;
pub mod app;
pub mod logging;
4 changes: 2 additions & 2 deletions src/web/api/client/v1/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use serde::Serialize;
use super::connection_info::ConnectionInfo;
use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm};
use super::contexts::tag::forms::{AddTagForm, DeleteTagForm};
use super::contexts::torrent::forms::UpdateTorrentFrom;
use super::contexts::torrent::forms::UpdateTorrentForm;
use super::contexts::torrent::requests::InfoHash;
use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username};
use super::http::{Query, ReqwestQuery};
Expand Down Expand Up @@ -119,7 +119,7 @@ impl Client {
self.http_client.delete(&format!("/torrent/{info_hash}")).await
}

pub async fn update_torrent(&self, info_hash: &InfoHash, update_torrent_form: UpdateTorrentFrom) -> TextResponse {
pub async fn update_torrent(&self, info_hash: &InfoHash, update_torrent_form: UpdateTorrentForm) -> TextResponse {
self.http_client
.put(&format!("/torrent/{info_hash}"), &update_torrent_form)
.await
Expand Down
12 changes: 9 additions & 3 deletions src/web/api/client/v1/contexts/torrent/forms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::path::Path;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
pub struct UpdateTorrentFrom {
pub struct UpdateTorrentForm {
pub title: Option<String>,
pub description: Option<String>,
pub category: Option<i64>,
Expand All @@ -28,9 +28,9 @@ pub struct BinaryFile {

impl BinaryFile {
/// # Panics
///
///
/// Will panic if:
///
///
/// - The path is not a file.
/// - The path can't be converted into string.
/// - The file can't be read.
Expand All @@ -41,6 +41,12 @@ impl BinaryFile {
contents: fs::read(path).unwrap(),
}
}

/// Build the binary file directly from the binary data provided.
#[must_use]
pub fn from_bytes(name: String, contents: Vec<u8>) -> Self {
BinaryFile { name, contents }
}
}

impl From<UploadTorrentMultipartForm> for Form {
Expand Down
4 changes: 2 additions & 2 deletions src/web/api/client/v1/contexts/torrent/responses.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};

pub type Id = i64;
pub type CategoryId = i64;
Expand Down Expand Up @@ -102,7 +102,7 @@ pub struct UploadedTorrentResponse {
pub data: UploadedTorrent,
}

#[derive(Deserialize, PartialEq, Debug)]
#[derive(Deserialize, Serialize, PartialEq, Debug)]
pub struct UploadedTorrent {
pub torrent_id: Id,
pub info_hash: String,
Expand Down

0 comments on commit c94b566

Please sign in to comment.