Files
modrinth/packages/app-lib/src/install/shared_instance.rs
T
Calum H.andsychic e58af98f21 feat: instance sharing thru shared-instances service (#6569)
* 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>
2026-07-24 13:06:38 +00:00

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(&current.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))
})
}