mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
1189 lines
36 KiB
Rust
1189 lines
36 KiB
Rust
use super::client::*;
|
|
use super::diff::*;
|
|
use super::install::*;
|
|
use super::types::*;
|
|
use super::*;
|
|
use async_walkdir::WalkDir;
|
|
use async_zip::{Compression, ZipEntryBuilder};
|
|
use futures::StreamExt;
|
|
use sha2::Digest;
|
|
use std::collections::BTreeMap;
|
|
|
|
#[tracing::instrument]
|
|
pub async fn unpublish_shared_instance(instance_id: &str) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let metadata = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
let Some(attachment) = metadata.shared_instance.clone() else {
|
|
return Ok(());
|
|
};
|
|
ensure_owner(&attachment)?;
|
|
|
|
delete_remote_instance(&attachment.id, &state).await?;
|
|
detach_local_shared_instance(instance_id, metadata.link, &state).await?;
|
|
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn unlink_shared_instance(instance_id: &str) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let metadata = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
let Some(attachment) = metadata.shared_instance.clone() else {
|
|
return Ok(());
|
|
};
|
|
ensure_member(&attachment)?;
|
|
|
|
detach_local_shared_instance(instance_id, metadata.link, &state).await?;
|
|
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn detach_local_shared_instance(
|
|
instance_id: &str,
|
|
link: InstanceLink,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let link_patch = match link {
|
|
InstanceLink::SharedInstance {
|
|
modpack_project_id: Some(project_id),
|
|
modpack_version_id: Some(version_id),
|
|
} => Some((
|
|
InstanceLink::ModrinthModpack {
|
|
project_id,
|
|
version_id,
|
|
},
|
|
ContentSourceKind::ModrinthModpack,
|
|
)),
|
|
InstanceLink::SharedInstance { .. } => {
|
|
Some((InstanceLink::Unmanaged, ContentSourceKind::Local))
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
if let Some((link, source_kind)) = link_patch {
|
|
crate::state::edit_instance(
|
|
instance_id,
|
|
EditInstance {
|
|
link: Some(link),
|
|
content_set_patch: Some(AppliedContentSetPatch {
|
|
source_kind: Some(source_kind),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
crate::state::clear_shared_instance(instance_id, &state.pool).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn publish_shared_instance(
|
|
instance_id: &str,
|
|
config_paths: Vec<String>,
|
|
) -> crate::Result<SharedInstanceAttachment> {
|
|
let state = State::get().await?;
|
|
publish_shared_instance_inner(instance_id, &config_paths, &state).await?;
|
|
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
|
|
|
shared_attachment(instance_id, &state)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Shared instance attachment was not persisted".to_string(),
|
|
)
|
|
.into()
|
|
})
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_shared_instance_publish_preview(
|
|
instance_id: &str,
|
|
) -> crate::Result<Option<SharedInstancePublishPreview>> {
|
|
let state = State::get().await?;
|
|
let metadata = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
let Some(attachment) = metadata.shared_instance.clone() else {
|
|
return Ok(None);
|
|
};
|
|
ensure_owner(&attachment)?;
|
|
|
|
let (version, snapshot) = tokio::try_join!(
|
|
get_latest_remote_version_optional_unavailable(&attachment.id, &state,),
|
|
collect_publish_snapshot(&metadata, &state),
|
|
)?;
|
|
let version = match version {
|
|
SharedInstanceRemoteResponse::Available(version) => version,
|
|
SharedInstanceRemoteResponse::Unavailable(reason) => {
|
|
handle_unavailable_shared_instance_if_current_user(
|
|
instance_id,
|
|
&attachment,
|
|
reason,
|
|
&state,
|
|
)
|
|
.await?;
|
|
return Err(shared_instance_unavailable_error(reason));
|
|
}
|
|
};
|
|
let diffs =
|
|
shared_instance_publish_diffs(&metadata, &version, &snapshot, &state)
|
|
.await?;
|
|
set_shared_instance_publish_status(
|
|
instance_id,
|
|
&attachment,
|
|
version.version,
|
|
!diffs.is_empty(),
|
|
&state,
|
|
)
|
|
.await?;
|
|
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
|
|
|
Ok(Some(SharedInstancePublishPreview {
|
|
shared_instance_id: attachment.id,
|
|
latest_version: version.version,
|
|
diffs,
|
|
config_files: snapshot
|
|
.config_files
|
|
.into_iter()
|
|
.map(|file| file.path)
|
|
.collect(),
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn remote_publish_content(
|
|
version: &InstanceVersionResponse,
|
|
include_modpack_dependencies: bool,
|
|
state: &State,
|
|
) -> crate::Result<(Vec<String>, HashSet<String>)> {
|
|
let mut version_ids = version.modrinth_ids.clone();
|
|
if let Some(modpack_id) =
|
|
version.modpack_id.as_deref().filter(|id| !id.is_empty())
|
|
{
|
|
version_ids.retain(|id| id != modpack_id);
|
|
|
|
if include_modpack_dependencies {
|
|
version_ids.extend(
|
|
modpack_dependency_version_ids(modpack_id, state).await?,
|
|
);
|
|
}
|
|
}
|
|
dedupe_strings(&mut version_ids);
|
|
|
|
Ok((
|
|
version_ids,
|
|
version
|
|
.external_files
|
|
.iter()
|
|
.filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
|
|
.map(|file| file.file_name.clone())
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
pub(super) async fn modpack_dependency_version_ids(
|
|
modpack_id: &str,
|
|
state: &State,
|
|
) -> crate::Result<Vec<String>> {
|
|
let modpack_version = CachedEntry::get_version(
|
|
modpack_id,
|
|
Some(CacheBehaviour::Bypass),
|
|
&state.pool,
|
|
&state.api_semaphore,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Shared instance modpack version was not found".to_string(),
|
|
)
|
|
})?;
|
|
|
|
Ok(modpack_version
|
|
.dependencies
|
|
.into_iter()
|
|
.filter_map(|dependency| dependency.version_id)
|
|
.collect())
|
|
}
|
|
|
|
pub(super) async fn shared_instance_install_modpack(
|
|
version: &InstanceVersionResponse,
|
|
state: &State,
|
|
) -> crate::Result<Option<crate::install::SharedInstanceInstallModpack>> {
|
|
let Some(modpack_id) =
|
|
version.modpack_id.as_deref().filter(|id| !id.is_empty())
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let modpack_version = CachedEntry::get_version(
|
|
modpack_id,
|
|
Some(CacheBehaviour::MustRevalidate),
|
|
&state.pool,
|
|
&state.api_semaphore,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Shared instance modpack version was not found".to_string(),
|
|
)
|
|
})?;
|
|
let project = CachedEntry::get_project(
|
|
&modpack_version.project_id,
|
|
Some(CacheBehaviour::MustRevalidate),
|
|
&state.pool,
|
|
&state.api_semaphore,
|
|
)
|
|
.await?;
|
|
|
|
Ok(Some(crate::install::SharedInstanceInstallModpack {
|
|
project_id: modpack_version.project_id,
|
|
version_id: modpack_version.id,
|
|
dependency_count: modpack_version.dependencies.len(),
|
|
title: project
|
|
.as_ref()
|
|
.map(|project| project.title.clone())
|
|
.unwrap_or(modpack_version.name),
|
|
icon_url: project.and_then(|project| project.icon_url),
|
|
}))
|
|
}
|
|
|
|
pub(super) fn shared_instance_loader_version(
|
|
loader_version: String,
|
|
) -> Option<String> {
|
|
(!loader_version.is_empty()).then_some(loader_version)
|
|
}
|
|
|
|
pub(super) async fn current_shared_content(
|
|
metadata: &crate::state::InstanceMetadata,
|
|
include_linked_modpack_content: bool,
|
|
state: &State,
|
|
) -> crate::Result<(Vec<String>, HashSet<String>)> {
|
|
let entries =
|
|
crate::state::instances::adapters::sqlite::content_rows::get_content_entries(
|
|
&metadata.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files(
|
|
&metadata.instance.id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.map(|file| (file.id.clone(), file))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut version_ids = Vec::new();
|
|
let mut external_files = HashSet::new();
|
|
|
|
for entry in entries {
|
|
let include_entry = entry.source_kind
|
|
== crate::state::ContentSourceKind::SharedInstance
|
|
|| (include_linked_modpack_content
|
|
&& entry.source_kind
|
|
== crate::state::ContentSourceKind::ModrinthModpack);
|
|
if !include_entry {
|
|
continue;
|
|
}
|
|
|
|
if let Some(version_id) = entry.version_id {
|
|
version_ids.push(version_id);
|
|
continue;
|
|
}
|
|
|
|
let Some(file_id) = entry.file_id else {
|
|
continue;
|
|
};
|
|
if let Some(file) = files.get(&file_id) {
|
|
external_files.insert(file.file_name.clone());
|
|
}
|
|
}
|
|
if include_linked_modpack_content
|
|
&& let Some(modpack_id) = shared_modpack_id(&metadata.link)
|
|
{
|
|
version_ids
|
|
.extend(modpack_dependency_version_ids(&modpack_id, state).await?);
|
|
}
|
|
dedupe_strings(&mut version_ids);
|
|
|
|
Ok((version_ids, external_files))
|
|
}
|
|
|
|
pub(super) struct CurrentPublishSnapshot {
|
|
pub(super) version_ids: Vec<String>,
|
|
pub(super) external_files: Vec<ExternalFileCandidate>,
|
|
pub(super) disabled_project_ids: HashSet<String>,
|
|
pub(super) disabled_version_ids: Vec<String>,
|
|
pub(super) disabled_external_files: HashSet<String>,
|
|
pub(super) config_files: Vec<ConfigFile>,
|
|
}
|
|
|
|
pub(super) async fn collect_publish_snapshot(
|
|
metadata: &crate::state::InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<CurrentPublishSnapshot> {
|
|
let instance_path = state
|
|
.directories
|
|
.instances_dir()
|
|
.join(&metadata.instance.path);
|
|
let (items, config_files) = if CONFIG_SYNC_ENABLED {
|
|
tokio::try_join!(
|
|
crate::state::list_content(
|
|
&metadata.instance.id,
|
|
None,
|
|
None,
|
|
state,
|
|
),
|
|
collect_config_files(&instance_path),
|
|
)?
|
|
} else {
|
|
(
|
|
crate::state::list_content(
|
|
&metadata.instance.id,
|
|
None,
|
|
None,
|
|
state,
|
|
)
|
|
.await?,
|
|
Vec::new(),
|
|
)
|
|
};
|
|
let modpack_id = shared_modpack_id(&metadata.link);
|
|
let mut version_ids = Vec::new();
|
|
let mut seen_version_ids = HashSet::new();
|
|
let mut external_files = Vec::new();
|
|
let mut seen_external_files = HashSet::new();
|
|
let mut disabled_project_ids = HashSet::new();
|
|
let mut disabled_version_ids = Vec::new();
|
|
let mut seen_disabled_version_ids = HashSet::new();
|
|
let mut disabled_external_files = HashSet::new();
|
|
|
|
for item in items {
|
|
if item.enabled {
|
|
if let Some(version) = item.version {
|
|
if seen_version_ids.insert(version.id.clone()) {
|
|
version_ids.push(version.id);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if item.file_path.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let file_type = file_type(item.project_type);
|
|
let external_key = format!("{}:{file_type}", item.file_path);
|
|
if seen_external_files.insert(external_key) {
|
|
external_files.push(ExternalFileCandidate {
|
|
file_name: item.file_name,
|
|
file_type,
|
|
source: ExternalFileSource::InstanceFile(item.file_path),
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let is_modpack = item.version.as_ref().is_some_and(|version| {
|
|
modpack_id.as_deref() == Some(version.id.as_str())
|
|
});
|
|
if is_modpack {
|
|
continue;
|
|
}
|
|
|
|
if let Some(project) = item.project.as_ref() {
|
|
disabled_project_ids.insert(project.id.clone());
|
|
}
|
|
|
|
if let Some(version) = item.version {
|
|
if seen_disabled_version_ids.insert(version.id.clone()) {
|
|
disabled_version_ids.push(version.id);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if item.file_path.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
disabled_external_files.insert(enabled_file_name(&item.file_name));
|
|
}
|
|
|
|
Ok(CurrentPublishSnapshot {
|
|
version_ids,
|
|
external_files,
|
|
disabled_project_ids,
|
|
disabled_version_ids,
|
|
disabled_external_files,
|
|
config_files,
|
|
})
|
|
}
|
|
|
|
pub(super) async fn shared_versions_by_project(
|
|
version_ids: &[String],
|
|
state: &State,
|
|
) -> crate::Result<HashMap<String, crate::state::Version>> {
|
|
let version_id_refs =
|
|
version_ids.iter().map(String::as_str).collect::<Vec<_>>();
|
|
let versions = CachedEntry::get_version_many(
|
|
&version_id_refs,
|
|
Some(CacheBehaviour::Bypass),
|
|
&state.pool,
|
|
&state.api_semaphore,
|
|
)
|
|
.await?;
|
|
|
|
Ok(versions
|
|
.into_iter()
|
|
.map(|version| (version.project_id.clone(), version))
|
|
.collect())
|
|
}
|
|
|
|
pub(super) async fn shared_project_names(
|
|
project_ids: &HashSet<String>,
|
|
state: &State,
|
|
) -> crate::Result<HashMap<String, String>> {
|
|
let project_id_refs =
|
|
project_ids.iter().map(String::as_str).collect::<Vec<_>>();
|
|
let projects = CachedEntry::get_project_many(
|
|
&project_id_refs,
|
|
Some(CacheBehaviour::Bypass),
|
|
&state.pool,
|
|
&state.api_semaphore,
|
|
)
|
|
.await?;
|
|
|
|
Ok(projects
|
|
.into_iter()
|
|
.map(|project| (project.id, project.title))
|
|
.collect())
|
|
}
|
|
|
|
pub(super) fn dedupe_strings(values: &mut Vec<String>) {
|
|
let mut seen = HashSet::new();
|
|
values.retain(|value| seen.insert(value.clone()));
|
|
}
|
|
|
|
pub(super) fn enabled_file_name(file_name: &str) -> String {
|
|
file_name
|
|
.strip_suffix(".disabled")
|
|
.unwrap_or(file_name)
|
|
.to_string()
|
|
}
|
|
|
|
pub(super) fn shared_instance_name(name: String) -> String {
|
|
match name.trim() {
|
|
"" => "Shared instance".to_string(),
|
|
name => name.to_string(),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn set_shared_instance_publish_status(
|
|
instance_id: &str,
|
|
attachment: &SharedInstanceAttachment,
|
|
latest_version: i32,
|
|
has_changes: bool,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let (status, applied_version) = if has_changes {
|
|
(ContentSetSyncStatus::Stale, attachment.applied_version)
|
|
} else {
|
|
(ContentSetSyncStatus::UpToDate, Some(latest_version))
|
|
};
|
|
|
|
crate::state::set_shared_instance_sync_status(
|
|
instance_id,
|
|
status,
|
|
applied_version,
|
|
Some(latest_version),
|
|
&state.pool,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(super) async fn publish_shared_instance_inner(
|
|
instance_id: &str,
|
|
config_paths: &[String],
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let attachment =
|
|
shared_attachment(instance_id, state)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Instance is not attached to a shared instance".to_string(),
|
|
)
|
|
})?;
|
|
ensure_owner(&attachment)?;
|
|
tracing::info!(
|
|
instance_id,
|
|
shared_instance_id = %attachment.id,
|
|
applied_version = attachment.applied_version,
|
|
latest_version = attachment.latest_version,
|
|
"Publishing shared instance content"
|
|
);
|
|
|
|
crate::state::set_shared_instance_sync_status(
|
|
instance_id,
|
|
ContentSetSyncStatus::Applying,
|
|
attachment.applied_version,
|
|
attachment.latest_version,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
|
|
let result = publish_current_content(
|
|
instance_id,
|
|
&attachment.id,
|
|
config_paths,
|
|
state,
|
|
)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(version) => {
|
|
tracing::info!(
|
|
instance_id,
|
|
shared_instance_id = %attachment.id,
|
|
version,
|
|
"Published shared instance content"
|
|
);
|
|
crate::state::set_shared_instance_sync_status(
|
|
instance_id,
|
|
ContentSetSyncStatus::UpToDate,
|
|
Some(version),
|
|
Some(version),
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
instance_id,
|
|
shared_instance_id = %attachment.id,
|
|
error = %error,
|
|
"Failed to publish shared instance content"
|
|
);
|
|
crate::state::set_shared_instance_sync_status(
|
|
instance_id,
|
|
ContentSetSyncStatus::Error,
|
|
attachment.applied_version,
|
|
attachment.latest_version,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
Err(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn publish_current_content(
|
|
instance_id: &str,
|
|
shared_instance_id: &str,
|
|
config_paths: &[String],
|
|
state: &State,
|
|
) -> crate::Result<i32> {
|
|
let metadata = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
ensure_shareable_link(&metadata.link)?;
|
|
update_remote_instance(
|
|
shared_instance_id,
|
|
shared_instance_name(metadata.instance.name.clone()),
|
|
state,
|
|
)
|
|
.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(
|
|
&metadata.instance.path,
|
|
&snapshot.config_files,
|
|
config_paths,
|
|
previous_version.as_ref(),
|
|
state,
|
|
)
|
|
.await?
|
|
} else {
|
|
None
|
|
};
|
|
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);
|
|
}
|
|
tracing::debug!(
|
|
instance_id,
|
|
shared_instance_id,
|
|
modpack_id = modpack_id.as_deref().unwrap_or("none"),
|
|
modrinth_id_count = modrinth_ids.len(),
|
|
external_file_count = external_files.len(),
|
|
"Creating shared instance version"
|
|
);
|
|
let external_file_data = external_files
|
|
.iter()
|
|
.map(|file| ExternalFileData {
|
|
file_name: file.file_name.clone(),
|
|
file_type: file.file_type.clone(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let response = request_json_optional_unavailable::<InstanceVersionResponse>(
|
|
"create_instance_version",
|
|
Method::POST,
|
|
&format!("/instances/{shared_instance_id}/versions"),
|
|
Some(json!({
|
|
"modrinth_ids": modrinth_ids,
|
|
"external_files": external_file_data,
|
|
"modpack_id": modpack_id,
|
|
"game_version": metadata.applied_content_set.game_version.clone(),
|
|
"loader": metadata.applied_content_set.loader.as_str(),
|
|
"loader_version": metadata
|
|
.applied_content_set
|
|
.loader_version
|
|
.clone()
|
|
.unwrap_or_default(),
|
|
})),
|
|
state,
|
|
SharedInstancesRequestAuth::ModrinthSession,
|
|
)
|
|
.await?;
|
|
let response = match response {
|
|
SharedInstanceRemoteResponse::Available(response) => response,
|
|
SharedInstanceRemoteResponse::Unavailable(reason) => {
|
|
if let Some(attachment) = metadata.shared_instance.as_ref() {
|
|
handle_unavailable_shared_instance_if_current_user(
|
|
instance_id,
|
|
attachment,
|
|
reason,
|
|
state,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
return Err(shared_instance_unavailable_error(reason));
|
|
}
|
|
};
|
|
|
|
if !response.external_files.is_empty() {
|
|
upload_external_files(
|
|
&metadata.instance.path,
|
|
&external_files,
|
|
&response.external_files,
|
|
state,
|
|
)
|
|
.await?;
|
|
} else if !response.ready {
|
|
tracing::debug!(
|
|
"Shared instance version {} was not ready but had no external files",
|
|
response.version
|
|
);
|
|
}
|
|
|
|
Ok(response.version)
|
|
}
|
|
|
|
pub(super) async fn collect_config_files(
|
|
instance_path: &std::path::Path,
|
|
) -> crate::Result<Vec<ConfigFile>> {
|
|
let config_path = instance_path.join(CONFIG_DIRECTORY);
|
|
crate::util::io::create_dir_all(&config_path).await?;
|
|
let mut files = Vec::new();
|
|
let mut walker = WalkDir::new(&config_path);
|
|
|
|
while let Some(entry) = walker.next().await {
|
|
let entry = entry.map_err(|error| {
|
|
crate::ErrorKind::OtherError(format!(
|
|
"Failed to read config directory: {error}"
|
|
))
|
|
})?;
|
|
if !entry.file_type().await?.is_file()
|
|
|| !is_supported_config_file(&entry.path())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let entry_path = entry.path();
|
|
let relative_path = entry_path.strip_prefix(&config_path)?;
|
|
let path = relative_path.to_string_lossy().replace('\\', "/");
|
|
files.push(ConfigFile { path });
|
|
}
|
|
|
|
files.sort_by(|left, right| left.path.cmp(&right.path));
|
|
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(
|
|
instance_path: &str,
|
|
local_files: &[ConfigFile],
|
|
selected_paths: &[String],
|
|
previous_version: Option<&InstanceVersionResponse>,
|
|
state: &State,
|
|
) -> crate::Result<Option<ExternalFileCandidate>> {
|
|
let selected_paths = selected_paths
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<HashSet<_>>();
|
|
let local_files_by_path = local_files
|
|
.iter()
|
|
.map(|file| (file.path.as_str(), file))
|
|
.collect::<HashMap<_, _>>();
|
|
for selected_path in &selected_paths {
|
|
if !local_files_by_path.contains_key(selected_path) {
|
|
return Err(crate::ErrorKind::InputError(format!(
|
|
"Config file is unavailable for sharing: {selected_path}"
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
let mut entries = BTreeMap::new();
|
|
if let (Some(_), Some(previous_bundle)) =
|
|
(previous_version, previous_bundle)
|
|
{
|
|
let response = REQWEST_CLIENT.get(&previous_bundle.url).send().await?;
|
|
if !response.status().is_success() {
|
|
return Err(crate::ErrorKind::OtherError(format!(
|
|
"Previous config bundle download failed with status {}",
|
|
response.status()
|
|
))
|
|
.into());
|
|
}
|
|
let bytes = response.bytes().await?;
|
|
let archived_entries = tokio::task::spawn_blocking(move || {
|
|
read_config_bundle(bytes.as_ref())
|
|
})
|
|
.await??;
|
|
entries.extend(archived_entries);
|
|
}
|
|
|
|
let config_path = state
|
|
.directories
|
|
.instances_dir()
|
|
.join(instance_path)
|
|
.join(CONFIG_DIRECTORY);
|
|
for selected_path in selected_paths {
|
|
let file = local_files_by_path
|
|
.get(selected_path)
|
|
.expect("selected config paths were validated");
|
|
let bytes = crate::util::io::read(config_path.join(&file.path)).await?;
|
|
entries.insert(file.path.clone(), bytes);
|
|
}
|
|
|
|
let bytes = config_bundle_bytes(&entries).await?;
|
|
|
|
Ok(Some(ExternalFileCandidate {
|
|
file_name: CONFIG_BUNDLE_FILE_NAME.to_string(),
|
|
file_type: CONFIG_BUNDLE_FILE_TYPE.to_string(),
|
|
source: ExternalFileSource::ConfigBundle(bytes),
|
|
}))
|
|
}
|
|
|
|
async fn config_bundle_bytes(
|
|
entries: &BTreeMap<String, Vec<u8>>,
|
|
) -> crate::Result<Vec<u8>> {
|
|
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)
|
|
{
|
|
return Err(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."
|
|
))
|
|
.into());
|
|
}
|
|
|
|
return Err(crate::ErrorKind::InputError(format!(
|
|
"Too many config files were selected to share ({}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}). Select fewer files.",
|
|
entries.len()
|
|
))
|
|
.into());
|
|
}
|
|
let mut total_size = 0_u64;
|
|
for bytes in entries.values() {
|
|
let size = bytes.len() as u64;
|
|
if size > MAX_CONFIG_BUNDLE_FILE_SIZE {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Shared instance config bundle contains a file that is too large"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
total_size = total_size.checked_add(size).ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Shared instance config bundle size overflowed".to_string(),
|
|
)
|
|
})?;
|
|
if total_size > MAX_CONFIG_BUNDLE_TOTAL_SIZE {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Shared instance config bundle exceeds the uncompressed size limit"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
|
|
let mut writer = async_zip::base::write::ZipFileWriter::new(Vec::new());
|
|
for (path, bytes) in entries {
|
|
writer
|
|
.write_entry_whole(
|
|
ZipEntryBuilder::new(path.clone().into(), Compression::Deflate),
|
|
bytes,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Ok(writer.close().await?)
|
|
}
|
|
|
|
fn read_config_bundle(
|
|
bytes: &[u8],
|
|
) -> crate::Result<BTreeMap<String, 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 entries = BTreeMap::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 path = path.to_string_lossy().replace('\\', "/");
|
|
let declared_size = file.size();
|
|
let bytes = read_bounded_config_bundle_entry(
|
|
file,
|
|
declared_size,
|
|
&mut total_size,
|
|
)?;
|
|
if entries.insert(path.clone(), bytes).is_some() {
|
|
return Err(crate::ErrorKind::InputError(format!(
|
|
"Shared instance config bundle contains duplicate file {path}"
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
pub(super) fn shared_modpack_id(link: &InstanceLink) -> Option<String> {
|
|
match link {
|
|
InstanceLink::ModrinthModpack { version_id, .. } => {
|
|
Some(version_id.clone())
|
|
}
|
|
InstanceLink::ServerProjectModpack {
|
|
content_version_id, ..
|
|
} => Some(content_version_id.clone()),
|
|
InstanceLink::SharedInstance {
|
|
modpack_version_id: Some(version_id),
|
|
..
|
|
} => Some(version_id.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn ensure_shareable_link(link: &InstanceLink) -> crate::Result<()> {
|
|
if matches!(link, InstanceLink::ImportedModpack { .. }) {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"You must unlink this modpack to share your instance".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn upload_external_files(
|
|
instance_path: &str,
|
|
candidates: &[ExternalFileCandidate],
|
|
uploads: &[ExternalFileResponse],
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
for upload in uploads {
|
|
let candidate = candidates
|
|
.iter()
|
|
.find(|candidate| {
|
|
candidate.file_name == upload.file_name
|
|
&& candidate.file_type == upload.file_type
|
|
})
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Shared instance service requested unknown external file {}",
|
|
upload.file_name
|
|
))
|
|
})?;
|
|
let bytes = match &candidate.source {
|
|
ExternalFileSource::InstanceFile(file_path) => {
|
|
let path = state
|
|
.directories
|
|
.instances_dir()
|
|
.join(instance_path)
|
|
.join(file_path);
|
|
crate::util::io::read(path).await?
|
|
}
|
|
ExternalFileSource::ConfigBundle(bytes) => bytes.clone(),
|
|
};
|
|
let upload_url = url::Url::parse(&upload.url).map_err(|error| {
|
|
crate::ErrorKind::OtherError(format!(
|
|
"Invalid shared instance external file upload URL: {error}"
|
|
))
|
|
})?;
|
|
let (bytes, file_sha512) = tokio::task::spawn_blocking(move || {
|
|
let hash = format!("{:x}", sha2::Sha512::digest(&bytes));
|
|
(bytes, hash)
|
|
})
|
|
.await?;
|
|
let response = send_bytes_request_to_url(
|
|
"upload_external_file",
|
|
Method::PUT,
|
|
upload_url.path(),
|
|
&upload.url,
|
|
bytes,
|
|
Some(&file_sha512),
|
|
state,
|
|
)
|
|
.await?;
|
|
|
|
if !response.status().is_success() {
|
|
return shared_instances_request_error(
|
|
"upload_external_file",
|
|
Method::PUT,
|
|
upload_url.path(),
|
|
response,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn shared_attachment(
|
|
instance_id: &str,
|
|
state: &State,
|
|
) -> crate::Result<Option<SharedInstanceAttachment>> {
|
|
Ok(crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.and_then(|metadata| metadata.shared_instance))
|
|
}
|
|
|
|
pub(crate) async fn sync_shared_instance_icon(
|
|
instance_id: &str,
|
|
icon_path: Option<&str>,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let Some(attachment) = shared_attachment(instance_id, state).await? else {
|
|
return Ok(());
|
|
};
|
|
if attachment.role != SharedInstanceRole::Owner {
|
|
return Ok(());
|
|
}
|
|
|
|
update_remote_instance_icon(&attachment.id, icon_path, state).await
|
|
}
|
|
|
|
pub(super) async fn shared_instance_for_invites(
|
|
instance_id: &str,
|
|
user_count: usize,
|
|
state: &State,
|
|
) -> crate::Result<(crate::state::InstanceMetadata, SharedInstanceAttachment)> {
|
|
let metadata = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
let attachment = match metadata.shared_instance.clone() {
|
|
Some(attachment) => {
|
|
tracing::debug!(
|
|
instance_id,
|
|
shared_instance_id = %attachment.id,
|
|
role = attachment.role.as_str(),
|
|
user_count,
|
|
"Using existing shared instance attachment for invite"
|
|
);
|
|
attachment
|
|
}
|
|
None => {
|
|
ensure_shareable_link(&metadata.link)?;
|
|
tracing::info!(
|
|
instance_id,
|
|
user_count,
|
|
"Creating shared instance before first invite"
|
|
);
|
|
let remote = create_remote_instance(
|
|
shared_instance_name(metadata.instance.name.clone()),
|
|
state,
|
|
)
|
|
.await?;
|
|
let linked_user_id = linked_modrinth_user_id(state).await?;
|
|
tracing::info!(
|
|
instance_id,
|
|
shared_instance_id = %remote.id,
|
|
"Created remote shared instance"
|
|
);
|
|
crate::state::attach_shared_instance(
|
|
instance_id,
|
|
crate::state::SharedInstanceAttachmentInput {
|
|
id: remote.id.clone(),
|
|
role: SharedInstanceRole::Owner,
|
|
manager_id: None,
|
|
server_manager_name: None,
|
|
server_manager_icon_url: None,
|
|
linked_user_id,
|
|
status: ContentSetSyncStatus::Unknown,
|
|
applied_version: None,
|
|
latest_version: None,
|
|
},
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
tracing::debug!(
|
|
instance_id,
|
|
shared_instance_id = %remote.id,
|
|
"Attached local instance as shared instance owner"
|
|
);
|
|
publish_shared_instance_inner(instance_id, &[], state).await?;
|
|
shared_attachment(instance_id, state)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Shared instance attachment was not persisted"
|
|
.to_string(),
|
|
)
|
|
})?
|
|
}
|
|
};
|
|
|
|
ensure_owner(&attachment)?;
|
|
update_remote_instance_icon(
|
|
&attachment.id,
|
|
metadata.instance.icon_path.as_deref(),
|
|
state,
|
|
)
|
|
.await?;
|
|
|
|
Ok((metadata, attachment))
|
|
}
|
|
|
|
pub(super) fn ensure_owner(
|
|
attachment: &SharedInstanceAttachment,
|
|
) -> crate::Result<()> {
|
|
if attachment.role == SharedInstanceRole::Owner {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(crate::ErrorKind::InputError(
|
|
"Only the owner instance can manage shared instance users".to_string(),
|
|
)
|
|
.into())
|
|
}
|
|
|
|
pub(super) fn ensure_member(
|
|
attachment: &SharedInstanceAttachment,
|
|
) -> crate::Result<()> {
|
|
if attachment.role.is_member() {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(crate::ErrorKind::InputError(
|
|
"Only shared instance members can unlink from shared instances"
|
|
.to_string(),
|
|
)
|
|
.into())
|
|
}
|
|
|
|
pub(super) fn file_type(project_type: ProjectType) -> String {
|
|
project_type.get_name().to_string()
|
|
}
|