mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
1017 lines
30 KiB
Rust
1017 lines
30 KiB
Rust
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<String, CurrentSharedInstanceProject>,
|
|
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<String, DesiredSharedInstanceProject>,
|
|
external_files: HashMap<
|
|
SharedInstanceExternalFileKey,
|
|
DesiredSharedInstanceExternalFile,
|
|
>,
|
|
config_bundle: Option<SharedInstanceExternalFileData>,
|
|
}
|
|
|
|
struct SharedInstanceProjectUpdate {
|
|
current: CurrentSharedInstanceProject,
|
|
desired: DesiredSharedInstanceProject,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SharedInstanceApplyPlan {
|
|
configuration_changed: bool,
|
|
project_removals: Vec<CurrentSharedInstanceProject>,
|
|
project_updates: Vec<SharedInstanceProjectUpdate>,
|
|
project_additions: Vec<DesiredSharedInstanceProject>,
|
|
external_removals: Vec<CurrentSharedInstanceExternalFile>,
|
|
external_updates: Vec<DesiredSharedInstanceExternalFile>,
|
|
external_additions: Vec<DesiredSharedInstanceExternalFile>,
|
|
config_bundle: Option<SharedInstanceExternalFileData>,
|
|
}
|
|
|
|
impl SharedInstanceApplyPlan {
|
|
async fn build(
|
|
metadata: &crate::state::InstanceMetadata,
|
|
data: &SharedInstanceInstallData,
|
|
state: &State,
|
|
) -> crate::Result<Self> {
|
|
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<CurrentSharedInstanceContent> {
|
|
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::<HashMap<_, _>>();
|
|
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::<Vec<_>>();
|
|
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<DesiredSharedInstanceContent> {
|
|
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<HashMap<String, crate::state::Version>> {
|
|
if version_ids.is_empty() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
|
|
let mut ids = version_ids.iter().map(String::as_str).collect::<Vec<_>>();
|
|
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::<HashMap<_, _>>();
|
|
|
|
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::<std::collections::HashMap<_, _>>();
|
|
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<Vec<(PathBuf, Vec<u8>)>> {
|
|
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))
|
|
})
|
|
}
|