From 20b5fd226f29752a0cf9e7ca7336822281a48f37 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Thu, 15 Oct 2026 16:43:09 +0100 Subject: [PATCH 01/12] feat: upload config files seperately, not as zips --- packages/app-lib/src/api/instance.rs | 5 +- .../app-lib/src/api/instance/shared/client.rs | 28 +- .../src/api/instance/shared/content.rs | 196 ++++++++++++ .../app-lib/src/api/instance/shared/diff.rs | 29 +- .../src/api/instance/shared/install.rs | 35 +- .../app-lib/src/api/instance/shared/mod.rs | 24 +- .../src/api/instance/shared/publish.rs | 301 +++++------------- .../app-lib/src/install/shared_instance.rs | 67 ++-- 8 files changed, 424 insertions(+), 261 deletions(-) create mode 100644 packages/app-lib/src/api/instance/shared/content.rs diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index 9f7b21562b6..99ee90efdea 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -75,8 +75,9 @@ pub use self::screenshots::{ }; pub(crate) use self::shared::{ CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, - CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, - read_bounded_config_bundle_entry, + CONFIG_FILE_TYPE, CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, + MAX_CONFIG_BUNDLE_FILE_SIZE, is_shareable_config_path, + is_supported_config_file, read_bounded_config_bundle_entry, }; pub use self::shared::{ SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, diff --git a/packages/app-lib/src/api/instance/shared/client.rs b/packages/app-lib/src/api/instance/shared/client.rs index 710e13d35f1..778dd6a2c57 100644 --- a/packages/app-lib/src/api/instance/shared/client.rs +++ b/packages/app-lib/src/api/instance/shared/client.rs @@ -50,7 +50,7 @@ pub(super) struct ExternalFileCandidate { #[derive(Clone, Debug)] pub(super) enum ExternalFileSource { InstanceFile(String), - ConfigBundle(std::sync::Arc), + ConfigFile(std::path::PathBuf), } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -394,6 +394,32 @@ pub(super) async fn get_latest_remote_version_optional_unavailable_with_auth( .await } +/// Returns `None` for versions that never became ready. +pub(super) async fn get_remote_version( + shared_instance_id: &str, + version: i32, + state: &State, +) -> crate::Result> { + match request_json_optional_unavailable( + "get_instance_version", + Method::GET, + &format!("/instances/{shared_instance_id}/versions/{version}"), + None, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Ok(Some(version)), + SharedInstanceRemoteResponse::Unavailable( + SharedInstanceUnavailableReason::Deleted, + ) => Ok(None), + SharedInstanceRemoteResponse::Unavailable(reason) => { + Err(shared_instance_unavailable_error(reason)) + } + } +} + pub(super) async fn add_remote_users( shared_instance_id: &str, user_ids: Vec, diff --git a/packages/app-lib/src/api/instance/shared/content.rs b/packages/app-lib/src/api/instance/shared/content.rs new file mode 100644 index 00000000000..136ac4bdc9c --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/content.rs @@ -0,0 +1,196 @@ +use super::client::{ + ExternalFileResponse, InstanceVersionResponse, get_remote_version, +}; +use super::*; +use std::collections::BTreeMap; + +const CONFIG_VERSION_FETCH_CONCURRENCY: usize = 4; + +/// Config files are published as deltas, so a member needs every config +/// uploaded after `applied_version`, keeping the newest upload of each path. +pub(super) async fn remote_shared_config_files( + shared_instance_id: &str, + applied_version: Option, + latest: &InstanceVersionResponse, + state: &State, +) -> crate::Result> { + use futures::{StreamExt, TryStreamExt}; + + let first = applied_version.map_or(1, |version| version.saturating_add(1)); + let intermediate = futures::stream::iter(first..latest.version) + .map(|version| get_remote_version(shared_instance_id, version, state)) + .buffered(CONFIG_VERSION_FETCH_CONCURRENCY) + .try_collect::>() + .await?; + + newest_config_files( + intermediate + .iter() + .flatten() + .chain((first <= latest.version).then_some(latest)) + .map(|version| version.external_files.as_slice()), + ) +} + +/// Takes each version's external files in ascending version order. A legacy +/// config bundle is a full snapshot, so it replaces every older config. The +/// bundle comes first so newer individual files are written over it. +/// +/// Limits apply per version, matching what a single publish may upload. +fn newest_config_files<'a>( + versions: impl IntoIterator, +) -> crate::Result> { + let mut bundle = None; + let mut files = BTreeMap::new(); + for external_files in versions { + ensure_config_limits(external_files)?; + if let Some(newer) = external_files + .iter() + .find(|file| file.file_type == CONFIG_BUNDLE_FILE_TYPE) + { + bundle = Some(newer.clone()); + files.clear(); + } + for file in external_files + .iter() + .filter(|file| file.file_type == CONFIG_FILE_TYPE) + { + files.insert(file.file_name.clone(), file.clone()); + } + } + + Ok(bundle.into_iter().chain(files.into_values()).collect()) +} + +fn ensure_config_limits( + external_files: &[ExternalFileResponse], +) -> crate::Result<()> { + let mut count = 0; + let mut size = 0_u64; + for file in external_files + .iter() + .filter(|file| file.file_type == CONFIG_FILE_TYPE) + { + count += 1; + size = size.saturating_add( + file.file_size + .and_then(|size| u64::try_from(size).ok()) + .unwrap_or(u64::MAX), + ); + } + if count > MAX_CONFIG_BUNDLE_ENTRIES || size > MAX_CONFIG_BUNDLE_TOTAL_SIZE + { + return Err(crate::ErrorKind::InputError( + "Shared instance config files exceed the size or file count limit" + .to_string(), + ) + .into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file( + file_type: &str, + file_name: &str, + url: &str, + ) -> ExternalFileResponse { + ExternalFileResponse { + file_name: file_name.to_string(), + file_type: file_type.to_string(), + url: url.to_string(), + file_size: Some(1), + } + } + + fn names(files: &[ExternalFileResponse]) -> Vec<(&str, &str, &str)> { + files + .iter() + .map(|file| { + ( + file.file_type.as_str(), + file.file_name.as_str(), + file.url.as_str(), + ) + }) + .collect() + } + + #[test] + fn newest_upload_of_each_path_wins() { + let v1 = [ + file(CONFIG_FILE_TYPE, "config/a.toml", "v1"), + file(CONFIG_FILE_TYPE, "config/b.toml", "v1"), + ]; + let v2 = [file("mod", "extra.jar", "v2")]; + let v3 = [file(CONFIG_FILE_TYPE, "config/a.toml", "v3")]; + + let files = newest_config_files([&v1[..], &v2[..], &v3[..]]).unwrap(); + + assert_eq!( + names(&files), + [ + (CONFIG_FILE_TYPE, "config/a.toml", "v3"), + (CONFIG_FILE_TYPE, "config/b.toml", "v1"), + ] + ); + } + + #[test] + fn bundle_replaces_older_configs_and_is_written_first() { + let v1 = [file(CONFIG_FILE_TYPE, "config/old.toml", "v1")]; + let v2 = [ + file(CONFIG_FILE_TYPE, "config/same.toml", "v2"), + file(CONFIG_BUNDLE_FILE_TYPE, "configs.zip", "v2"), + ]; + let v3 = [file(CONFIG_FILE_TYPE, "config/new.toml", "v3")]; + + let files = newest_config_files([&v1[..], &v2[..], &v3[..]]).unwrap(); + + assert_eq!( + names(&files), + [ + (CONFIG_BUNDLE_FILE_TYPE, "configs.zip", "v2"), + (CONFIG_FILE_TYPE, "config/new.toml", "v3"), + (CONFIG_FILE_TYPE, "config/same.toml", "v2"), + ] + ); + } + + #[test] + fn limits_apply_per_version() { + let mut half = file(CONFIG_FILE_TYPE, "config/a.toml", "v1"); + half.file_size = Some((MAX_CONFIG_BUNDLE_TOTAL_SIZE / 2 + 1) as i64); + let mut other_half = half.clone(); + other_half.file_name = "config/b.toml".to_string(); + let mut too_large = half.clone(); + too_large.file_name = "config/c.toml".to_string(); + + assert!( + newest_config_files([ + &[half.clone()][..], + &[other_half.clone()][..] + ]) + .is_ok() + ); + assert!( + newest_config_files([&[half, other_half, too_large][..]]).is_err() + ); + } + + #[test] + fn newest_bundle_wins() { + let v1 = [file(CONFIG_BUNDLE_FILE_TYPE, "configs.zip", "v1")]; + let v2 = [file(CONFIG_BUNDLE_FILE_TYPE, "configs.zip", "v2")]; + + let files = newest_config_files([&v1[..], &v2[..]]).unwrap(); + + assert_eq!( + names(&files), + [(CONFIG_BUNDLE_FILE_TYPE, "configs.zip", "v2")] + ); + } +} diff --git a/packages/app-lib/src/api/instance/shared/diff.rs b/packages/app-lib/src/api/instance/shared/diff.rs index 84483bce32a..d01f0c07084 100644 --- a/packages/app-lib/src/api/instance/shared/diff.rs +++ b/packages/app-lib/src/api/instance/shared/diff.rs @@ -13,6 +13,7 @@ struct SharedContentSnapshot { pub(super) async fn shared_instance_update_diffs( metadata: &crate::state::InstanceMetadata, version: &InstanceVersionResponse, + config_files: &[ExternalFileResponse], state: &State, ) -> crate::Result> { let before_configuration = local_configuration(metadata); @@ -33,7 +34,7 @@ pub(super) async fn shared_instance_update_diffs( configuration: after_configuration, }; - shared_content_diffs( + let mut diffs = shared_content_diffs( &before, &after, &HashSet::new(), @@ -42,7 +43,24 @@ pub(super) async fn shared_instance_update_diffs( CommonExternalFilePolicy::AssumeUpdated, state, ) - .await + .await?; + let config_file_count = config_files + .iter() + .filter(|file| file.file_type == CONFIG_FILE_TYPE) + .count(); + if config_file_count > 0 { + diffs.push(SharedInstanceUpdateDiff { + type_: SharedInstanceUpdateDiffType::ConfigFilesUpdated, + project_id: None, + project_name: None, + file_name: None, + current_version_name: None, + new_version_name: None, + config_file_count: Some(config_file_count), + disabled: false, + }); + } + Ok(diffs) } pub(super) async fn shared_instance_publish_diffs( @@ -484,7 +502,12 @@ fn remote_shared_content( let external_files = version .external_files .iter() - .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .filter(|file| { + !matches!( + file.file_type.as_str(), + CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE + ) + }) .map(|file| shared_external_file_key(&file.file_type, &file.file_name)) .collect::>()?; Ok((version_ids, external_files)) diff --git a/packages/app-lib/src/api/instance/shared/install.rs b/packages/app-lib/src/api/instance/shared/install.rs index 0e6b9123f02..d1d34fcd193 100644 --- a/packages/app-lib/src/api/instance/shared/install.rs +++ b/packages/app-lib/src/api/instance/shared/install.rs @@ -1,4 +1,5 @@ use super::client::*; +use super::content::remote_shared_config_files; use super::diff::*; use super::publish::*; use super::types::*; @@ -23,6 +24,7 @@ pub async fn install_shared_instance( instance_icon_url, name, version, + None, &state, ) .await?; @@ -142,7 +144,12 @@ pub(super) async fn shared_instance_install_preview_from_version( let external_files = version .external_files .iter() - .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .filter(|file| { + !matches!( + file.file_type.as_str(), + CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE + ) + }) .map(|file| SharedInstanceExternalFilePreview { file_name: file.file_name.clone(), file_type: file.file_type.clone(), @@ -212,7 +219,15 @@ pub async fn get_shared_instance_update_preview( .applied_version .is_none_or(|current| current < version.version); let diffs = if update_available { - shared_instance_update_diffs(&metadata, &version, &state).await? + let config_files = remote_shared_config_files( + &attachment.id, + attachment.applied_version, + &version, + &state, + ) + .await?; + shared_instance_update_diffs(&metadata, &version, &config_files, &state) + .await? } else { Vec::new() }; @@ -296,6 +311,7 @@ pub async fn update_shared_instance( None, metadata.instance.name, version, + attachment.applied_version, &state, ) .await?; @@ -504,6 +520,7 @@ pub(super) async fn shared_instance_install_data( instance_icon_url: Option, name: String, version: InstanceVersionResponse, + applied_version: Option, state: &State, ) -> crate::Result { if !version.ready { @@ -511,6 +528,13 @@ pub(super) async fn shared_instance_install_data( "shared instance version is not ready to install".to_string(), )); } + let config_files = remote_shared_config_files( + shared_instance_id, + applied_version, + &version, + state, + ) + .await?; let name = shared_instance_name(name); let linked_user_id = linked_modrinth_user_id(state).await?; @@ -536,6 +560,13 @@ pub(super) async fn shared_instance_install_data( external_files: version .external_files .into_iter() + .filter(|file| { + !matches!( + file.file_type.as_str(), + CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE + ) + }) + .chain(config_files) .map(shared_instance_external_file_data) .collect::>>()?, modpack, diff --git a/packages/app-lib/src/api/instance/shared/mod.rs b/packages/app-lib/src/api/instance/shared/mod.rs index 60334f73f24..4ca5e2bd2ee 100644 --- a/packages/app-lib/src/api/instance/shared/mod.rs +++ b/packages/app-lib/src/api/instance/shared/mod.rs @@ -31,13 +31,13 @@ use std::collections::BTreeSet; use std::collections::{HashMap, HashSet}; use std::io::Read; -pub(crate) const CONFIG_BUNDLE_FILE_NAME: &str = "configs.zip"; pub(crate) const CONFIG_BUNDLE_FILE_TYPE: &str = "configs"; +pub(crate) const CONFIG_FILE_TYPE: &str = "config"; pub(crate) const CONFIG_SYNC_ENABLED: bool = true; pub(crate) const CONFIG_DIRECTORY: &str = "config"; pub(crate) const MAX_CONFIG_BUNDLE_ENTRIES: usize = 4096; -pub(crate) const MAX_CONFIG_BUNDLE_FILE_SIZE: u64 = 16 * 1024 * 1024; -pub(crate) const MAX_CONFIG_BUNDLE_TOTAL_SIZE: u64 = 128 * 1024 * 1024; +pub(crate) const MAX_CONFIG_BUNDLE_FILE_SIZE: u64 = 512 * 1024 * 1024; +pub(crate) const MAX_CONFIG_BUNDLE_TOTAL_SIZE: u64 = 512 * 1024 * 1024; pub(crate) const CONFIG_FILE_EXTENSIONS: [&str; 14] = [ "json", "json5", @@ -55,6 +55,23 @@ pub(crate) const CONFIG_FILE_EXTENSIONS: [&str; 14] = [ "nbt", ]; +pub(crate) 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)) + }) +} + +/// Whether members can install a shared config at this instance-relative path. +pub(crate) fn is_shareable_config_path(path: &str) -> bool { + crate::state::content_store::validate_relative(path).is_ok() + && !path.split('/').any(|part| part.starts_with('.')) + && is_supported_config_file(std::path::Path::new(path)) +} + pub(crate) fn read_bounded_config_bundle_entry( reader: impl Read, declared_size: u64, @@ -96,6 +113,7 @@ pub(crate) fn read_bounded_config_bundle_entry( } mod client; +mod content; mod diff; mod install; mod invites; diff --git a/packages/app-lib/src/api/instance/shared/publish.rs b/packages/app-lib/src/api/instance/shared/publish.rs index c06690b16fe..70e68a62418 100644 --- a/packages/app-lib/src/api/instance/shared/publish.rs +++ b/packages/app-lib/src/api/instance/shared/publish.rs @@ -6,9 +6,6 @@ use super::*; use async_walkdir::WalkDir; use futures::StreamExt; use sha2::Digest; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; #[tracing::instrument] pub async fn unpublish_shared_instance(instance_id: &str) -> crate::Result<()> { @@ -187,7 +184,12 @@ pub(super) async fn remote_publish_content( version .external_files .iter() - .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE) + .filter(|file| { + !matches!( + file.file_type.as_str(), + CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE + ) + }) .map(|file| { shared_external_file_key(&file.file_type, &file.file_name) }) @@ -674,42 +676,20 @@ pub(super) async fn publish_current_content( .await?; let modpack_id = shared_modpack_id(&metadata.link); let snapshot = collect_publish_snapshot(&metadata, state).await?; - let previous_version = if CONFIG_SYNC_ENABLED - && metadata - .shared_instance - .as_ref() - .and_then(|attachment| attachment.applied_version) - .is_some() - { - match get_latest_remote_version_optional_unavailable( - shared_instance_id, - state, - ) - .await? - { - SharedInstanceRemoteResponse::Available(version) => Some(version), - SharedInstanceRemoteResponse::Unavailable(_) => None, - } - } else { - None - }; - let config_bundle = if CONFIG_SYNC_ENABLED { - build_config_bundle_candidate( + let config_files = if CONFIG_SYNC_ENABLED { + config_file_candidates( &metadata.instance.path, &snapshot.config_files, config_paths, - previous_version.as_ref(), state, ) .await? } else { - None + Vec::new() }; let modrinth_ids = snapshot.version_ids; let mut external_files = snapshot.external_files; - if let Some(config_bundle) = config_bundle { - external_files.push(config_bundle); - } + external_files.extend(config_files); tracing::debug!( instance_id, shared_instance_id, @@ -790,15 +770,16 @@ pub(super) async fn collect_config_files( while let Some(entry) = walker.next().await { let entry = entry.context("failed to read config directory")?; - if !entry.file_type().await?.is_file() - || !is_supported_config_file(&entry.path()) - { + if !entry.file_type().await?.is_file() { continue; } let entry_path = entry.path(); let relative_path = entry_path.strip_prefix(&config_path)?; let path = relative_path.to_string_lossy().replace('\\', "/"); + if !is_shareable_config_path(&format!("{CONFIG_DIRECTORY}/{path}")) { + continue; + } files.push(ConfigFile { path }); } @@ -806,224 +787,88 @@ pub(super) async fn collect_config_files( 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)) - }) -} - -async fn build_config_bundle_candidate( +/// Each selected config is uploaded as its own file under the instance root, +/// matching how linked servers publish configs. +async fn config_file_candidates( instance_path: &str, local_files: &[ConfigFile], selected_paths: &[String], - previous_version: Option<&InstanceVersionResponse>, state: &State, -) -> crate::Result> { +) -> crate::Result> { + let local_paths = local_files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); let selected_paths = selected_paths .iter() .map(String::as_str) - .collect::>(); - let local_files_by_path = local_files + .collect::>(); + if let Some(missing) = selected_paths .iter() - .map(|file| (file.path.as_str(), file)) - .collect::>(); - for selected_path in &selected_paths { - if !local_files_by_path.contains_key(selected_path) { - bail!(crate::ErrorKind::InputError(format!( - "config file {selected_path} is unavailable for sharing" - ))); - } - } - - let previous_bundle = previous_version.and_then(|version| { - version - .external_files - .iter() - .find(|file| file.file_type == CONFIG_BUNDLE_FILE_TYPE) - }); - if previous_bundle.is_none() && selected_paths.is_empty() { - return Ok(None); + .find(|path| !local_paths.contains(*path)) + { + bail!(crate::ErrorKind::InputError(format!( + "config file {missing} is unavailable for sharing" + ))); } + ensure_config_file_count(&selected_paths)?; - let previous_bundle = if let Some(previous_bundle) = previous_bundle { - Some( - crate::util::fetch::fetch_file_mirrors_in( - &[&previous_bundle.url], - None, - None, - None, - &state.fetch_semaphore, - &state.pool, - None, - Some(&state.directories.store_staging_dir()), - ) - .await?, - ) - } else { - None - }; let config_path = state .directories .instances_dir() .join(instance_path) .join(CONFIG_DIRECTORY); - let selected_files = selected_paths - .into_iter() - .map(|path| (path.to_string(), config_path.join(path))) - .collect::>(); - let bundle = tokio::task::spawn_blocking(move || { - let directory = tempfile::tempdir()?; - let mut entries = match &previous_bundle { - Some(bundle) => { - read_config_bundle(bundle.path(), directory.path())? - } - None => BTreeMap::new(), - }; - entries.extend(selected_files); - config_bundle_file(&entries) - }) - .await??; - - Ok(Some(ExternalFileCandidate { - file_name: CONFIG_BUNDLE_FILE_NAME.to_string(), - file_type: CONFIG_BUNDLE_FILE_TYPE.to_string(), - source: ExternalFileSource::ConfigBundle(Arc::new(bundle)), - })) -} - -fn config_bundle_file( - entries: &BTreeMap, -) -> crate::Result { - if entries.len() > MAX_CONFIG_BUNDLE_ENTRIES { - let mut folder_entry_counts = HashMap::new(); - for path in entries.keys() { - if let Some((folder, _)) = path.split_once('/') { - *folder_entry_counts.entry(folder).or_insert(0_usize) += 1; - } - } - - if let Some((folder, count)) = folder_entry_counts - .into_iter() - .filter(|(_, count)| *count > MAX_CONFIG_BUNDLE_ENTRIES) - .max_by_key(|(_, count)| *count) + let mut total_size = 0_u64; + let mut candidates = Vec::with_capacity(selected_paths.len()); + for path in selected_paths { + let source = config_path.join(path); + let size = crate::util::io::metadata(&source).await?.len(); + total_size = total_size.saturating_add(size); + if size > MAX_CONFIG_BUNDLE_FILE_SIZE + || total_size > MAX_CONFIG_BUNDLE_TOTAL_SIZE { - bail!(crate::ErrorKind::InputError(format!( - "the \"{folder}\" config folder has too many files to share ({count}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}); select fewer files from this folder" - ))); + return Err(crate::ErrorKind::InputError( + "Selected config files exceed the size limit for sharing" + .to_string(), + ) + .into()); } - - bail!(crate::ErrorKind::InputError(format!( - "too many config files were selected to share ({}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}); select fewer files", - entries.len() - ))); - } - let temporary = tempfile::NamedTempFile::new()?; - let mut writer = zip::ZipWriter::new(temporary); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - let mut total_size = 0; - for (path, source) in entries { - let file = std::fs::File::open(source)?; - let declared_size = file.metadata()?.len(); - writer - .start_file(path, options) - .map_err(std::io::Error::from)?; - copy_config_bundle_entry( - file, - declared_size, - &mut total_size, - &mut writer, - )?; + candidates.push(ExternalFileCandidate { + file_name: format!("{CONFIG_DIRECTORY}/{path}"), + file_type: CONFIG_FILE_TYPE.to_string(), + source: ExternalFileSource::ConfigFile(source), + }); } - Ok(writer - .finish() - .map_err(std::io::Error::from)? - .into_temp_path()) -} -fn copy_config_bundle_entry( - reader: impl std::io::Read, - declared_size: u64, - total_size: &mut u64, - writer: &mut impl std::io::Write, -) -> crate::Result<()> { - let limit = MAX_CONFIG_BUNDLE_FILE_SIZE - .min(MAX_CONFIG_BUNDLE_TOTAL_SIZE.saturating_sub(*total_size)); - if declared_size > limit { - bail!(crate::ErrorKind::InputError( - "shared instance config bundle exceeds the uncompressed size limit" - .to_string(), - )); - } - let size = std::io::copy(&mut reader.take(limit + 1), writer)?; - if size > limit { - bail!(crate::ErrorKind::InputError( - "shared instance config bundle exceeds the uncompressed size limit" - .to_string(), - )); - } - *total_size += size; - Ok(()) + Ok(candidates) } -fn read_config_bundle( - path: &Path, - directory: &Path, -) -> crate::Result> { - let mut archive = zip::ZipArchive::new(std::fs::File::open(path)?) - .context(crate::ErrorKind::InputError( - "invalid shared instance config bundle".to_string(), - ))?; - if archive.len() > MAX_CONFIG_BUNDLE_ENTRIES { - bail!(crate::ErrorKind::InputError( - "shared instance config bundle contains too many entries" - .to_string(), - )); +fn ensure_config_file_count(paths: &BTreeSet<&str>) -> crate::Result<()> { + if paths.len() <= MAX_CONFIG_BUNDLE_ENTRIES { + return Ok(()); } - let mut entries = BTreeMap::new(); - let mut total_size = 0; - - for index in 0..archive.len() { - let file = - archive - .by_index(index) - .context(crate::ErrorKind::InputError( - "invalid shared instance config bundle entry".to_string(), - ))?; - if file.is_dir() { - continue; - } - let path = file.enclosed_name().ok_or(crate::ErrorKind::InputError( - "shared instance config bundle contains an unsafe path".to_string(), - ))?; - if !is_supported_config_file(&path) { - bail!(crate::ErrorKind::InputError(format!( - "shared instance config bundle contains unsupported file {}", - path.display() - ))); - } - let path = path.to_string_lossy().replace('\\', "/"); - let declared_size = file.size(); - let destination = directory.join(index.to_string()); - let mut output = std::fs::File::create(&destination)?; - copy_config_bundle_entry( - file, - declared_size, - &mut total_size, - &mut output, - )?; - if entries.insert(path.clone(), destination).is_some() { - bail!(crate::ErrorKind::InputError(format!( - "shared instance config bundle contains duplicate file {path}" - ))); + + let mut folder_entry_counts = HashMap::new(); + for path in paths { + if let Some((folder, _)) = path.split_once('/') { + *folder_entry_counts.entry(folder).or_insert(0_usize) += 1; } } - Ok(entries) + if let Some((folder, count)) = folder_entry_counts + .into_iter() + .filter(|(_, count)| *count > MAX_CONFIG_BUNDLE_ENTRIES) + .max_by_key(|(_, count)| *count) + { + bail!(crate::ErrorKind::InputError(format!( + "the \"{folder}\" config folder has too many files to share ({count}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}); select fewer files from this folder" + ))); + } + + bail!(crate::ErrorKind::InputError(format!( + "too many config files were selected to share ({}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}); select fewer files", + paths.len() + ))) } pub(super) fn shared_modpack_id(link: &InstanceLink) -> Option { @@ -1077,9 +922,9 @@ pub(super) async fn upload_external_files( .ok_or(crate::ErrorKind::InputError("shared content file is not registered".to_string()))?; state.content_store.read_path(&file, instance_path).await? } - ExternalFileSource::ConfigBundle(path) => { + ExternalFileSource::ConfigFile(path) => { crate::state::content_store::ReadableContent::Local( - path.as_ref().to_path_buf(), + path.clone(), ) } }; diff --git a/packages/app-lib/src/install/shared_instance.rs b/packages/app-lib/src/install/shared_instance.rs index 1362b637cca..74e5e9f950c 100644 --- a/packages/app-lib/src/install/shared_instance.rs +++ b/packages/app-lib/src/install/shared_instance.rs @@ -9,9 +9,10 @@ use super::runner::{ }; use crate::ErrorKind; use crate::api::instance::{ - CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, + CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_TYPE, CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, - read_bounded_config_bundle_entry, + MAX_CONFIG_BUNDLE_FILE_SIZE, is_shareable_config_path, + is_supported_config_file, read_bounded_config_bundle_entry, }; use crate::api::pack::install_from::CreatePackLocation; use crate::state::instances::adapters::sqlite::content_rows; @@ -121,6 +122,7 @@ struct DesiredSharedInstanceContent { DesiredSharedInstanceExternalFile, >, config_bundle: Option, + config_files: Vec, } struct SharedInstanceProjectUpdate { @@ -138,6 +140,7 @@ struct SharedInstanceApplyPlan { external_updates: Vec, external_additions: Vec, config_bundle: Option, + config_files: Vec, } impl SharedInstanceApplyPlan { @@ -173,6 +176,7 @@ impl SharedInstanceApplyPlan { project_removals, external_removals, config_bundle: desired.config_bundle, + config_files: desired.config_files, ..Default::default() }; @@ -226,7 +230,8 @@ impl SharedInstanceApplyPlan { + self.project_additions.len() + self.external_updates.len() + self.external_additions.len() - + usize::from(self.config_bundle.is_some())) as u64 + + usize::from(self.config_bundle.is_some()) + + self.config_files.len()) as u64 } } @@ -406,13 +411,9 @@ async fn apply_shared_instance_update_inner( .await?; } - if let Some(config_bundle) = plan.config_bundle { - install_shared_instance_external_file( - instance_id, - &config_bundle, - state, - ) - .await?; + for config_file in plan.config_bundle.into_iter().chain(plan.config_files) { + install_shared_instance_external_file(instance_id, &config_file, state) + .await?; completed_content_changes += 1; update_content_progress( job_id, @@ -556,6 +557,12 @@ async fn desired_shared_instance_content( } for file in &data.external_files { + if file.file_type == CONFIG_FILE_TYPE { + if CONFIG_SYNC_ENABLED { + content.config_files.push(file.clone()); + } + continue; + } if file.file_type == CONFIG_BUNDLE_FILE_TYPE { if CONFIG_SYNC_ENABLED { content.config_bundle = Some(file.clone()); @@ -842,11 +849,27 @@ async fn install_shared_instance_external_file( file: &SharedInstanceExternalFileData, state: &State, ) -> crate::Result<()> { - if file.file_type == CONFIG_BUNDLE_FILE_TYPE && !CONFIG_SYNC_ENABLED { + if matches!( + file.file_type.as_str(), + CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE + ) && !CONFIG_SYNC_ENABLED + { return Ok(()); } - validate_shared_instance_external_file_name(&file.file_name)?; + if file.file_type == CONFIG_FILE_TYPE { + if !is_shareable_config_path(&file.file_name) + || file.file_size > MAX_CONFIG_BUNDLE_FILE_SIZE + { + return Err(crate::ErrorKind::InputError(format!( + "Shared instance config file {} is unsupported or too large", + file.file_name + )) + .into()); + } + } else { + validate_shared_instance_external_file_name(&file.file_name)?; + } if file.file_size > MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE { bail!(ErrorKind::InputError(format!( @@ -908,6 +931,16 @@ async fn install_shared_instance_external_file( } let bytes = bytes::Bytes::from(bytes); + if file.file_type == CONFIG_FILE_TYPE { + return crate::api::instance::write_instance_file( + instance_id, + &file.file_name, + &bytes, + false, + ) + .await; + } + if file.file_type == CONFIG_BUNDLE_FILE_TYPE { return install_shared_instance_config_bundle( instance_id, @@ -1033,13 +1066,3 @@ fn read_config_bundle(bytes: &[u8]) -> crate::Result)>> { 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)) - }) -} From c40f500e1a2605e06c8c385d61e6e1510fca8b4f Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Thu, 15 Oct 2026 17:09:46 +0100 Subject: [PATCH 02/12] fix(frontend): expand expands properly --- .../SharedInstancePublishModal.vue | 44 ++-- .../components/ContentDiffModal.vue | 236 ++++++++++-------- 2 files changed, 167 insertions(+), 113 deletions(-) diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue index 2ef93151c92..9649ed55234 100644 --- a/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue +++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue @@ -10,23 +10,36 @@ :confirm-disabled="publishDiffs.length === 0 && selectedConfigPaths.length === 0" :added-label="formatMessage(messages.addedLabel)" :removed-label="formatMessage(messages.removedLabel)" + :hide-summary="configFilesExpanded" @confirm="publishChanges" @cancel="finishReview" > diff --git a/packages/ui/src/layouts/shared/installation-settings/components/ContentDiffModal.vue b/packages/ui/src/layouts/shared/installation-settings/components/ContentDiffModal.vue index c304dfba70c..946db33c741 100644 --- a/packages/ui/src/layouts/shared/installation-settings/components/ContentDiffModal.vue +++ b/packages/ui/src/layouts/shared/installation-settings/components/ContentDiffModal.vue @@ -9,134 +9,50 @@ width="544px" no-padding scrollable - :animate-resize="!resizeAnimationPaused" > -
-
-
- - -
- {{ description }} - {{ formatMessage(messages.unknownContentBody) }} -
-
- -
- {{ versionDate }} -
-
- - {{ formatMessage(messages.updatedCount, { count: updatedCount }) }} -
-
- - {{ formatMessage(messages.addedCount, { count: addedCount }) }} -
-
- - {{ formatMessage(messages.removedCount, { count: removedCount }) }} -
-
- - {{ formatMessage(messages.removedDisabledCount, { count: removedDisabledCount }) }} -
-
-
+
+ + +
+ {{ description }} + {{ formatMessage(messages.unknownContentBody) }}
- -
-
-
- -
- - {{ formatMessage(messages.noContentChanges) }} - + + +
+ {{ versionDate }} +
+
+ + {{ formatMessage(messages.updatedCount, { count: updatedCount }) }}
-
-
-
- - - -
-
- -
- {{ - getDiffTypeLabel(diff) - }} - - - {{ diff.projectName || (diff.fileName ? decodeURIComponent(diff.fileName) : '') }} - -
- - {{ getVersionLabel(diff) }} - +
+ + {{ formatMessage(messages.addedCount, { count: addedCount }) }} +
+
+ + {{ formatMessage(messages.removedCount, { count: removedCount }) }} +
+
+ + {{ formatMessage(messages.removedDisabledCount, { count: removedDisabledCount }) }}
-
- -
+
-
+