From 2fb0d8fb721087020f97f15eda3b3a501310940c Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Wed, 8 Mar 2023 09:18:19 +0100 Subject: [PATCH] feat(nexusmods): Implement NXM URI parsing --- lib/nexusmods/Cargo.toml | 1 + lib/nexusmods/src/lib.rs | 155 ++++++++++++++++++++++++++++++++++--- lib/nexusmods/src/types.rs | 10 ++- 3 files changed, 153 insertions(+), 13 deletions(-) diff --git a/lib/nexusmods/Cargo.toml b/lib/nexusmods/Cargo.toml index 1c94289..d7967ef 100644 --- a/lib/nexusmods/Cargo.toml +++ b/lib/nexusmods/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +futures = "0.3.26" lazy_static = "1.4.0" regex = "1.7.1" reqwest = { version = "0.11.14" } diff --git a/lib/nexusmods/src/lib.rs b/lib/nexusmods/src/lib.rs index e761a77..a0700ae 100644 --- a/lib/nexusmods/src/lib.rs +++ b/lib/nexusmods/src/lib.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::convert::Infallible; use lazy_static::lazy_static; @@ -8,10 +9,12 @@ use serde::Deserialize; use thiserror::Error; mod types; +use time::OffsetDateTime; pub use types::*; // TODO: Add OS information const USER_AGENT: &str = concat!("DTMM/", env!("CARGO_PKG_VERSION")); +const GAME_ID: &str = "warhammer40kdarktide"; lazy_static! { static ref BASE_URL: Url = Url::parse("https://api.nexusmods.com/v1/").unwrap(); @@ -34,10 +37,20 @@ pub enum Error { InvalidHeaderValue(#[from] InvalidHeaderValue), #[error("this error cannot happen")] Infallible(#[from] Infallible), + #[error("invalid NXM URL '{}': {0}", .1.as_str())] + InvalidNXM(&'static str, Url), } pub type Result = std::result::Result; +pub struct Nxm { + pub mod_id: u64, + pub file_id: u64, + pub user_id: u64, + pub key: String, + pub expires: OffsetDateTime, +} + pub struct Api { client: Client, } @@ -89,29 +102,138 @@ impl Api { self.send(req).await } - pub fn parse_file_name>(name: S) -> Option<(String, u64, String, u64)> { + pub fn parse_file_name>( + name: S, + ) -> Option<(String, u64, String, OffsetDateTime)> { lazy_static! { static ref RE: Regex = Regex::new(r#"^(?P.+?)-(?P[1-9]\d*)-(?P.+?)-(?P[1-9]\d*)(?:\.\w+)?$"#).unwrap(); } RE.captures(name.as_ref()).and_then(|cap| { - let name = cap.name("name")?; - let mod_id = cap.name("mod_id")?; - let version = cap.name("version")?; - let updated = cap.name("updated")?; + let name = cap.name("name").map(|s| s.as_str().to_string())?; + let mod_id = cap.name("mod_id").and_then(|s| s.as_str().parse().ok())?; + let version = cap.name("version").map(|s| s.as_str().to_string())?; + let updated = cap + .name("updated") + .and_then(|s| s.as_str().parse().ok()) + .and_then(|s| OffsetDateTime::from_unix_timestamp(s).ok())?; - Some(( - name.as_str().to_string(), - mod_id.as_str().parse().ok()?, - version.as_str().to_string(), - updated.as_str().parse().ok()?, - )) + Some((name, mod_id, version, updated)) + }) + } + + #[tracing::instrument(skip(self))] + pub async fn mods_download_link( + &self, + mod_id: u64, + file_id: u64, + key: String, + expires: OffsetDateTime, + ) -> Result> { + let url = + BASE_URL_GAME.join(&format!("mods/{mod_id}/files/{file_id}/download_link.json"))?; + let req = self + .client + .get(url) + .query(&[("key", key)]) + .query(&[("expires", expires.unix_timestamp())]); + self.send(req).await + } + + pub async fn handle_nxm(&self, url: Url) -> Result<(Mod, Vec)> { + let nxm = Self::parse_nxm(url.clone())?; + + let user = self.user_validate().await?; + + if nxm.user_id != user.user_id { + return Err(Error::InvalidNXM("user_id mismtach", url)); + } + + let (mod_data, download_info) = futures::try_join!( + self.mods_id(nxm.mod_id), + self.mods_download_link(nxm.mod_id, nxm.file_id, nxm.key, nxm.expires) + )?; + + let Some(download_url) = download_info.get(0).map(|i| i.uri.clone()) else { + return Err(Error::InvalidNXM("no download link", url)); + }; + + let req = self.client.get(download_url); + let data = req.send().await?.bytes().await?; + + Ok((mod_data, data.to_vec())) + } + + pub fn parse_nxm(nxm: Url) -> Result { + if nxm.scheme() != "nxm" { + return Err(Error::InvalidNXM("Invalid scheme", nxm)); + } + + // Now it makes sense, why Nexus calls this field `game_domain_name`, when it's just + // another path segmentin the regular API calls. + if nxm.host_str() != Some(GAME_ID) { + return Err(Error::InvalidNXM("Invalid game domain name", nxm)); + } + + let Some(mut segments) = nxm.path_segments() else { + return Err(Error::InvalidNXM("Cannot be a base", nxm)); + }; + + if segments.next() != Some("mods") { + return Err(Error::InvalidNXM("Unexpected path segment", nxm)); + } + + let Some(mod_id) = segments.next().and_then(|id| id.parse().ok()) else { + return Err(Error::InvalidNXM("Invalid mod ID", nxm)); + }; + + if segments.next() != Some("files") { + return Err(Error::InvalidNXM("Unexpected path segment", nxm)); + } + + let Some(file_id) = segments.next().and_then(|id| id.parse().ok()) else { + return Err(Error::InvalidNXM("Invalid file ID", nxm)); + }; + + let mut query = HashMap::new(); + let pairs = nxm.query_pairs(); + + for (key, val) in pairs { + query.insert(key, val); + } + + let Some(key) = query.get("key") else { + return Err(Error::InvalidNXM("Missing 'key'", nxm)); + }; + + let expires = query + .get("expires") + .and_then(|expires| expires.parse().ok()) + .and_then(|expires| OffsetDateTime::from_unix_timestamp(expires).ok()); + let Some(expires) = expires else { + return Err(Error::InvalidNXM("Missing 'expires'", nxm)); + }; + + let user_id = query.get("user_id").and_then(|id| id.parse().ok()); + let Some(user_id) = user_id else { + return Err(Error::InvalidNXM("Missing 'user_id'", nxm)); + }; + + Ok(Nxm { + mod_id, + file_id, + key: key.to_string(), + expires, + user_id, }) } } #[cfg(test)] mod test { + use reqwest::Url; + use time::OffsetDateTime; + use crate::Api; fn make_api() -> Api { @@ -155,6 +277,15 @@ mod test { assert_eq!(name, String::from("Darktide Mod Framework")); assert_eq!(mod_id, 8); assert_eq!(version, String::from("23-3-04")); - assert_eq!(updated, 1677966575); + assert_eq!( + updated, + OffsetDateTime::from_unix_timestamp(1677966575).unwrap() + ); + } + + #[test] + fn parse_nxm() { + let nxm = Url::parse("nxm://warhammer40kdarktide/mods/8/files/1000172397?key=VZ86Guj_LosPvtkD90-ZQg&expires=1678359882&user_id=1234567").expect("invalid NXM example"); + Api::parse_nxm(nxm).expect("failed to parse nxm link"); } } diff --git a/lib/nexusmods/src/types.rs b/lib/nexusmods/src/types.rs index fade6c1..e811d29 100644 --- a/lib/nexusmods/src/types.rs +++ b/lib/nexusmods/src/types.rs @@ -63,7 +63,15 @@ pub struct Mod { // pub contains_adult_content: bool, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Debug, Deserialize)] +pub struct DownloadLink { + pub name: String, + pub short_name: String, + #[serde(alias = "URI")] + pub uri: Url, +} + +#[derive(Debug, Deserialize)] pub struct UpdateInfo { pub mod_id: u64, #[serde(with = "time::serde::timestamp")]