From 6b99f82cea49b945e3aba07f0e208a0c167b5b93 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 30 Jan 2024 17:42:58 -0800 Subject: [PATCH] api modifications --- Cargo.lock | 20 + theseus/Cargo.toml | 2 +- theseus/src/api/handler.rs | 3 + theseus/src/api/mod.rs | 2 + theseus/src/api/pack/import/atlauncher.rs | 2 +- theseus/src/api/pack/install_from.rs | 14 +- theseus/src/api/profile/create.rs | 8 +- theseus/src/api/profile/mod.rs | 17 +- theseus/src/api/profile/update.rs | 34 +- theseus/src/api/shared_profile.rs | 656 ++++++++++++++++++++++ theseus/src/config.rs | 3 +- theseus/src/event/mod.rs | 3 + theseus/src/state/mod.rs | 7 +- theseus/src/state/mr_auth.rs | 5 +- theseus/src/state/profiles.rs | 151 ++++- 15 files changed, 873 insertions(+), 54 deletions(-) create mode 100644 theseus/src/api/shared_profile.rs diff --git a/Cargo.lock b/Cargo.lock index 47156347d..3011452e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,6 +2498,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minidump-common" version = "0.14.0" @@ -3582,6 +3592,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -5237,6 +5248,15 @@ dependencies = [ "libc", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.13" diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index c2c35d954..99b64bf03 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -46,7 +46,7 @@ indicatif = { version = "0.17.3", optional = true } async-tungstenite = { version = "0.22.1", features = ["tokio-runtime", "tokio-native-tls"] } futures = "0.3" -reqwest = { version = "0.11", features = ["json", "stream"] } +reqwest = { version = "0.11", features = ["json", "stream", "multipart", "blocking"] } tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["fs"] } async-recursion = "1.0.4" diff --git a/theseus/src/api/handler.rs b/theseus/src/api/handler.rs index fd6b766ec..5726f11c8 100644 --- a/theseus/src/api/handler.rs +++ b/theseus/src/api/handler.rs @@ -14,6 +14,9 @@ use crate::{ /// (Does not include modrinth://) pub async fn handle_url(sublink: &str) -> crate::Result { Ok(match sublink.split_once('/') { + Some(("shared_profile", link)) => { + CommandPayload::OpenSharedProfile { link: link.to_string() } + }, // /mod/{id} - Installs a mod of mod id Some(("mod", id)) => CommandPayload::InstallMod { id: id.to_string() }, // /version/{id} - Installs a specific version of id diff --git a/theseus/src/api/mod.rs b/theseus/src/api/mod.rs index 37ca4b4d1..6b6edeeb4 100644 --- a/theseus/src/api/mod.rs +++ b/theseus/src/api/mod.rs @@ -10,6 +10,7 @@ pub mod pack; pub mod process; pub mod profile; pub mod safety; +pub mod shared_profile; pub mod settings; pub mod tags; @@ -37,5 +38,6 @@ pub mod prelude { jre::JavaVersion, }, State, + shared_profile, }; } diff --git a/theseus/src/api/pack/import/atlauncher.rs b/theseus/src/api/pack/import/atlauncher.rs index 8aa31758d..9b155ec4f 100644 --- a/theseus/src/api/pack/import/atlauncher.rs +++ b/theseus/src/api/pack/import/atlauncher.rs @@ -215,7 +215,7 @@ async fn import_atlauncher_unmanaged( .clone() .unwrap_or_else(|| backup_name.to_string()); prof.install_stage = ProfileInstallStage::PackInstalling; - prof.metadata.linked_data = Some(LinkedData { + prof.metadata.linked_data = Some(LinkedData::ModrinthModpack { project_id: description.project_id.clone(), version_id: description.version_id.clone(), locked: Some( diff --git a/theseus/src/api/pack/install_from.rs b/theseus/src/api/pack/install_from.rs index 10a4484c2..7a5de4054 100644 --- a/theseus/src/api/pack/install_from.rs +++ b/theseus/src/api/pack/install_from.rs @@ -159,7 +159,7 @@ pub fn get_profile_from_pack( } => CreatePackProfile { name: title, icon_url, - linked_data: Some(LinkedData { + linked_data: Some(LinkedData::ModrinthModpack { project_id: Some(project_id), version_id: Some(version_id), locked: Some(true), @@ -394,13 +394,21 @@ pub async fn set_profile_information( prof.metadata.linked_data = if project_id.is_some() && version_id.is_some() { - Some(LinkedData { + Some(LinkedData::ModrinthModpack { project_id, version_id, locked: if !ignore_lock { Some(true) } else { - prof.metadata.linked_data.as_ref().and_then(|x| x.locked) + prof.metadata.linked_data.as_ref().and_then(|x| if let LinkedData::ModrinthModpack { + locked: Some(locked), + .. + } = x + { + Some(*locked) + } else { + None + }) }, }) } else { diff --git a/theseus/src/api/profile/create.rs b/theseus/src/api/profile/create.rs index 770da0523..3a2fb7a5b 100644 --- a/theseus/src/api/profile/create.rs +++ b/theseus/src/api/profile/create.rs @@ -102,10 +102,10 @@ pub async fn profile_create( } profile.metadata.linked_data = linked_data; - if let Some(linked_data) = &mut profile.metadata.linked_data { - linked_data.locked = Some( - linked_data.project_id.is_some() - && linked_data.version_id.is_some(), + if let Some(LinkedData::ModrinthModpack{ project_id, version_id, locked, .. }) = &mut profile.metadata.linked_data { + *locked = Some( + project_id.is_some() + && version_id.is_some(), ); } diff --git a/theseus/src/api/profile/mod.rs b/theseus/src/api/profile/mod.rs index 7e35204ff..ee0785d21 100644 --- a/theseus/src/api/profile/mod.rs +++ b/theseus/src/api/profile/mod.rs @@ -3,6 +3,7 @@ use crate::event::emit::{ emit_loading, init_loading, loading_try_for_each_concurrent, }; +use crate::state::LinkedData; use crate::event::LoadingBarType; use crate::pack::install_from::{ EnvType, PackDependency, PackFile, PackFileHash, PackFormat, @@ -67,13 +68,11 @@ pub async fn get( let state = State::get().await?; let profiles = state.profiles.read().await; let mut profile = profiles.0.get(path).cloned(); - if clear_projects.unwrap_or(false) { if let Some(profile) = &mut profile { profile.projects = HashMap::new(); } } - Ok(profile) } @@ -118,14 +117,14 @@ pub async fn get_mod_full_path( ) -> crate::Result { if get(profile_path, Some(true)).await?.is_some() { let full_path = io::canonicalize( - project_path.get_full_path(profile_path.clone()).await?, + project_path.get_full_path(&profile_path).await?, )?; return Ok(full_path); } Err(crate::ErrorKind::OtherError(format!( "Tried to get the full path of a nonexistent or unloaded project at path {}!", - project_path.get_full_path(profile_path.clone()).await?.display() + project_path.get_full_path(&profile_path).await?.display() )) .into()) } @@ -874,7 +873,13 @@ pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> { let res = if updated_recent_playtime > 0 { // Create update struct to send to Labrinth let modrinth_pack_version_id = - profile.metadata.linked_data.and_then(|l| l.version_id); + profile.metadata.linked_data.and_then(|l| + if let LinkedData::ModrinthModpack { + version_id, + .. + } = l { + Some(version_id) + } else { None }); let playtime_update_json = json!({ "seconds": updated_recent_playtime, "loader": profile.metadata.loader.to_string(), @@ -892,7 +897,7 @@ pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> { let creds = state.credentials.read().await; fetch::post_json( - "https://api.modrinth.com/analytics/playtime", + "https://staging-api.modrinth.com/analytics/playtime", serde_json::to_value(hashmap)?, &state.fetch_semaphore, &creds, diff --git a/theseus/src/api/profile/update.rs b/theseus/src/api/profile/update.rs index e46ceb068..35f00bbba 100644 --- a/theseus/src/api/profile/update.rs +++ b/theseus/src/api/profile/update.rs @@ -6,7 +6,7 @@ use crate::{ pack::{self, install_from::generate_pack_from_version_id}, prelude::{ProfilePathId, ProjectPathId}, profile::get, - state::{ProfileInstallStage, Project}, + state::{ProfileInstallStage, Project, LinkedData}, LoadingBarType, State, }; use futures::try_join; @@ -30,15 +30,13 @@ pub async fn update_managed_modrinth_version( }; // Extract modrinth pack information, if appropriate - let linked_data = profile - .metadata - .linked_data - .as_ref() - .ok_or_else(unmanaged_err)?; - let project_id: &String = - linked_data.project_id.as_ref().ok_or_else(unmanaged_err)?; - let version_id = - linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?; + let Some(LinkedData::ModrinthModpack{ + project_id: Some(ref project_id), + version_id: Some(ref version_id), + .. + }) = profile.metadata.linked_data else { + return Err(unmanaged_err().into()); + }; // Replace the pack with the new version replace_managed_modrinth( @@ -107,15 +105,13 @@ pub async fn repair_managed_modrinth( .await?; // Extract modrinth pack information, if appropriate - let linked_data = profile - .metadata - .linked_data - .as_ref() - .ok_or_else(unmanaged_err)?; - let project_id: &String = - linked_data.project_id.as_ref().ok_or_else(unmanaged_err)?; - let version_id = - linked_data.version_id.as_ref().ok_or_else(unmanaged_err)?; + let Some(LinkedData::ModrinthModpack{ + project_id: Some(ref project_id), + version_id: Some(ref version_id), + .. + }) = profile.metadata.linked_data else { + return Err(unmanaged_err().into()); + }; // Replace the pack with the same version replace_managed_modrinth( diff --git a/theseus/src/api/shared_profile.rs b/theseus/src/api/shared_profile.rs new file mode 100644 index 000000000..fe840660c --- /dev/null +++ b/theseus/src/api/shared_profile.rs @@ -0,0 +1,656 @@ +use std::path::PathBuf; + +use chrono::{DateTime,Utc}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use crate::{config::MODRINTH_API_URL_INTERNAL, prelude::{LinkedData, ModLoader, ProfilePathId, ProjectMetadata, ProjectPathId}, profile, util::{fetch::{fetch_advanced, REQWEST_CLIENT}, io}, state::{Profile, Profiles}}; + +#[derive(Deserialize, Serialize, Debug)] +pub struct SharedProfile { + pub id: String, + pub name: String, + pub is_owned: bool, // Whether we are the owner (intentionally redundant) + pub owner_id: String, + pub icon_url: Option, + pub loader: ModLoader, + pub loader_version: String, + pub game_version: String, + + pub updated_at: DateTime, + pub created_at: DateTime, + + pub versions: Vec, + pub overrides: Vec, + + pub share_links: Option>, + pub users: Option> +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct SharedProfileLink { + pub id: String, + pub created: DateTime, + pub expires: DateTime, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct SharedProfileOverride { + pub url: String, + pub install_path: PathBuf, + pub hashes: SharedProfileOverrideHashes, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct SharedProfileOverrideHashes { + pub sha1: String, + pub sha512: String, +} + +// Create a new shared profile from ProfilePathId +// This converts the LinkedData to a SharedProfile and uploads it to the Labrinth API +#[tracing::instrument] +pub async fn create( + profile_id: ProfilePathId, +) -> crate::Result<()> { + let state = crate::State::get().await?; + + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + let creds = state.credentials.read().await; + let creds = creds.0.as_ref().ok_or_else(|| crate::ErrorKind::NoCredentialsError)?; + + // Currently existing linked data should fail + match profile.metadata.linked_data { + Some(LinkedData::SharedProfile { .. }) => { + return Err(crate::ErrorKind::OtherError("Profile already linked to a shared profile".to_string()).as_error()); + }, + Some(LinkedData::ModrinthModpack { .. }) => { + return Err(crate::ErrorKind::OtherError("Profile already linked to a modrinth project".to_string()).as_error()); + }, + None => {} + }; + + let name = profile.metadata.name; + let loader = profile.metadata.loader; + let loader_version = profile.metadata.loader_version; + let game_version = profile.metadata.game_version; + + let modrinth_projects : Vec<_> = profile.projects.iter() + .filter_map(|(_, project)|if let ProjectMetadata::Modrinth { ref version, .. } = project.metadata { + Some(&version.id) + } else { + None + }).collect(); + + let override_files : Vec<_> = profile.projects.iter() + .filter_map(|(id, project)|if let ProjectMetadata::Inferred { ..} = project.metadata { + Some(id) + } else { + None + }).collect(); + + // Create the profile on the Labrinth API + let response = REQWEST_CLIENT + .post( + format!("{MODRINTH_API_URL_INTERNAL}client/profile"), + ).header("Authorization", &creds.session) + .json(&serde_json::json!({ + "name": name, + "loader": loader.as_api_str(), + "loader_version": loader_version.map(|x| x.id).unwrap_or_default(), + "game": "minecraft-java", + "game_version": game_version, + "versions": modrinth_projects, + })) + .send().await?; + + let profile_response = response.json::().await?; + + // Extract the profile ID from the response + let shared_profile_id = profile_response["id"].as_str().ok_or_else(|| crate::ErrorKind::OtherError("Could not parse response from Labrinth API".to_string()))?.to_string(); + + // Unmanaged projects + let mut data = vec![]; // 'data' field, giving installation context to labrinth + let mut parts = vec![]; // 'parts' field, containing the actual files + + for override_file in override_files { + let path = override_file.get_inner_path_unix(); + let Some(name) = path.0.split('/').last().map(|x| x.to_string()) else { continue }; + + // Load override to file + let full_path = &override_file.get_full_path(&profile_id).await?; + let file_bytes = io::read(full_path).await?; + let ext = full_path.extension().and_then(|x| x.to_str()).unwrap_or_default(); + let mime = project_file_type(ext).ok_or_else(|| crate::ErrorKind::OtherError(format!("Could not determine file type for {}", ext)))?; + + data.push(serde_json::json!({ + "file_name": name.clone(), + "install_path": path + })); + + let part = reqwest::multipart::Part::bytes(file_bytes).file_name(name.clone()).mime_str(mime)?; + parts.push((name.clone(), part)); + } + + // Build multipart with 'data' field first + let mut multipart = reqwest::multipart::Form::new().percent_encode_noop(); + let json_part = reqwest::multipart::Part::text(serde_json::to_string(&data)?);//mime_str("application/json")?; + multipart = multipart.part("data", json_part); + for (name, part) in parts { + multipart = multipart.part(name, part); + } + let response = REQWEST_CLIENT.post( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile_id}/override"), + ) + .header("Authorization", &creds.session) + .multipart(multipart); + + response.send().await?.error_for_status()?; + + // Update the profile with the new linked data + profile::edit(&profile_id, |profile| { + let shared_profile_id = shared_profile_id.clone(); + profile.metadata.linked_data = Some(LinkedData::SharedProfile { + profile_id: shared_profile_id, + is_owner: true, + }); + async { Ok(()) } + }).await?; + + // Sync + crate::State::sync().await?; + + Ok(()) +} + +pub fn project_file_type(ext: &str) -> Option<&str> { + match ext { + "jar" => Some("application/java-archive"), + "zip" | "litemod" => Some("application/zip"), + "mrpack" => Some("application/x-modrinth-modpack+zip"), + _ => None, + } +} + +#[tracing::instrument] +pub async fn get_all() -> crate::Result> { + let state = crate::State::get().await?; + let creds = state.credentials.read().await; + let creds = creds.0.as_ref().ok_or_else(|| crate::ErrorKind::NoCredentialsError)?; + + // First, get list of shared profiles the user has access to + #[derive(Deserialize, Serialize, Debug)] + pub struct SharedProfileResponse { + pub id: String, + pub name: String, + pub owner_id: String, + pub created: DateTime, + pub updated: DateTime, + pub icon_url: Option, + + pub loader: ModLoader, + pub game : String, + + pub loader_version: String, + pub game_version: String, + + // Present only if we are the owner + pub share_links: Option>, + pub users: Option>, + } + + let response = REQWEST_CLIENT + .get( + format!("{MODRINTH_API_URL_INTERNAL}client/user"), + ) + .header("Authorization", &creds.session) + .send().await?.error_for_status()?; + + let profiles = response.json::>().await?; + + // Next, get files for each shared profile + // TODO: concurrent requests + #[derive(Serialize, Deserialize)] + pub struct SharedFiles { + pub version_ids: Vec, + pub override_cdns: Vec, + } + + let mut shared_profiles = vec![]; + for profile in profiles.into_iter() { + if profile.game != "minecraft-java" { + continue; + } + + let id = profile.id; + let response = REQWEST_CLIENT + .get( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/{id}/files"), + ) + .header("Authorization", &creds.session) + .send().await?.error_for_status()?; + + let files = response.json::().await?; + + shared_profiles.push(SharedProfile { + id, + name: profile.name, + is_owned: profile.owner_id == state.credentials.read().await.0.as_ref().map(|x| x.user.id.as_str()).unwrap_or_default(), + owner_id: profile.owner_id, + loader: profile.loader, + loader_version: profile.loader_version, + game_version: profile.game_version, + icon_url: profile.icon_url, + versions: files.version_ids, + overrides: files.override_cdns, + share_links: profile.share_links, + users: profile.users, + updated_at: profile.updated, + created_at: profile.created, + }); + } + + Ok(shared_profiles) +} + +#[tracing::instrument] +pub async fn install(shared_profile : SharedProfile) -> crate::Result { + let state = crate::State::get().await?; + + let linked_data = LinkedData::SharedProfile { + profile_id: shared_profile.id, + is_owner: shared_profile.is_owned, + }; + + // Create new profile + let profile_id = crate::profile::create::profile_create( + shared_profile.name, + shared_profile.game_version, + shared_profile.loader, + Some(shared_profile.loader_version), + None, + shared_profile.icon_url, + Some(linked_data), + None, + None, + ).await?; + + // Get the profile + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + let creds = state.credentials.read().await; + + // TODO: concurrent requests + // Add projects + for version in shared_profile.versions { + profile.add_project_version(version).await?; + } + + for file_override in shared_profile.overrides { + let file = fetch_advanced( + Method::GET, + &file_override.url, + Some(file_override.hashes.sha1.as_str()), + None, + None, + None, + &state.fetch_semaphore, + &creds, + ) + .await?; + + profile.add_project_bytes_directly(&file_override.install_path, file).await?; + } + + + Ok(profile_id) +} + + +// Structure repesenting a synchronization difference between a local profile and a shared profile +#[derive(Default, Serialize, Deserialize, Clone, Debug)] +pub struct SharedModpackFileUpdate { + // Can be false if all other fields are empty + // if the metadata is different + pub is_synced: bool, + + // Projects that are in the local profile but not in the shared profile + pub unsynced_projects: Vec, + + // Projects that are in the shared profile but not in the local profile + pub missing_versions: Vec, + pub missing_overrides: Vec, +} + +#[tracing::instrument] +pub async fn check_updated(profile_id: &ProfilePathId, shared_profile : &SharedProfile) -> crate::Result { + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + + // Check if the metadata is the same- if different, we return false with no file updates + if profile.metadata.name != shared_profile.name || + profile.metadata.loader != shared_profile.loader || + profile.metadata.loader_version.map(|x| x.id).unwrap_or_default() != shared_profile.loader_version || + profile.metadata.game_version != shared_profile.game_version { + return Ok(SharedModpackFileUpdate::default()); + } + + // Check if the projects are the same- we check each override by hash and each modrinth project by version id + let mut modrinth_projects = shared_profile.versions.clone(); + let mut overrides = shared_profile.overrides.clone(); + let unsynced_projects : Vec<_> = profile.projects.into_iter().filter_map(|(id, project)|{ + match project.metadata { + ProjectMetadata::Modrinth { ref version, .. } => { + if modrinth_projects.contains(&version.id) { + modrinth_projects.retain(|x| x != &version.id); + } + else { + return Some(id); + } + }, + ProjectMetadata::Inferred { .. } => { + let Some(matching_override) = overrides.iter().position(|o| o.install_path.to_string_lossy().to_string() == id.get_inner_path_unix().0) + else { + return Some(id); + }; + + if let Some(o) = overrides.get(matching_override) { + if o.hashes.sha512 != project.sha512 { + return Some(id); + } + } else { + return Some(id); + } + overrides.remove(matching_override); + } + ProjectMetadata::Unknown => { + // TODO: What to do for unknown projects? + return Some(id) + } + } + None + }).collect(); + + Ok(SharedModpackFileUpdate { + is_synced: modrinth_projects.is_empty() && overrides.is_empty() && unsynced_projects.is_empty(), + unsynced_projects, + missing_versions: modrinth_projects, + missing_overrides: overrides, + }) + +} + +// Updates projects for a given ProfilePathId from a SharedProfile +// This updates the local profile to match the shared profile on the Labrinth API +#[tracing::instrument] +pub async fn inbound_sync( + profile_id: ProfilePathId, +) -> crate::Result<()> { + let state = crate::State::get().await?; + + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + let creds = state.credentials.read().await; + + // Get linked + let shared_profile = match profile.metadata.linked_data { + Some(LinkedData::SharedProfile { ref profile_id, .. }) => profile_id, + _ => return Err(crate::ErrorKind::OtherError("Profile is not linked to a shared profile".to_string()).as_error()), + }; + + // Get updated shared profile + let shared_profile = get_all().await?.into_iter().find(|x| &x.id == shared_profile).ok_or_else(|| crate::ErrorKind::OtherError("Profile is not linked to a shared profile".to_string()))?; + + let update_data = check_updated(&profile_id, &shared_profile).await?; + if update_data.is_synced { + return Ok(()); + } + + // Remove projects- unsynced projects need to be removed + for project in update_data.unsynced_projects { + profile.remove_project(&project, None).await?; + } + + // TODO: concurrent requests + // Add projects- missing projects need to be added + for version in update_data.missing_versions { + profile.add_project_version(version).await?; + } + + for file_override in update_data.missing_overrides { + let file = fetch_advanced( + Method::GET, + &file_override.url, + Some(file_override.hashes.sha1.as_str()), + None, + None, + None, + &state.fetch_semaphore, + &creds, + ) + .await?; + + profile.add_project_bytes_directly(&file_override.install_path, file).await?; + } + + Ok(()) +} + +// Updates metadata for a given ProfilePathId to the Labrinth API +// Must be an owner of the shared profile +#[tracing::instrument] +pub async fn outbound_sync( + profile_id: ProfilePathId, +) -> crate::Result<()> { + let state = crate::State::get().await?; + + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + let creds = state.credentials.read().await; + let creds = creds.0.as_ref().ok_or_else(|| crate::ErrorKind::NoCredentialsError)?; + + // Get linked + let shared_profile = match profile.metadata.linked_data { + Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id, + _ => return Err(crate::ErrorKind::OtherError("Profile is not linked to a shared profile".to_string()).as_error()), + }; + + // Get updated shared profile + let shared_profile = get_all().await?.into_iter().find(|x| x.id == shared_profile).ok_or_else(|| crate::ErrorKind::OtherError("Profile is not linked to a shared profile".to_string()))?; + + // Check owner + if !shared_profile.is_owned { + return Err(crate::ErrorKind::OtherError("Profile is not owned by the current user".to_string()).as_error()); + } + + // Check if we are synced + let update_data = check_updated(&profile_id, &shared_profile).await?; + let id = shared_profile.id; + if update_data.is_synced { + return Ok(()); + } + + let unsynced = update_data.unsynced_projects; + let projects : Vec<_> = profile.projects.clone().into_iter().filter(|(id, _)| unsynced.contains(id)).collect(); + let unsynced_modrinth_projects : Vec<_> = projects.iter() + .filter_map(|(_, project)|if let ProjectMetadata::Modrinth { ref version, .. } = project.metadata { + Some(&version.id) + } else { + None + }).collect(); + + let unsynced_override_files : Vec<_> = projects.iter() + .filter_map(|(id, project)|if let ProjectMetadata::Inferred { ..} = project.metadata { + Some(id) + } else { + None + }).collect(); + + // Generate new version set + let mut new_version_set = shared_profile.versions; + for version in update_data.missing_versions { + new_version_set.retain(|x| x != &version); + } + for version in unsynced_modrinth_projects { + new_version_set.push(version.to_string()); + } + + // Update metadata + versions + REQWEST_CLIENT + .patch( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/{id}"), + ) + .header("Authorization", &creds.session) + .json(&serde_json::json!({ + "name": profile.metadata.name, + "loader": profile.metadata.loader.as_api_str(), + "loader_version": profile.metadata.loader_version.map(|x| x.id).unwrap_or_default(), + "game": "minecraft-java", + "game_version": profile.metadata.game_version, + "versions": new_version_set, + })) + .send().await?.error_for_status()?; + + // Create multipart for uploading new overrides + let mut parts = vec![]; // 'parts' field, containing the actual files + let mut data = vec![]; // 'data' field, giving installation context to labrinth + for override_file in unsynced_override_files { + let path = override_file.get_inner_path_unix(); + let Some(name) = path.0.split('/').last().map(|x| x.to_string()) else { continue }; + + // Load override to file + let full_path = &override_file.get_full_path(&profile_id).await?; + let file_bytes = io::read(full_path).await?; + let ext = full_path.extension().and_then(|x| x.to_str()).unwrap_or_default(); + let mime = project_file_type(ext).ok_or_else(|| crate::ErrorKind::OtherError(format!("Could not determine file type for {}", ext)))?; + + data.push(serde_json::json!({ + "file_name": name.clone(), + "install_path": path + })); + + let part = reqwest::multipart::Part::bytes(file_bytes).file_name(name.clone()).mime_str(mime)?; + parts.push((name.clone(), part)); + } + + // Build multipart with 'data' field first + let mut multipart = reqwest::multipart::Form::new().percent_encode_noop(); + let json_part = reqwest::multipart::Part::text(serde_json::to_string(&data)?);//mime_str("application/json")?; + multipart = multipart.part("data", json_part); + for (name, part) in parts { + multipart = multipart.part(name, part); + } + let response = REQWEST_CLIENT.post( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/{id}/override"), + ) + .header("Authorization", &creds.session) + .multipart(multipart); + + response.send().await?.error_for_status()?; + + // Cannot fail, simply re-checks its synced with the shared profile + Profiles::update_shared_projects().await; + + Ok(()) +} + +pub async fn remove_shared_profile_users( + profile_id: ProfilePathId, + users: Vec, +) -> crate::Result<()> { + let state = crate::State::get().await?; + + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + let creds = state.credentials.read().await; + let creds = creds.0.as_ref().ok_or_else(|| crate::ErrorKind::NoCredentialsError)?; + + let shared_profile = match profile.metadata.linked_data { + Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id, + _ => return Err(crate::ErrorKind::OtherError("Profile is not linked to a shared profile".to_string()).as_error()), + }; + + REQWEST_CLIENT + .patch( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile}"), + ) + .header("Authorization", &creds.session) + .json(&serde_json::json!({ + "remove_users": users, + })) + .send().await?.error_for_status()?; + + Ok(()) +} + +pub async fn remove_shared_profile_links( + profile_id: ProfilePathId, + links: Vec, +) -> crate::Result<()> { + let state = crate::State::get().await?; + + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + let creds = state.credentials.read().await; + let creds = creds.0.as_ref().ok_or_else(|| crate::ErrorKind::NoCredentialsError)?; + + let shared_profile = match profile.metadata.linked_data { + Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id, + _ => return Err(crate::ErrorKind::OtherError("Profile is not linked to a shared profile".to_string()).as_error()), + }; + + REQWEST_CLIENT + .patch( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile}"), + ) + .header("Authorization", &creds.session) + .json(&serde_json::json!({ + "remove_links": links, + })) + .send().await?.error_for_status()?; + + Ok(()) +} + +pub async fn generate_share_link( + profile_id: ProfilePathId, +) -> crate::Result { + let state = crate::State::get().await?; + + let profile : Profile = profile::get(&profile_id, None).await?.ok_or_else(|| crate::ErrorKind::UnmanagedProfileError(profile_id.to_string()))?; + let creds = state.credentials.read().await; + let creds = creds.0.as_ref().ok_or_else(|| crate::ErrorKind::NoCredentialsError)?; + + let shared_profile = match profile.metadata.linked_data { + Some(LinkedData::SharedProfile { profile_id, .. }) => profile_id, + _ => return Err(crate::ErrorKind::OtherError("Profile is not linked to a shared profile".to_string()).as_error()), + }; + + let response = REQWEST_CLIENT + .post( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/{shared_profile}/share"), + ) + .header("Authorization", &creds.session) + .send().await?.error_for_status()?; + + let link = response.json::().await?; + + Ok(generate_deep_link(&link)) +} + +fn generate_deep_link( + link: &SharedProfileLink +) -> String { + format!("modrinth://shared_profile/{}", link.id) +} + +pub async fn accept_share_link( + link: String, +) -> crate::Result<()> { + let state = crate::State::get().await?; + + let creds = state.credentials.read().await; + let creds = creds.0.as_ref().ok_or_else(|| crate::ErrorKind::NoCredentialsError)?; + + REQWEST_CLIENT + .post( + format!("{MODRINTH_API_URL_INTERNAL}client/profile/share/{link}/accept"), + ) + .header("Authorization", &creds.session) + .send().await?.error_for_status()?; + + Ok(()) +} \ No newline at end of file diff --git a/theseus/src/config.rs b/theseus/src/config.rs index 760f9eeda..5dab778dd 100644 --- a/theseus/src/config.rs +++ b/theseus/src/config.rs @@ -1,3 +1,4 @@ //! Configuration structs -pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/"; +pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/"; +pub const MODRINTH_API_URL_INTERNAL: &str = "https://staging-api.modrinth.com/_internal/"; diff --git a/theseus/src/event/mod.rs b/theseus/src/event/mod.rs index aa3c92fd2..8c384d064 100644 --- a/theseus/src/event/mod.rs +++ b/theseus/src/event/mod.rs @@ -229,6 +229,9 @@ pub enum CommandPayload { // run or install .mrpack path: PathBuf, }, + OpenSharedProfile { + link: String, + }, } #[derive(Serialize, Clone)] diff --git a/theseus/src/state/mod.rs b/theseus/src/state/mod.rs index 8ef066939..d0af812a8 100644 --- a/theseus/src/state/mod.rs +++ b/theseus/src/state/mod.rs @@ -247,13 +247,16 @@ impl State { tokio::task::spawn(async { if let Ok(state) = crate::State::get().await { if !*state.offline.read().await { + // Resolve update_creds first, as it might affect calls made by other updates + let _ = CredentialsStore::update_creds().await; + let res1 = Profiles::update_modrinth_versions(); let res2 = Tags::update(); let res3 = Metadata::update(); let res4 = Profiles::update_projects(); let res5 = Settings::update_java(); - let res6 = CredentialsStore::update_creds(); - let res7 = Settings::update_default_user(); + let res6 = Settings::update_default_user(); + let res7 = Profiles::update_shared_projects(); let _ = join!(res1, res2, res3, res4, res5, res6, res7); } diff --git a/theseus/src/state/mr_auth.rs b/theseus/src/state/mr_auth.rs index 383660fc5..0a7c92866 100644 --- a/theseus/src/state/mr_auth.rs +++ b/theseus/src/state/mr_auth.rs @@ -116,7 +116,7 @@ pub struct ModrinthAuthFlow { impl ModrinthAuthFlow { pub async fn new(provider: &str) -> crate::Result { let (socket, _) = async_tungstenite::tokio::connect_async(format!( - "wss://api.modrinth.com/v2/auth/ws?provider={provider}" + "wss://staging-api.modrinth.com/v2/auth/ws?provider={provider}" )) .await?; Ok(Self { socket }) @@ -209,7 +209,7 @@ async fn get_result_from_res( } } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] struct Session { session: String, } @@ -351,6 +351,7 @@ pub async fn refresh_credentials( } } + credentials_store.save().await?; Ok(()) } diff --git a/theseus/src/state/profiles.rs b/theseus/src/state/profiles.rs index b62f9854d..a40ac01d1 100644 --- a/theseus/src/state/profiles.rs +++ b/theseus/src/state/profiles.rs @@ -4,13 +4,14 @@ use crate::data::DirectoryInfo; use crate::event::emit::{emit_profile, emit_warning}; use crate::event::ProfilePayloadType; use crate::prelude::JavaVersion; +use crate::shared_profile::SharedModpackFileUpdate; use crate::state::projects::Project; use crate::state::{ModrinthVersion, ProjectMetadata, ProjectType}; use crate::util::fetch::{ fetch, fetch_json, write, write_cached_icon, IoSemaphore, }; use crate::util::io::{self, IOError}; -use crate::State; +use crate::{shared_profile, State}; use chrono::{DateTime, Utc}; use daedalus::get_hash; use daedalus::modded::LoaderVersion; @@ -164,7 +165,7 @@ impl ProjectPathId { pub async fn get_full_path( &self, - profile: ProfilePathId, + profile: &ProfilePathId, ) -> crate::Result { let profile_dir = profile.get_full_path().await?; Ok(profile_dir.join(&self.0)) @@ -209,7 +210,14 @@ pub struct Profile { pub hooks: Option, pub projects: HashMap, #[serde(default)] - pub modrinth_update_version: Option, + pub sync_update_version: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum ProfileUpdateData { + ModrinthModpack(String), + SharedProfile(SharedModpackFileUpdate), } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -244,12 +252,19 @@ pub struct ProfileMetadata { } #[derive(Serialize, Deserialize, Clone, Debug)] -pub struct LinkedData { - pub project_id: Option, - pub version_id: Option, +#[serde(rename_all = "snake_case")] +pub enum LinkedData { + ModrinthModpack { + project_id: Option, + version_id: Option, + #[serde(default = "default_locked")] + locked: Option, + }, - #[serde(default = "default_locked")] - pub locked: Option, + SharedProfile { + profile_id: String, + is_owner: bool, + }, } // Called if linked_data is present but locked is not @@ -344,7 +359,7 @@ impl Profile { resolution: None, fullscreen: None, hooks: None, - modrinth_update_version: None, + sync_update_version: None, }) } @@ -424,6 +439,10 @@ impl Profile { &creds, ) .await?; + + // Check existing shared profiles for updates + tokio::task::spawn(Profiles::update_shared_projects()); + drop(creds); let mut new_profiles = state.profiles.write().await; @@ -580,6 +599,52 @@ impl Profile { Ok((path, version)) } + #[tracing::instrument(skip(self))] + #[theseus_macros::debug_pin] + pub async fn add_project_bytes_directly( + &self, + relative_path: &Path, + bytes: bytes::Bytes, + ) -> crate::Result { + + let state = State::get().await?; + let file_path = self + .get_profile_full_path() + .await? + .join(relative_path); + let project_path_id = ProjectPathId::new(&relative_path); + write(&file_path, &bytes, &state.io_semaphore).await?; + + let file_name = relative_path + .file_name() + .ok_or_else(|| { + crate::ErrorKind::InputError( + format!("Could not find file name for {:?}", relative_path), + ) + })? + .to_string_lossy(); + + let hash = get_hash(bytes).await?; + { + let mut profiles = state.profiles.write().await; + + if let Some(profile) = profiles.0.get_mut(&self.profile_id()) { + profile.projects.insert( + project_path_id.clone(), + Project { + sha512: hash, + disabled: false, + metadata: ProjectMetadata::Unknown, + file_name: file_name.to_string(), + }, + ); + profile.metadata.date_modified = Utc::now(); + } + } + + Ok(project_path_id) + } + #[tracing::instrument(skip(self, bytes))] #[theseus_macros::debug_pin] pub async fn add_project_bytes( @@ -872,18 +937,20 @@ impl Profiles { pub async fn update_modrinth_versions() { let res = async { let state = State::get().await?; + + // First, we'll fetch updates for all Modrinth modpacks // Temporarily store all profiles that have modrinth linked data let mut modrinth_updatables: Vec<(ProfilePathId, String)> = Vec::new(); { let profiles = state.profiles.read().await; for (profile_path, profile) in profiles.0.iter() { - if let Some(linked_data) = &profile.metadata.linked_data { - if let Some(linked_project) = &linked_data.project_id { + if let Some(LinkedData::ModrinthModpack { project_id, .. }) = &profile.metadata.linked_data { + if let Some(linked_project) = &project_id { modrinth_updatables.push(( profile_path.clone(), linked_project.clone(), - )); + )); } } } @@ -922,10 +989,10 @@ impl Profiles { .contains(&loader.as_api_str().to_string()) }); if let Some(recent_version) = recent_version { - profile.modrinth_update_version = - Some(recent_version.id.clone()); + profile.sync_update_version = + Some(ProfileUpdateData::ModrinthModpack(recent_version.id.clone())); } else { - profile.modrinth_update_version = None; + profile.sync_update_version = None; } } drop(new_profiles); @@ -953,6 +1020,60 @@ impl Profiles { }; } + #[tracing::instrument] + #[theseus_macros::debug_pin] + pub async fn update_shared_projects() { + let res = async { + let state = State::get().await?; + // Next, get updates for all shared profiles + // Get SharedProfiles for all available + let shared_profiles = shared_profile::get_all().await?; + let mut update_profiles = HashMap::new(); + { + let profiles = state.profiles.read().await; + for (profile_path, profile) in profiles.0.iter() { + if let Some(LinkedData::SharedProfile { profile_id, .. }) = &profile.metadata.linked_data { + if let Some(shared_profile) = shared_profiles.iter().find(|x| x.id == *profile_id) { + // Check for update + let update = shared_profile::check_updated(profile_path, shared_profile).await?; + update_profiles.insert(profile_path.clone(), update); + } + } + } + } + { + let mut new_profiles = state.profiles.write().await; + for (profile_path, update) in update_profiles.iter() { + if let Some(profile) = new_profiles.0.get_mut(&profile_path) { + profile.sync_update_version = Some(ProfileUpdateData::SharedProfile(update.clone())); + } + } + } + { + let profiles = state.profiles.read().await; + profiles.sync().await?; + + for (profile_path, _) in update_profiles.iter() { + let Some(profile) = profiles.0.get(&profile_path) else { continue; }; + emit_profile( + profile.uuid, + &profile_path, + &profile.metadata.name, + ProfilePayloadType::Edited, + ) + .await?; + } + } + Ok::<(), crate::Error>(()) + }.await; + match res { + Ok(()) => {} + Err(err) => { + tracing::warn!("Unable to update modrinth versions: {err}") + } + }; + } + #[tracing::instrument(skip(self, profile))] #[theseus_macros::debug_pin] pub async fn insert(