use crate::State; use crate::util::fetch::INSECURE_REQWEST_CLIENT; use bytes::Bytes; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; const CONNECTOR_URL_ENV: &str = "AXOLOTL_PLANET_MINECRAFT_CONNECTOR_URL"; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlanetMinecraftProject { pub id: String, pub title: String, pub page_url: String, #[serde(default)] pub summary: Option, #[serde(default)] pub versions: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlanetMinecraftVersion { pub id: String, pub name: String, pub game_versions: Vec, pub download: PlanetMinecraftDownload, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlanetMinecraftDownload { pub page_url: String, #[serde(default)] pub file_name: Option, #[serde(default)] pub direct_url: Option, #[serde(default)] pub sha256: Option, } #[derive(Clone, Debug, Serialize)] #[serde(tag = "state", rename_all = "snake_case")] pub enum PlanetMinecraftInstallRoute { Automatic { direct_url: String, sha256: String, file_name: Option, }, Manual { page_url: String, file_name: Option, }, } impl PlanetMinecraftDownload { pub fn install_route(&self) -> PlanetMinecraftInstallRoute { match ( self.direct_url.as_deref().filter(|url| { !url.trim().is_empty() && validated_download_url(url).is_ok() }), self.sha256 .as_deref() .filter(|hash| !hash.trim().is_empty()), ) { (Some(direct_url), Some(sha256)) => { PlanetMinecraftInstallRoute::Automatic { direct_url: direct_url.to_string(), sha256: sha256.to_string(), file_name: self.file_name.clone(), } } _ => PlanetMinecraftInstallRoute::Manual { page_url: self.page_url.clone(), file_name: self.file_name.clone(), }, } } } pub fn connector_base_url() -> crate::Result { let value = std::env::var(CONNECTOR_URL_ENV).map_err(|_| { crate::ErrorKind::InputError( "Planet Minecraft connector is not configured".to_string(), ) })?; let url = reqwest::Url::parse(&value).map_err(|_| { crate::ErrorKind::InputError( "Planet Minecraft connector URL is invalid".to_string(), ) })?; if url.scheme() != "https" { return Err(crate::ErrorKind::InputError( "Planet Minecraft connector must use HTTPS".to_string(), ) .into()); } Ok(value.trim_end_matches('/').to_string()) } pub async fn search_projects( query: &str, game_version: Option<&str>, ) -> crate::Result> { let mut url = format!( "{}/projects?query={}", connector_base_url()?, urlencoding::encode(query) ); if let Some(game_version) = game_version.filter(|value| !value.is_empty()) { url.push_str(&format!( "&game_version={}", urlencoding::encode(game_version) )); } connector_get(&url).await } pub async fn get_project(id: &str) -> crate::Result { connector_get(&format!( "{}/projects/{}", connector_base_url()?, urlencoding::encode(id) )) .await } pub async fn download_verified_file( direct_url: &str, expected_sha256: &str, ) -> crate::Result { let url = validated_download_url(direct_url)?; if expected_sha256.trim().is_empty() { return Err(crate::ErrorKind::InputError( "Planet Minecraft downloads require a SHA-256".to_string(), ) .into()); } let state = State::get().await?; let _permit = state.api_semaphore.0.acquire().await?; let bytes = INSECURE_REQWEST_CLIENT .get(url) .send() .await? .error_for_status()? .bytes() .await?; if bytes.is_empty() { return Err(crate::ErrorKind::InputError( "Planet Minecraft returned an empty file".to_string(), ) .into()); } let actual = format!("{:x}", Sha256::digest(&bytes)); if !actual.eq_ignore_ascii_case(expected_sha256.trim()) { return Err(crate::ErrorKind::OtherError( "Planet Minecraft SHA-256 mismatch".to_string(), ) .into()); } Ok(bytes) } fn validated_download_url(value: &str) -> crate::Result { let url = reqwest::Url::parse(value).map_err(|_| { crate::ErrorKind::InputError( "Planet Minecraft download URL is invalid".to_string(), ) })?; if url.scheme() != "https" { return Err(crate::ErrorKind::InputError( "Planet Minecraft download URL must use HTTPS".to_string(), ) .into()); } Ok(url) } async fn connector_get(url: &str) -> crate::Result where T: for<'de> Deserialize<'de>, { let state = State::get().await?; let _permit = state.api_semaphore.0.acquire().await?; Ok(INSECURE_REQWEST_CLIENT .get(url) .send() .await? .error_for_status()? .json() .await?) } #[cfg(test)] mod tests { use super::*; #[test] fn downloads_without_a_hash_remain_manual() { let download = PlanetMinecraftDownload { page_url: "https://www.planetminecraft.com/mod/example".to_string(), file_name: Some("example.jar".to_string()), direct_url: Some("https://host.example/example.jar".to_string()), sha256: None, }; assert!(matches!( download.install_route(), PlanetMinecraftInstallRoute::Manual { .. } )); } #[test] fn direct_downloads_require_https() { assert!(validated_download_url("http://example.com/file.jar").is_err()); assert!(validated_download_url("https://example.com/file.jar").is_ok()); } #[test] fn non_https_direct_downloads_fall_back_to_manual_import() { let download = PlanetMinecraftDownload { page_url: "https://www.planetminecraft.com/mod/example".to_string(), file_name: Some("example.jar".to_string()), direct_url: Some("http://host.example/example.jar".to_string()), sha256: Some("abc".to_string()), }; assert!(matches!( download.install_route(), PlanetMinecraftInstallRoute::Manual { .. } )); } }