use super::events::InstallProgressReporter; use super::model::{ InstallJobState, InstallPhaseDetails, InstallPhaseId, SharedInstanceExternalFileData, SharedInstanceInstallData, SharedInstanceInstallModpack, }; use super::runner::{ install_pack, modpack_details, update_content_progress, update_progress, }; use crate::api::instance::{ CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, read_bounded_config_bundle_entry, }; use crate::api::pack::install_from::CreatePackLocation; use crate::state::instances::adapters::sqlite::content_rows; use crate::state::{ CachedEntry, ContentSetSyncStatus, ContentSourceKind, InstanceLink, ProjectType, SharedInstanceAttachmentInput, SharedInstanceRole, State, }; use crate::util::fetch::{DownloadReason, REQWEST_CLIENT}; use futures::StreamExt; use path_util::SafeRelativeUtf8UnixPathBuf; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use uuid::Uuid; const MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE: u64 = 500 * 1024 * 1024; const MAX_SHARED_INSTANCE_INITIAL_BUFFER_SIZE: u64 = 8 * 1024 * 1024; pub(super) async fn attach_pending_shared_instance( instance_id: &str, data: &SharedInstanceInstallData, state: &State, ) -> crate::Result<()> { crate::state::attach_shared_instance( instance_id, SharedInstanceAttachmentInput { id: data.shared_instance_id.clone(), role: SharedInstanceRole::Member, manager_id: data.manager_id.clone(), server_manager_name: data.server_manager_name.clone(), server_manager_icon_url: data.server_manager_icon_url.clone(), linked_user_id: data.linked_user_id.clone(), status: ContentSetSyncStatus::NotReady, applied_version: None, latest_version: Some(data.version), }, &state.pool, ) .await } pub(super) async fn finalize_shared_instance_attachment( instance_id: &str, data: &SharedInstanceInstallData, state: &State, ) -> crate::Result<()> { crate::state::attach_shared_instance( instance_id, SharedInstanceAttachmentInput { id: data.shared_instance_id.clone(), role: SharedInstanceRole::Member, manager_id: data.manager_id.clone(), server_manager_name: data.server_manager_name.clone(), server_manager_icon_url: data.server_manager_icon_url.clone(), linked_user_id: data.linked_user_id.clone(), status: ContentSetSyncStatus::UpToDate, applied_version: Some(data.version), latest_version: Some(data.version), }, &state.pool, ) .await } #[derive(Clone, Debug)] struct CurrentSharedInstanceProject { project_id: String, version_id: String, relative_path: String, } #[derive(Clone, Debug)] struct CurrentSharedInstanceExternalFile { relative_path: String, } type SharedInstanceExternalFileKey = (ProjectType, String); #[derive(Default)] struct CurrentSharedInstanceContent { projects: HashMap, external_files: HashMap< SharedInstanceExternalFileKey, CurrentSharedInstanceExternalFile, >, } #[derive(Clone, Debug)] struct DesiredSharedInstanceProject { project_id: String, version_id: String, } #[derive(Clone, Debug)] struct DesiredSharedInstanceExternalFile { file: SharedInstanceExternalFileData, file_type: ProjectType, } #[derive(Default)] struct DesiredSharedInstanceContent { projects: HashMap, external_files: HashMap< SharedInstanceExternalFileKey, DesiredSharedInstanceExternalFile, >, config_bundle: Option, } struct SharedInstanceProjectUpdate { current: CurrentSharedInstanceProject, desired: DesiredSharedInstanceProject, } #[derive(Default)] struct SharedInstanceApplyPlan { configuration_changed: bool, project_removals: Vec, project_updates: Vec, project_additions: Vec, external_removals: Vec, external_updates: Vec, external_additions: Vec, config_bundle: Option, } impl SharedInstanceApplyPlan { async fn build( metadata: &crate::state::InstanceMetadata, data: &SharedInstanceInstallData, state: &State, ) -> crate::Result { if shared_instance_update_requires_full_apply(metadata, data) { return Ok(Self { configuration_changed: true, ..Default::default() }); } let current = current_shared_instance_content(metadata, state).await?; let desired = desired_shared_instance_content(data, state).await?; let project_removals = current .projects .values() .filter(|current| { !desired.projects.contains_key(¤t.project_id) }) .cloned() .collect(); let external_removals = current .external_files .iter() .filter(|(key, _)| !desired.external_files.contains_key(*key)) .map(|(_, current)| current.clone()) .collect(); let mut plan = Self { project_removals, external_removals, config_bundle: desired.config_bundle, ..Default::default() }; for desired in desired.projects.into_values() { match current.projects.get(&desired.project_id) { Some(current) if current.version_id != desired.version_id => { plan.project_updates.push(SharedInstanceProjectUpdate { current: current.clone(), desired, }); } None => plan.project_additions.push(desired), Some(_) => {} } } for desired in desired.external_files.into_values() { let key = (desired.file_type, desired.file.file_name.clone()); match current.external_files.get(&key) { Some(_) => { plan.external_updates.push(desired); } _ => plan.external_additions.push(desired), } } Ok(plan) } fn content_change_count(&self) -> u64 { (self.project_updates.len() + self.project_additions.len() + self.external_updates.len() + self.external_additions.len() + usize::from(self.config_bundle.is_some())) as u64 } } pub(super) async fn apply_shared_instance_update( job_id: Uuid, job_state: &mut InstallJobState, state: &State, instance_id: &str, data: &SharedInstanceInstallData, ) -> crate::Result<()> { let metadata = crate::state::instances::commands::get_instance_metadata( instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; let plan = SharedInstanceApplyPlan::build(&metadata, data, state).await?; if plan.configuration_changed { remove_existing_shared_instance_content(instance_id, state).await?; Box::pin(apply_shared_instance_content( job_id, job_state, state, instance_id, data, )) .await?; return Ok(()); } update_progress( job_id, job_state, state, InstallPhaseId::PreparingInstance, InstallPhaseDetails::Instance { name: data.name.clone(), }, ) .await?; let content_change_count = plan.content_change_count(); if content_change_count > 0 { update_content_progress( job_id, job_state, state, 0, content_change_count, ) .await?; } for project in plan.project_removals { crate::state::instances::commands::remove_project( instance_id, &project.relative_path, state, ) .await?; } for file in plan.external_removals { crate::state::instances::commands::remove_project( instance_id, &file.relative_path, state, ) .await?; } let mut completed_content_changes = 0; for update in plan.project_updates { let new_path = crate::state::instances::commands::add_project_from_version( instance_id, &update.desired.version_id, DownloadReason::Update, Some(update.current.version_id), ContentSourceKind::SharedInstance, state, ) .await?; if update.current.relative_path != new_path { crate::state::instances::commands::remove_project( instance_id, &update.current.relative_path, state, ) .await?; } completed_content_changes += 1; update_content_progress( job_id, job_state, state, completed_content_changes, content_change_count, ) .await?; } for project in plan.project_additions { crate::state::instances::commands::add_project_from_version( instance_id, &project.version_id, DownloadReason::Standalone, None, ContentSourceKind::SharedInstance, state, ) .await?; completed_content_changes += 1; update_content_progress( job_id, job_state, state, completed_content_changes, content_change_count, ) .await?; } for file in plan .external_updates .into_iter() .chain(plan.external_additions) { install_shared_instance_external_file(instance_id, &file.file, state) .await?; completed_content_changes += 1; update_content_progress( job_id, job_state, state, completed_content_changes, content_change_count, ) .await?; } if let Some(config_bundle) = plan.config_bundle { install_shared_instance_external_file( instance_id, &config_bundle, state, ) .await?; completed_content_changes += 1; update_content_progress( job_id, job_state, state, completed_content_changes, content_change_count, ) .await?; } crate::api::instance::edit( instance_id, crate::state::EditInstance { name: Some(data.name.clone()), link: Some(shared_instance_link(data.modpack.as_ref())), ..Default::default() }, ) .await?; Ok(()) } fn shared_instance_update_requires_full_apply( metadata: &crate::state::InstanceMetadata, data: &SharedInstanceInstallData, ) -> bool { let (current_modpack_project_id, current_modpack_version_id) = match &metadata.link { InstanceLink::SharedInstance { modpack_project_id, modpack_version_id, } => (modpack_project_id.as_deref(), modpack_version_id.as_deref()), _ => return true, }; let next_modpack_project_id = data .modpack .as_ref() .map(|modpack| modpack.project_id.as_str()); let next_modpack_version_id = data .modpack .as_ref() .map(|modpack| modpack.version_id.as_str()); current_modpack_project_id != next_modpack_project_id || current_modpack_version_id != next_modpack_version_id || metadata.applied_content_set.game_version != data.game_version || metadata.applied_content_set.loader != data.loader || metadata.applied_content_set.loader_version != data.loader_version } async fn current_shared_instance_content( metadata: &crate::state::InstanceMetadata, state: &State, ) -> crate::Result { let entries = content_rows::get_content_entries( &metadata.applied_content_set.id, &state.pool, ) .await?; let files = content_rows::get_instance_files(&metadata.instance.id, &state.pool) .await? .into_iter() .map(|file| (file.id.clone(), file)) .collect::>(); let version_ids_without_project = entries .iter() .filter(|entry| entry.source_kind == ContentSourceKind::SharedInstance) .filter(|entry| entry.project_id.is_none()) .filter_map(|entry| entry.version_id.clone()) .collect::>(); let versions_by_id = shared_instance_versions_by_id(&version_ids_without_project, state) .await?; let mut content = CurrentSharedInstanceContent::default(); for entry in entries { if entry.source_kind != ContentSourceKind::SharedInstance { continue; } let Some(file_id) = entry.file_id.as_ref() else { continue; }; let Some(file) = files.get(file_id) else { continue; }; if let Some(version_id) = entry.version_id.clone() { let Some(project_id) = entry.project_id.clone().or_else(|| { versions_by_id .get(&version_id) .map(|version| version.project_id.clone()) }) else { continue; }; content.projects.insert( project_id.clone(), CurrentSharedInstanceProject { project_id, version_id, relative_path: file.relative_path.clone(), }, ); } else { content.external_files.insert( (entry.project_type, file.file_name.clone()), CurrentSharedInstanceExternalFile { relative_path: file.relative_path.clone(), }, ); } } Ok(content) } async fn desired_shared_instance_content( data: &SharedInstanceInstallData, state: &State, ) -> crate::Result { let versions_by_id = shared_instance_versions_by_id(&data.modrinth_ids, state).await?; let mut content = DesiredSharedInstanceContent::default(); for version_id in &data.modrinth_ids { let version = versions_by_id.get(version_id).ok_or_else(|| { crate::ErrorKind::InputError(format!( "Shared instance version {version_id} was not found" )) })?; content.projects.insert( version.project_id.clone(), DesiredSharedInstanceProject { project_id: version.project_id.clone(), version_id: version.id.clone(), }, ); } for file in &data.external_files { if file.file_type == CONFIG_BUNDLE_FILE_TYPE { if CONFIG_SYNC_ENABLED { content.config_bundle = Some(file.clone()); } continue; } let file_type = ProjectType::from_name(&file.file_type).ok_or_else(|| { crate::ErrorKind::InputError(format!( "Unknown shared instance file type {}", file.file_type )) })?; content.external_files.insert( (file_type, file.file_name.clone()), DesiredSharedInstanceExternalFile { file: file.clone(), file_type, }, ); } Ok(content) } async fn shared_instance_versions_by_id( version_ids: &[String], state: &State, ) -> crate::Result> { if version_ids.is_empty() { return Ok(HashMap::new()); } let mut ids = version_ids.iter().map(String::as_str).collect::>(); ids.sort_unstable(); ids.dedup(); let versions = CachedEntry::get_version_many( &ids, None, &state.pool, &state.api_semaphore, ) .await?; let versions_by_id = versions .into_iter() .map(|version| (version.id.clone(), version)) .collect::>(); for id in ids { if !versions_by_id.contains_key(id) { return Err(crate::ErrorKind::InputError(format!( "Shared instance version {id} was not found" )) .into()); } } Ok(versions_by_id) } pub(super) async fn apply_shared_instance_content( job_id: Uuid, job_state: &mut InstallJobState, state: &State, instance_id: &str, data: &SharedInstanceInstallData, ) -> crate::Result<()> { update_progress( job_id, job_state, state, InstallPhaseId::PreparingInstance, InstallPhaseDetails::Instance { name: data.name.clone(), }, ) .await?; if let Some(modpack) = data.modpack.clone() { let location = shared_instance_pack_location(modpack); update_progress( job_id, job_state, state, InstallPhaseId::ResolvingPack, modpack_details(&location), ) .await?; Box::pin(install_pack( job_id, job_state, location, instance_id.to_string(), DownloadReason::Modpack, )) .await?; } else { crate::api::instance::edit( instance_id, crate::state::EditInstance { content_set_patch: Some(crate::state::AppliedContentSetPatch { source_kind: Some(ContentSourceKind::SharedInstance), game_version: Some(data.game_version.clone()), protocol_version: Some(None), loader: Some(data.loader), loader_version: Some(data.loader_version.clone()), }), ..Default::default() }, ) .await?; update_progress( job_id, job_state, state, InstallPhaseId::DownloadingMinecraft, InstallPhaseDetails::Minecraft { game_version: data.game_version.clone(), loader: data.loader, }, ) .await?; let context = crate::state::instances::commands::get_instance_launch_context( instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; crate::launcher::install_minecraft_with_reporter( &context, false, Some(InstallProgressReporter::new(job_id, job_state.clone())), ) .await?; } if !data.modrinth_ids.is_empty() || !data.external_files.is_empty() { let content_change_count = data.modrinth_ids.len() as u64 + data.external_files.len() as u64; update_content_progress( job_id, job_state, state, 0, content_change_count, ) .await?; let mut completed_content_changes = 0; for version_id in &data.modrinth_ids { crate::state::instances::commands::add_project_from_version( instance_id, version_id, DownloadReason::Standalone, None, ContentSourceKind::SharedInstance, state, ) .await?; completed_content_changes += 1; update_content_progress( job_id, job_state, state, completed_content_changes, content_change_count, ) .await?; } for file in &data.external_files { install_shared_instance_external_file(instance_id, file, state) .await?; completed_content_changes += 1; update_content_progress( job_id, job_state, state, completed_content_changes, content_change_count, ) .await?; } } crate::api::instance::edit( instance_id, crate::state::EditInstance { name: Some(data.name.clone()), link: Some(shared_instance_link(data.modpack.as_ref())), ..Default::default() }, ) .await?; Ok(()) } pub(super) async fn remove_existing_shared_instance_content( instance_id: &str, state: &State, ) -> crate::Result<()> { let _content_lock = state.lock_instance_content(instance_id).await; let metadata = crate::state::instances::commands::get_instance_metadata( instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; let entries = content_rows::get_content_entries( &metadata.applied_content_set.id, &state.pool, ) .await?; let files = content_rows::get_instance_files(instance_id, &state.pool) .await? .into_iter() .map(|file| (file.id.clone(), file)) .collect::>(); let base = state .directories .instances_dir() .join(&metadata.instance.path); let mut removed_file_ids = HashSet::new(); for entry in entries { if !entry.source_kind.is_shared_instance_managed() { continue; } let Some(file_id) = entry.file_id else { continue; }; if !removed_file_ids.insert(file_id.clone()) { continue; } let Some(file) = files.get(&file_id) else { continue; }; crate::util::io::remove_file(base.join(&file.relative_path)).await?; let mut tx = state.pool.begin().await?; content_rows::remove_content_entries_for_file( &metadata.applied_content_set.id, &file.id, &mut tx, ) .await?; content_rows::remove_instance_file_by_relative_path( instance_id, &file.relative_path, &mut tx, ) .await?; tx.commit().await?; } Ok(()) } pub(super) fn shared_instance_pack_location( modpack: SharedInstanceInstallModpack, ) -> CreatePackLocation { CreatePackLocation::FromVersionId { project_id: modpack.project_id, version_id: modpack.version_id, title: modpack.title, icon_url: modpack.icon_url, } } pub(super) fn shared_instance_link( modpack: Option<&SharedInstanceInstallModpack>, ) -> InstanceLink { InstanceLink::SharedInstance { modpack_project_id: modpack.map(|modpack| modpack.project_id.clone()), modpack_version_id: modpack.map(|modpack| modpack.version_id.clone()), } } async fn install_shared_instance_external_file( instance_id: &str, file: &SharedInstanceExternalFileData, state: &State, ) -> crate::Result<()> { if file.file_type == CONFIG_BUNDLE_FILE_TYPE && !CONFIG_SYNC_ENABLED { return Ok(()); } validate_shared_instance_external_file_name(&file.file_name)?; if file.file_size > MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE { return Err(crate::ErrorKind::InputError(format!( "Shared instance external file {} exceeds the maximum size", file.file_name )) .into()); } let response = REQWEST_CLIENT.get(&file.url).send().await?; if !response.status().is_success() { return Err(crate::ErrorKind::OtherError(format!( "Shared instance external file download failed with status {}", response.status() )) .into()); } if response .content_length() .is_some_and(|content_length| content_length != file.file_size) { return Err(crate::ErrorKind::OtherError(format!( "Shared instance external file {} has an unexpected size", file.file_name )) .into()); } let mut stream = response.bytes_stream(); let initial_capacity = usize::try_from( file.file_size.min(MAX_SHARED_INSTANCE_INITIAL_BUFFER_SIZE), ) .map_err(|_| { crate::ErrorKind::InputError(format!( "Shared instance external file {} is too large", file.file_name )) })?; let mut bytes = Vec::with_capacity(initial_capacity); while let Some(chunk) = stream.next().await { let chunk = chunk?; let downloaded_size = (bytes.len() as u64) .checked_add(chunk.len() as u64) .ok_or_else(|| { crate::ErrorKind::InputError(format!( "Shared instance external file {} is too large", file.file_name )) })?; if downloaded_size > file.file_size { return Err(crate::ErrorKind::OtherError(format!( "Shared instance external file {} has an unexpected size", file.file_name )) .into()); } bytes.extend_from_slice(&chunk); } if bytes.len() as u64 != file.file_size { return Err(crate::ErrorKind::OtherError(format!( "Shared instance external file {} has an unexpected size", file.file_name )) .into()); } let bytes = bytes::Bytes::from(bytes); if file.file_type == CONFIG_BUNDLE_FILE_TYPE { return install_shared_instance_config_bundle( instance_id, bytes, state, ) .await; } let project_type = ProjectType::from_name(&file.file_type).ok_or_else(|| { crate::ErrorKind::InputError(format!( "Unknown shared instance file type {}", file.file_type )) })?; crate::state::instances::commands::add_project_bytes( instance_id, &file.file_name, bytes, None, Some(project_type), ContentSourceKind::SharedInstance, None, None, state, ) .await?; Ok(()) } fn validate_shared_instance_external_file_name( file_name: &str, ) -> crate::Result<()> { let path = SafeRelativeUtf8UnixPathBuf::try_from(file_name.to_string()) .map_err(|_| { crate::ErrorKind::InputError(format!( "Shared instance external file {file_name} has an invalid file name" )) })?; if file_name == "." || file_name.contains('/') || file_name.contains('\\') || path.components().count() != 1 { return Err(crate::ErrorKind::InputError(format!( "Shared instance external file {file_name} has an invalid file name" )) .into()); } Ok(()) } async fn install_shared_instance_config_bundle( instance_id: &str, bytes: bytes::Bytes, state: &State, ) -> crate::Result<()> { let files = tokio::task::spawn_blocking(move || read_config_bundle(bytes.as_ref())) .await??; let metadata = crate::state::instances::commands::get_instance_metadata( instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; let config_path = state .directories .instances_dir() .join(metadata.instance.path) .join(CONFIG_DIRECTORY); crate::util::io::create_dir_all(&config_path).await?; for (relative_path, bytes) in files { let path = config_path.join(relative_path); if let Some(parent) = path.parent() { crate::util::io::create_dir_all(parent).await?; } crate::util::io::write(path, bytes).await?; } Ok(()) } fn read_config_bundle(bytes: &[u8]) -> crate::Result)>> { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) .map_err(|error| { crate::ErrorKind::InputError(format!( "Invalid shared instance config bundle: {error}" )) })?; if archive.len() > MAX_CONFIG_BUNDLE_ENTRIES { return Err(crate::ErrorKind::InputError( "Shared instance config bundle contains too many entries" .to_string(), ) .into()); } let mut files = Vec::new(); let mut total_size = 0; for index in 0..archive.len() { let file = archive.by_index(index).map_err(|error| { crate::ErrorKind::InputError(format!( "Invalid shared instance config bundle entry: {error}" )) })?; if file.is_dir() { continue; } let path = file.enclosed_name().ok_or_else(|| { crate::ErrorKind::InputError( "Shared instance config bundle contains an unsafe path" .to_string(), ) })?; if !is_supported_config_file(&path) { return Err(crate::ErrorKind::InputError(format!( "Shared instance config bundle contains unsupported file {}", path.display() )) .into()); } let declared_size = file.size(); let bytes = read_bounded_config_bundle_entry( file, declared_size, &mut total_size, )?; files.push((path.to_path_buf(), bytes)); } Ok(files) } fn is_supported_config_file(path: &std::path::Path) -> bool { path.extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| { CONFIG_FILE_EXTENSIONS .iter() .any(|candidate| extension.eq_ignore_ascii_case(candidate)) }) }