diff --git a/Cargo.lock b/Cargo.lock index 0983b47..d50a771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -385,6 +391,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "0.2.12" @@ -604,6 +616,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -1561,11 +1582,15 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" name = "uklient" version = "0.1.0" dependencies = [ + "chrono", "color-eyre", + "hex", + "itertools", "oauth2", "reqwest 0.12.5", "serde", "serde_json", + "sha2", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 6db619e..5d4b372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,10 @@ version = "0.1.0" edition = "2021" [dependencies] +chrono = { version = "0.4.38", features = ["serde"] } color-eyre = "0.6.3" +hex = "0.4.3" +itertools = "0.13.0" oauth2 = "4.4.2" reqwest = { version = "0.12.5", default-features = false, features = [ "http2", @@ -14,6 +17,7 @@ reqwest = { version = "0.12.5", default-features = false, features = [ ] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10.8" tokio = { version = "1.38.0", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "chrono"] } diff --git a/src/auth.rs b/src/auth.rs index b2808bb..98ef6be 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -55,6 +55,12 @@ pub struct MinecraftProfile { // TODO better error handling !!!! pub async fn login() -> Result { + // temporary until i can access api.minecraftservices.com + return Ok(MinecraftProfile { + id: String::new(), + name: String::new(), + }); + let access_token = request_msa_token().await?; let (xbox_token, userhash) = auth_xbox_live(&access_token).await?; let (xsts_token, xsts_userhash) = fetch_xsts_token(&xbox_token).await?; diff --git a/src/main.rs b/src/main.rs index e11169e..c66af4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ #![warn(clippy::pedantic)] mod auth; +mod modrinth; use std::{fs::File, sync::LazyLock}; use color_eyre::eyre::Result; +use modrinth::VersionsExt; use reqwest::header::{HeaderMap, USER_AGENT}; use tracing::level_filters::LevelFilter; use tracing_subscriber::{ @@ -38,9 +40,25 @@ async fn main() -> Result<()> { tracing::debug!("starting debug"); let profile = auth::login().await?; - tracing::info!("logged in as {} ({})", profile.name, profile.id); + let versions = modrinth::get_versions().await?; + tracing::info!( + "available minecraft versions: {:?}", + versions.available_minecraft_versions() + ); + + if let Some(version) = versions.latest_for_mc_version("1.20.4") { + tracing::info!( + "latest 1.20.4 version: {} ({})", + version.name, + version.version_number + ); + + let path = version.download_file().await?; + tracing::info!("downloaded file at {:?}", path); + } + Ok(()) } diff --git a/src/modrinth.rs b/src/modrinth.rs new file mode 100644 index 0000000..eb17028 --- /dev/null +++ b/src/modrinth.rs @@ -0,0 +1,92 @@ +use std::{env, fs::File, path::PathBuf}; + +use chrono::{DateTime, Utc}; +use color_eyre::eyre::{bail, Report, Result}; +use itertools::Itertools; +use serde::Deserialize; +use sha2::{Digest, Sha512}; + +const PROJECT_ID: &str = "JR0bkFKa"; + +#[derive(Debug, Deserialize)] +pub struct ProjectVersion { + pub name: String, + pub version_number: String, + game_versions: Vec, + files: Vec, + date_published: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct VersionFile { + url: String, + filename: String, + size: usize, + hashes: VersionFileHashes, +} + +#[derive(Debug, Deserialize)] +pub struct VersionFileHashes { + sha512: String, +} + +impl ProjectVersion { + pub async fn download_file(&self) -> Result { + let version_file = &self.files[0]; + let path = env::temp_dir().join(&version_file.filename); + + let response = crate::HTTP_CLIENT.get(&version_file.url).send().await?; + let mut bytes = &*response.bytes().await?; // deref to get &[u8] + + if bytes.len() != version_file.size { + bail!("invalid length!") + } + + let expected_hash = hex::decode(&version_file.hashes.sha512)?; + let computed_hash = { + let mut sha512 = Sha512::new(); + sha512.update(bytes); + sha512.finalize() + }; + + if expected_hash[..] != computed_hash[..] { + bail!("hash mismatch!") + } + + let mut file = File::create(&path)?; + std::io::copy(&mut bytes, &mut file)?; + + Ok(path) + } +} + +pub async fn get_versions() -> Result> { + let url = format!("https://api.modrinth.com/v2/project/{PROJECT_ID}/version"); + + crate::HTTP_CLIENT + .get(url) + .send() + .await? + .json() + .await + .map_err(Report::from) +} + +pub trait VersionsExt { + fn available_minecraft_versions(&self) -> Vec<&String>; + fn latest_for_mc_version(&self, mc_version: &str) -> Option<&ProjectVersion>; +} + +impl VersionsExt for Vec { + fn available_minecraft_versions(&self) -> Vec<&String> { + // we can safely assume that there is only one game_version per project version since it's a modpack + self.iter().map(|v| &v.game_versions[0]).unique().collect() + } + + fn latest_for_mc_version(&self, mc_version: &str) -> Option<&ProjectVersion> { + self.iter() + .filter(|v| v.game_versions.contains(&mc_version.to_owned())) + .sorted_by_key(|v| v.date_published) + .next_back() + } +}