diff --git a/Cargo.lock b/Cargo.lock index abdaccd..9207105 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,6 +517,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.5.0" @@ -888,10 +894,12 @@ dependencies = [ "colored", "dialoguer", "futures-util", + "hex", "indicatif", "reqwest", "serde", "sha1", + "sha2", "terminal_size", "textwrap", "tokio", @@ -1412,6 +1420,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell-words" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index e5df991..da5dac3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,12 @@ clap = { version = "4.6.6", features = ["derive"] } colored = "3.1.1" dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } futures-util = "0.3.34" +hex = "0.4.3" indicatif = "0.18.6" reqwest = { version = "0.13.4", features = ["json", "stream", "query"] } serde = { version = "1.0.229", features = ["derive"] } sha1 = "0.11.0" +sha2 = "0.11.0" terminal_size = "0.4.4" textwrap = "0.16.2" tokio = { version = "1.53.1", features = ["full"] } diff --git a/src/commands/create.rs b/src/commands/create.rs index 91a0a85..bd27aea 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -2,7 +2,6 @@ use std::error::Error; use dialoguer::{FuzzySelect, Select, theme::ColorfulTheme}; use futures_util::StreamExt; -use sha1::{Digest, Sha1}; use tokio::{fs::File, io::AsyncWriteExt}; use crate::{ @@ -11,7 +10,7 @@ use crate::{ fabric::FabricVersions, vanilla::{VanillaVersionDetail, VanillaVersionType, VanillaVersions}, }, - utils::{with_progressbar, with_spinner}, + utils::{verify_sha1, with_progressbar, with_spinner}, }; pub struct Create { @@ -172,12 +171,10 @@ impl LoaderDownloader for VanillaDownloader { with_spinner("verifying sha1 hash", "sha1 hash verified", || async { let data = tokio::fs::read("server.jar").await?; - let mut hasher = Sha1::new(); - hasher.update(&data); - let hash = hasher.finalize(); - let hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect(); - if hex != version_detail.downloads.server.sha1 { + let verified = verify_sha1(&data, &version_detail.downloads.server.sha1); + + if !verified { tokio::fs::remove_file("server.jar").await?; return Err("sha1 verifaction failed for downloaded file".into()); } @@ -297,6 +294,8 @@ impl LoaderDownloader for FabricDownloader { }) .await?; + tokio::fs::create_dir("mods").await?; + let config = Config { server: Server { version: create.version.clone().unwrap(), diff --git a/src/commands/install.rs b/src/commands/install.rs new file mode 100644 index 0000000..45b7579 --- /dev/null +++ b/src/commands/install.rs @@ -0,0 +1,108 @@ +use std::error::Error; + +use futures_util::StreamExt; +use tokio::io::AsyncWriteExt; + +use crate::{ + models::{ + config::Config, + install::{InstallQueryParams, InstallResult}, + }, + utils::{verify_sha512, with_progressbar, with_spinner}, +}; + +pub struct Install { + mods: Vec, +} + +impl Install { + const BASE_URL: &str = "https://api.modrinth.com/v2"; + + pub fn new(mods: Vec) -> Self { + Install { mods } + } + + pub async fn run(&self) -> Result<(), Box> { + let file_res = tokio::fs::read_to_string("mcsm.toml").await; + if file_res.is_err() { + return Err("current directory is not a mcsm project".into()); + } + let config: Config = toml::from_str(&file_res.unwrap())?; + + if config.server.loader == "Vanilla" { + return Err("cannot search for mods on Vanilla server".into()); + } + + if std::env::set_current_dir("mods").is_err() { + return Err("mods folder not found".into()); + } + + let client = reqwest::Client::new(); + + for mod_name in &self.mods { + let url = format!("{}/project/{}/version", Self::BASE_URL, mod_name); + + let params = InstallQueryParams { + loaders: format!(r#"["{}"]"#, config.server.loader).to_lowercase(), + game_versions: format!(r#"["{}"]"#, config.server.version), + }; + + let install_result: Vec = with_spinner( + format!("fetching {}", mod_name).as_str(), + format!("fetched {}", mod_name).as_str(), + || async { + let res = client.get(url).query(¶ms).send().await; + + if res.is_err() { + return Err(format!(r#"mod "{}" not found"#, mod_name).into()); + } + + Ok(res.unwrap().json().await?) + }, + ) + .await?; + + let file = install_result.first().unwrap().files.first().unwrap(); + with_progressbar( + format!("downloading {}", mod_name).as_str(), + format!("downloaded {}", mod_name).as_str(), + file.size, + |pb| async move { + let mod_bytes = reqwest::get(&file.url).await?; + let mut file = tokio::fs::File::create(&file.filename).await?; + let mut stream = mod_bytes.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + file.write_all(&chunk).await?; + pb.inc(chunk.len() as u64); + } + + Ok(()) + }, + ) + .await?; + + with_spinner("verifying sha512 hash", "sha512 hash verified", || async { + let data = tokio::fs::read(&file.filename).await?; + let verified = verify_sha512(&data, &file.hashes.sha512); + + if !verified { + tokio::fs::remove_file("server.jar").await?; + return Err("sha1 verifaction failed for downloaded file".into()); + } + + Ok(()) + }) + .await?; + println!(); + } + + println!( + "{} finished installing mods", + dialoguer::console::style("✔").green() + ); + + Ok(()) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index eede39f..83e620d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod create; +pub mod install; pub mod search; diff --git a/src/commands/search.rs b/src/commands/search.rs index aa00fe0..2b5b335 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -1,20 +1,14 @@ use std::error::Error; use colored::Colorize; -use serde::Serialize; use terminal_size; +use terminal_size::{Width, terminal_size as get_terminal_size}; use textwrap::{Options, fill}; -use terminal_size::{terminal_size as get_terminal_size, Width}; -use crate::models::{config::Config, search::SearchResult}; - -#[derive(Serialize)] -struct SearchQueryParams { - query: String, - facets: String, - offset: Option, - limit: Option, -} +use crate::models::{ + config::Config, + search::{SearchQueryParams, SearchResult}, +}; pub struct Search { query: String, @@ -25,12 +19,12 @@ pub struct Search { impl Search { const BASE_URL: &str = "https://api.modrinth.com/v2"; - pub fn new( - query: String, - offset: Option, - limit: Option, - ) -> Self { - Search { query, offset, limit } + pub fn new(query: String, offset: Option, limit: Option) -> Self { + Search { + query, + offset, + limit, + } } pub async fn run(&self) -> Result<(), Box> { @@ -66,7 +60,7 @@ impl Search { .await? .json() .await?; - + self.print_results(&res); Ok(()) @@ -75,12 +69,18 @@ impl Search { fn print_results(&self, res: &SearchResult) { println!( "{}\n", - format!("Found {} result{}", res.total_hits, if res.total_hits == 1 { "" } else { "s" }) - .green() - .bold() + format!( + "Found {} result{}", + res.total_hits, + if res.total_hits == 1 { "" } else { "s" } + ) + .green() + .bold() ); - let width = get_terminal_size().map(|(Width(w), _)| w as usize).unwrap_or(80); + let width = get_terminal_size() + .map(|(Width(w), _)| w as usize) + .unwrap_or(80); for (i, hit) in res.hits.iter().enumerate() { let wrapped = fill( diff --git a/src/main.rs b/src/main.rs index c503db0..6d33756 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ mod commands; mod models; mod utils; -use crate::commands::{create::Create, search::Search}; +use crate::commands::{create::Create, install::Install, search::Search}; use clap::{ Parser, Subcommand, builder::styling::{self}, @@ -53,13 +53,13 @@ enum Commands { #[arg(long)] show_alphas: bool, }, - + /// Search for mods on modrinth for project in current directory /// automticallly filters mod loader, minecraft version from config file Search { /// Search term to look up, e.g. project name or keyword. query: String, - + /// Number of results to skip before returning matches. #[arg(long)] offset: Option, @@ -68,6 +68,13 @@ enum Commands { #[arg(long, value_parser = clap::value_parser!(u32).range(1..=100))] limit: Option, }, + + /// Install mods from modrinth for project in current directory + Install { + /// List of mods to install + #[arg(trailing_var_arg = true)] + mods: Vec, + }, } #[tokio::main] @@ -101,10 +108,18 @@ async fn main() -> Result<(), Box> { let config = create.download_server().await?; create.save_config(config).await?; } - Commands::Search { query, offset, limit } => { + Commands::Search { + query, + offset, + limit, + } => { let search = Search::new(query, offset, limit); search.run().await?; } + Commands::Install { mods } => { + let install = Install::new(mods); + install.run().await?; + } } Ok(()) diff --git a/src/models/install.rs b/src/models/install.rs new file mode 100644 index 0000000..145f29c --- /dev/null +++ b/src/models/install.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct InstallQueryParams { + pub loaders: String, + pub game_versions: String, +} + +#[derive(Deserialize)] +pub struct InstallResult { + pub version_number: String, + pub files: Vec, +} + +#[derive(Deserialize)] +pub struct InstallFile { + pub hashes: InstallHashes, + pub url: String, + pub filename: String, + pub size: u64, +} + +#[derive(Deserialize)] +pub struct InstallHashes { + pub sha512: String, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 6a75d69..f6747d3 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod config; pub mod fabric; +pub mod install; pub mod search; pub mod vanilla; diff --git a/src/models/search.rs b/src/models/search.rs index 2be2b5e..11929d5 100644 --- a/src/models/search.rs +++ b/src/models/search.rs @@ -1,4 +1,12 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct SearchQueryParams { + pub query: String, + pub facets: String, + pub offset: Option, + pub limit: Option, +} #[derive(Deserialize)] pub struct SearchResult { diff --git a/src/utils.rs b/src/utils.rs index 9b539ce..61b5a92 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,6 @@ use indicatif::{ProgressBar, ProgressStyle}; +use sha1::{Digest, Sha1}; +use sha2::Sha512; use std::{error::Error, future::Future}; pub async fn with_spinner( @@ -42,7 +44,20 @@ where pb.set_message(start_msg.to_string()); let result = f(pb.clone()).await; - pb.finish_with_message(finish_msg.to_string()); + let formatted_finish_msg = format!("{} {}", dialoguer::console::style("✔").green(), finish_msg); + pb.finish_with_message(formatted_finish_msg); result } + +pub fn verify_sha1(data: &[u8], expected_hex: &str) -> bool { + let mut hasher = Sha1::new(); + hasher.update(data); + hex::encode(hasher.finalize()) == expected_hex +} + +pub fn verify_sha512(data: &[u8], expected_hex: &str) -> bool { + let mut hasher = Sha512::new(); + hasher.update(data); + hex::encode(hasher.finalize()) == expected_hex +}