mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
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>
This commit is contained in:
@@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
|
||||
Some(("server", id)) => {
|
||||
CommandPayload::InstallServer { id: id.to_string() }
|
||||
}
|
||||
// /share/{invite_id}
|
||||
Some(("share", raw)) => {
|
||||
let (raw, _) = raw.split_once('?').unwrap_or((raw, ""));
|
||||
|
||||
match decode(raw) {
|
||||
Ok(decoded) => CommandPayload::InstallSharedInstanceInvite {
|
||||
invite_id: decoded.to_string(),
|
||||
},
|
||||
Err(e) => {
|
||||
emit_warning(&format!(
|
||||
"Invalid UTF-8 in shared instance invite path: {e}"
|
||||
))
|
||||
.await?;
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Invalid UTF-8 in shared instance invite path: {e}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// /launch/instance/{id} - Launches an instance
|
||||
Some(("launch", rest)) if rest.starts_with("instance/") => {
|
||||
let raw = rest.trim_start_matches("instance/");
|
||||
@@ -93,7 +113,7 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
|
||||
pub async fn parse_command(
|
||||
command_string: &str,
|
||||
) -> crate::Result<CommandPayload> {
|
||||
tracing::debug!("Parsing command: {}", &command_string);
|
||||
tracing::debug!("Parsing external command");
|
||||
|
||||
// modrinth://some-command
|
||||
// This occurs when following a web redirect link
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Theseus instance management interface
|
||||
|
||||
mod content;
|
||||
mod content_set_diff;
|
||||
mod export_mrpack;
|
||||
mod get;
|
||||
mod install;
|
||||
@@ -8,6 +9,7 @@ mod lifecycle;
|
||||
mod paths;
|
||||
mod projects;
|
||||
mod run;
|
||||
mod shared;
|
||||
|
||||
pub use self::content::{
|
||||
get_content_items, get_dependencies_as_content_items,
|
||||
@@ -33,3 +35,24 @@ pub use self::projects::{
|
||||
pub use self::run::{
|
||||
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
|
||||
};
|
||||
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,
|
||||
};
|
||||
pub use self::shared::{
|
||||
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
|
||||
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
|
||||
SharedInstanceJoinType, SharedInstancePublishPreview,
|
||||
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
|
||||
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
|
||||
accept_pending_shared_instance_invite,
|
||||
accept_shared_instance_invite_for_install,
|
||||
can_active_user_use_shared_instances, create_shared_instance_invite_link,
|
||||
decline_pending_shared_instance_invite,
|
||||
get_shared_instance_install_preview, get_shared_instance_publish_preview,
|
||||
get_shared_instance_update_preview, get_shared_instance_users,
|
||||
install_shared_instance, invite_shared_instance_users,
|
||||
publish_shared_instance, remove_shared_instance_users,
|
||||
unlink_shared_instance, unpublish_shared_instance, update_shared_instance,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::state::Version;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ContentSetDiffKind {
|
||||
Added,
|
||||
Removed,
|
||||
Updated,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ContentSetDiffEntry {
|
||||
Project {
|
||||
kind: ContentSetDiffKind,
|
||||
project_id: String,
|
||||
current_version_name: Option<String>,
|
||||
new_version_name: Option<String>,
|
||||
disabled: bool,
|
||||
},
|
||||
ExternalFile {
|
||||
kind: ContentSetDiffKind,
|
||||
file_name: String,
|
||||
disabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ContentSetDiffOptions {
|
||||
pub removed_disabled_project_ids: HashSet<String>,
|
||||
pub removed_disabled_external_files: HashSet<String>,
|
||||
pub common_external_files_are_updated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ContentSetSnapshot {
|
||||
pub versions: Vec<ContentSetSnapshotVersion>,
|
||||
pub external_files: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ContentSetSnapshotVersion {
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
pub version_name: String,
|
||||
}
|
||||
|
||||
impl From<Version> for ContentSetSnapshotVersion {
|
||||
fn from(version: Version) -> Self {
|
||||
Self {
|
||||
project_id: version.project_id,
|
||||
version_id: version.id,
|
||||
version_name: version.version_number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn diff_content_sets(
|
||||
current: &ContentSetSnapshot,
|
||||
latest: &ContentSetSnapshot,
|
||||
options: &ContentSetDiffOptions,
|
||||
) -> Vec<ContentSetDiffEntry> {
|
||||
let current_versions = versions_by_project(current);
|
||||
let latest_versions = versions_by_project(latest);
|
||||
let project_ids = current_versions
|
||||
.keys()
|
||||
.chain(latest_versions.keys())
|
||||
.copied()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
for project_id in project_ids {
|
||||
let current = current_versions.get(project_id);
|
||||
let latest = latest_versions.get(project_id);
|
||||
|
||||
match (current, latest) {
|
||||
(None, Some(latest)) => {
|
||||
diffs.push(ContentSetDiffEntry::Project {
|
||||
kind: ContentSetDiffKind::Added,
|
||||
project_id: project_id.to_string(),
|
||||
current_version_name: None,
|
||||
new_version_name: Some(latest.version_name.clone()),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
(Some(current), None) => {
|
||||
diffs.push(ContentSetDiffEntry::Project {
|
||||
kind: ContentSetDiffKind::Removed,
|
||||
project_id: project_id.to_string(),
|
||||
current_version_name: Some(current.version_name.clone()),
|
||||
new_version_name: None,
|
||||
disabled: options
|
||||
.removed_disabled_project_ids
|
||||
.contains(project_id),
|
||||
});
|
||||
}
|
||||
(Some(current), Some(latest))
|
||||
if current.version_id != latest.version_id =>
|
||||
{
|
||||
diffs.push(ContentSetDiffEntry::Project {
|
||||
kind: ContentSetDiffKind::Updated,
|
||||
project_id: project_id.to_string(),
|
||||
current_version_name: Some(current.version_name.clone()),
|
||||
new_version_name: Some(latest.version_name.clone()),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for file_name in latest.external_files.difference(¤t.external_files) {
|
||||
diffs.push(ContentSetDiffEntry::ExternalFile {
|
||||
kind: ContentSetDiffKind::Added,
|
||||
file_name: file_name.clone(),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if options.common_external_files_are_updated {
|
||||
for file_name in
|
||||
latest.external_files.intersection(¤t.external_files)
|
||||
{
|
||||
diffs.push(ContentSetDiffEntry::ExternalFile {
|
||||
kind: ContentSetDiffKind::Updated,
|
||||
file_name: file_name.clone(),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for file_name in current.external_files.difference(&latest.external_files) {
|
||||
diffs.push(ContentSetDiffEntry::ExternalFile {
|
||||
kind: ContentSetDiffKind::Removed,
|
||||
file_name: file_name.clone(),
|
||||
disabled: options
|
||||
.removed_disabled_external_files
|
||||
.contains(file_name),
|
||||
});
|
||||
}
|
||||
|
||||
diffs
|
||||
}
|
||||
|
||||
fn versions_by_project(
|
||||
snapshot: &ContentSetSnapshot,
|
||||
) -> HashMap<&str, &ContentSetSnapshotVersion> {
|
||||
snapshot
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| (version.project_id.as_str(), version))
|
||||
.collect()
|
||||
}
|
||||
@@ -101,12 +101,27 @@ pub async fn edit_icon(
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path),
|
||||
icon_path: Some(icon_path.clone()),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(error) = super::shared::sync_shared_instance_icon(
|
||||
instance_id,
|
||||
icon_path.as_deref(),
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
error = %error,
|
||||
"Failed to sync shared instance icon"
|
||||
);
|
||||
}
|
||||
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
||||
use crate::event::{InstancePayloadType, LoadingBarType};
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{CacheBehaviour, CachedEntry, ProjectType, State};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, ContentSourceKind, ProjectType, State,
|
||||
};
|
||||
use crate::util::fetch;
|
||||
use modrinth_content_management::{
|
||||
ContentType, ResolutionPreferences, ResolveContentPlan,
|
||||
@@ -23,6 +25,7 @@ pub async fn update_all_projects(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<HashMap<String, String>> {
|
||||
let state = State::get().await?;
|
||||
ensure_instance_content_unlocked(instance_id, &state).await?;
|
||||
let instance = get_instance_display_info(instance_id, &state).await?;
|
||||
let loading_bar = init_loading(
|
||||
LoadingBarType::InstanceUpdate {
|
||||
@@ -51,13 +54,18 @@ pub async fn update_project(
|
||||
skip_send_event: Option<bool>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
let path = crate::state::instances::commands::update_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !skip_send_event.unwrap_or(false) {
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
}
|
||||
@@ -73,6 +81,7 @@ pub async fn add_project_from_version(
|
||||
dependent_on_version_id: Option<String>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
ensure_instance_content_unlocked(instance_id, &state).await?;
|
||||
let project_path =
|
||||
crate::state::instances::commands::add_project_from_version(
|
||||
instance_id,
|
||||
@@ -97,6 +106,7 @@ pub async fn install_project_with_dependencies(
|
||||
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
ensure_metadata_content_unlocked(&metadata)?;
|
||||
let plan = crate::state::instances::commands::resolve_install_plan(
|
||||
instance_id,
|
||||
crate::state::instances::commands::InstanceInstallProjectRequest {
|
||||
@@ -181,6 +191,12 @@ pub async fn switch_project_version_with_dependencies(
|
||||
version_id: &str,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
@@ -204,13 +220,18 @@ pub async fn add_project_from_path(
|
||||
project_type: Option<ProjectType>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::add_project_from_path(
|
||||
instance_id,
|
||||
path,
|
||||
project_type,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
ensure_instance_content_unlocked(instance_id, &state).await?;
|
||||
let project_path =
|
||||
crate::state::instances::commands::add_project_from_path(
|
||||
instance_id,
|
||||
path,
|
||||
project_type,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
@@ -235,6 +256,8 @@ pub async fn toggle_disable_project(
|
||||
desired_enabled: Option<bool>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(instance_id, project, &state)
|
||||
.await?;
|
||||
let res = crate::state::instances::commands::toggle_disable_project(
|
||||
instance_id,
|
||||
project,
|
||||
@@ -253,6 +276,8 @@ pub async fn remove_project(
|
||||
project: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(instance_id, project, &state)
|
||||
.await?;
|
||||
crate::state::instances::commands::remove_project(
|
||||
instance_id,
|
||||
project,
|
||||
@@ -264,6 +289,45 @@ pub async fn remove_project(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_shared_instance_can_modify_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> 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())
|
||||
})?;
|
||||
ensure_metadata_content_unlocked(&metadata)?;
|
||||
if !metadata
|
||||
.shared_instance
|
||||
.is_some_and(|attachment| attachment.role.is_member())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source_kind =
|
||||
crate::state::instances::commands::content_source_kind_for_project_path(
|
||||
instance_id,
|
||||
project_path,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
if source_kind.is_some_and(ContentSourceKind::is_shared_instance_managed) {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Shared instance managed content cannot be changed directly."
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_managed_modrinth_version(
|
||||
instance_id: &str,
|
||||
@@ -278,6 +342,7 @@ pub async fn update_managed_modrinth_version(
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
ensure_metadata_content_unlocked(&metadata)?;
|
||||
|
||||
let post_install_edit = match &metadata.link {
|
||||
crate::state::InstanceLink::ServerProjectModpack {
|
||||
@@ -335,6 +400,7 @@ pub async fn repair_managed_modrinth(
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
ensure_metadata_content_unlocked(&metadata)?;
|
||||
|
||||
let post_install_edit = match &metadata.link {
|
||||
crate::state::InstanceLink::ServerProjectModpack { .. } => {
|
||||
@@ -381,6 +447,33 @@ fn unmanaged_pack_error(instance_id: &str) -> crate::ErrorKind {
|
||||
))
|
||||
}
|
||||
|
||||
async fn ensure_instance_content_unlocked(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
if instance_rows::is_instance_quarantined(instance_id, &state.pool).await? {
|
||||
return Err(quarantined_content_error().into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_metadata_content_unlocked(
|
||||
metadata: &crate::state::InstanceMetadata,
|
||||
) -> crate::Result<()> {
|
||||
if metadata.quarantined {
|
||||
return Err(quarantined_content_error().into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quarantined_content_error() -> crate::ErrorKind {
|
||||
crate::ErrorKind::InputError(
|
||||
"Content in quarantined instances cannot be changed.".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_instance_display_info(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
|
||||
@@ -24,6 +24,23 @@ pub async fn run(
|
||||
quick_play_type: QuickPlayType,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let state = State::get().await?;
|
||||
if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"This instance has been quarantined".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
super::shared::check_shared_instance_availability_before_launch(
|
||||
instance_id,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let default_account = Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?;
|
||||
@@ -50,6 +67,17 @@ async fn run_credentials(
|
||||
"Tried to run a nonexistent instance {instance_id}!"
|
||||
))
|
||||
})?;
|
||||
if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"This instance has been quarantined".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let pre_launch_hooks = context
|
||||
.launch_overrides
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
||||
use super::client::*;
|
||||
use super::publish::*;
|
||||
use super::types::*;
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn shared_instance_update_diffs(
|
||||
metadata: &crate::state::InstanceMetadata,
|
||||
version: &InstanceVersionResponse,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
|
||||
let remote_modpack_id =
|
||||
version.modpack_id.as_deref().filter(|id| !id.is_empty());
|
||||
let current_modpack_id = shared_modpack_id(&metadata.link);
|
||||
let modpack_unlinked =
|
||||
current_modpack_id.is_some() && remote_modpack_id.is_none();
|
||||
let (current_version_ids, current_external_files) =
|
||||
current_shared_content(metadata, modpack_unlinked, state).await?;
|
||||
let (latest_version_ids, latest_external_files) =
|
||||
remote_shared_content(version);
|
||||
let removed_disabled_project_ids = HashSet::new();
|
||||
let removed_disabled_external_files = HashSet::new();
|
||||
let mut diffs = shared_content_diffs(
|
||||
¤t_version_ids,
|
||||
¤t_external_files,
|
||||
&latest_version_ids,
|
||||
&latest_external_files,
|
||||
&removed_disabled_project_ids,
|
||||
&removed_disabled_external_files,
|
||||
true,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let mut configuration_diffs = shared_instance_configuration_diffs(
|
||||
current_modpack_id.as_deref(),
|
||||
remote_modpack_id,
|
||||
&metadata.applied_content_set.game_version,
|
||||
&version.game_version,
|
||||
metadata.applied_content_set.loader,
|
||||
version.loader,
|
||||
metadata.applied_content_set.loader_version.as_deref(),
|
||||
Some(version.loader_version.as_str()),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
configuration_diffs.append(&mut diffs);
|
||||
|
||||
Ok(configuration_diffs)
|
||||
}
|
||||
|
||||
pub(super) async fn shared_instance_publish_diffs(
|
||||
metadata: &crate::state::InstanceMetadata,
|
||||
version: &InstanceVersionResponse,
|
||||
snapshot: &CurrentPublishSnapshot,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
|
||||
let remote_modpack_id =
|
||||
version.modpack_id.as_deref().filter(|id| !id.is_empty());
|
||||
let current_modpack_id = shared_modpack_id(&metadata.link);
|
||||
let modpack_unlinked =
|
||||
remote_modpack_id.is_some() && current_modpack_id.is_none();
|
||||
let disabled_versions = async {
|
||||
if snapshot.disabled_version_ids.is_empty() {
|
||||
Ok(HashMap::new())
|
||||
} else {
|
||||
shared_versions_by_project(&snapshot.disabled_version_ids, state)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let ((latest_version_ids, latest_external_files), disabled_versions) = tokio::try_join!(
|
||||
remote_publish_content(version, modpack_unlinked, state),
|
||||
disabled_versions,
|
||||
)?;
|
||||
let current_external_files = snapshot
|
||||
.external_files
|
||||
.iter()
|
||||
.map(|file| file.file_name.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let current_version_ids = snapshot
|
||||
.version_ids
|
||||
.iter()
|
||||
.filter(|id| current_modpack_id.as_deref() != Some(id.as_str()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut removed_disabled_project_ids =
|
||||
snapshot.disabled_project_ids.clone();
|
||||
removed_disabled_project_ids.extend(disabled_versions.into_keys());
|
||||
let mut diffs = shared_content_diffs(
|
||||
&latest_version_ids,
|
||||
&latest_external_files,
|
||||
¤t_version_ids,
|
||||
¤t_external_files,
|
||||
&removed_disabled_project_ids,
|
||||
&snapshot.disabled_external_files,
|
||||
false,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let mut configuration_diffs = shared_instance_configuration_diffs(
|
||||
remote_modpack_id,
|
||||
current_modpack_id.as_deref(),
|
||||
&version.game_version,
|
||||
&metadata.applied_content_set.game_version,
|
||||
version.loader,
|
||||
metadata.applied_content_set.loader,
|
||||
Some(version.loader_version.as_str()),
|
||||
metadata.applied_content_set.loader_version.as_deref(),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
configuration_diffs.append(&mut diffs);
|
||||
|
||||
Ok(configuration_diffs)
|
||||
}
|
||||
|
||||
pub(super) async fn shared_instance_configuration_diffs(
|
||||
current_modpack_id: Option<&str>,
|
||||
new_modpack_id: Option<&str>,
|
||||
current_game_version: &str,
|
||||
new_game_version: &str,
|
||||
current_loader: ModLoader,
|
||||
new_loader: ModLoader,
|
||||
current_loader_version: Option<&str>,
|
||||
new_loader_version: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
|
||||
if current_modpack_id != new_modpack_id {
|
||||
match (current_modpack_id, new_modpack_id) {
|
||||
(None, Some(_)) => diffs.push(configuration_diff(
|
||||
SharedInstanceUpdateDiffType::ModpackLinked,
|
||||
None,
|
||||
shared_modpack_version_label(new_modpack_id, state).await,
|
||||
)),
|
||||
(Some(_), None) => diffs.push(configuration_diff(
|
||||
SharedInstanceUpdateDiffType::ModpackUnlinked,
|
||||
shared_modpack_version_label(current_modpack_id, state).await,
|
||||
None,
|
||||
)),
|
||||
(Some(current_modpack_id), Some(new_modpack_id)) => {
|
||||
let current =
|
||||
shared_modpack_version_details(current_modpack_id, state)
|
||||
.await;
|
||||
let new =
|
||||
shared_modpack_version_details(new_modpack_id, state).await;
|
||||
let project_name = new
|
||||
.as_ref()
|
||||
.and_then(|details| details.project_name.clone())
|
||||
.or_else(|| {
|
||||
current
|
||||
.as_ref()
|
||||
.and_then(|details| details.project_name.clone())
|
||||
});
|
||||
|
||||
diffs.push(SharedInstanceUpdateDiff {
|
||||
type_: SharedInstanceUpdateDiffType::ModpackUpdated,
|
||||
project_id: None,
|
||||
project_name,
|
||||
file_name: None,
|
||||
current_version_name: current
|
||||
.map(|details| details.version_name),
|
||||
new_version_name: new.map(|details| details.version_name),
|
||||
config_file_count: None,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
(None, None) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
if current_game_version != new_game_version {
|
||||
diffs.push(configuration_diff(
|
||||
SharedInstanceUpdateDiffType::GameVersionUpdated,
|
||||
Some(current_game_version.to_string()),
|
||||
Some(new_game_version.to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let current_loader_version =
|
||||
normalized_loader_version(current_loader_version);
|
||||
let new_loader_version = normalized_loader_version(new_loader_version);
|
||||
if current_loader != new_loader
|
||||
|| current_loader_version != new_loader_version
|
||||
{
|
||||
diffs.push(configuration_diff(
|
||||
SharedInstanceUpdateDiffType::LoaderUpdated,
|
||||
Some(shared_loader_label(current_loader, current_loader_version)),
|
||||
Some(shared_loader_label(new_loader, new_loader_version)),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
pub(super) fn configuration_diff(
|
||||
type_: SharedInstanceUpdateDiffType,
|
||||
current_version_name: Option<String>,
|
||||
new_version_name: Option<String>,
|
||||
) -> SharedInstanceUpdateDiff {
|
||||
SharedInstanceUpdateDiff {
|
||||
type_,
|
||||
project_id: None,
|
||||
project_name: None,
|
||||
file_name: None,
|
||||
current_version_name,
|
||||
new_version_name,
|
||||
config_file_count: None,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn shared_modpack_version_label(
|
||||
version_id: Option<&str>,
|
||||
state: &State,
|
||||
) -> Option<String> {
|
||||
let version_id = version_id?;
|
||||
let details = shared_modpack_version_details(version_id, state).await?;
|
||||
|
||||
Some(match details.project_name {
|
||||
Some(project_name) => {
|
||||
format!("{project_name} {}", details.version_name)
|
||||
}
|
||||
None => details.version_name,
|
||||
})
|
||||
}
|
||||
|
||||
struct SharedModpackVersionDetails {
|
||||
project_name: Option<String>,
|
||||
version_name: String,
|
||||
}
|
||||
|
||||
async fn shared_modpack_version_details(
|
||||
version_id: &str,
|
||||
state: &State,
|
||||
) -> Option<SharedModpackVersionDetails> {
|
||||
let Some(version) = CachedEntry::get_version(
|
||||
version_id,
|
||||
Some(CacheBehaviour::Bypass),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten() else {
|
||||
return Some(SharedModpackVersionDetails {
|
||||
project_name: None,
|
||||
version_name: version_id.to_string(),
|
||||
});
|
||||
};
|
||||
let project = CachedEntry::get_project(
|
||||
&version.project_id,
|
||||
Some(CacheBehaviour::Bypass),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
Some(SharedModpackVersionDetails {
|
||||
project_name: Some(
|
||||
project.map(|project| project.title).unwrap_or(version.name),
|
||||
),
|
||||
version_name: version.version_number,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn normalized_loader_version(
|
||||
loader_version: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
loader_version.filter(|version| !version.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn shared_loader_label(
|
||||
loader: ModLoader,
|
||||
loader_version: Option<&str>,
|
||||
) -> String {
|
||||
let loader_name = match loader {
|
||||
ModLoader::Vanilla => "Vanilla",
|
||||
ModLoader::Forge => "Forge",
|
||||
ModLoader::Fabric => "Fabric",
|
||||
ModLoader::Quilt => "Quilt",
|
||||
ModLoader::NeoForge => "NeoForge",
|
||||
};
|
||||
|
||||
match loader_version {
|
||||
Some(version) => format!("{loader_name} {version}"),
|
||||
None => loader_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn shared_content_diffs(
|
||||
current_version_ids: &[String],
|
||||
current_external_files: &HashSet<String>,
|
||||
latest_version_ids: &[String],
|
||||
latest_external_files: &HashSet<String>,
|
||||
removed_disabled_project_ids: &HashSet<String>,
|
||||
removed_disabled_external_files: &HashSet<String>,
|
||||
common_external_files_are_updated: bool,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
|
||||
let current = shared_content_snapshot(
|
||||
current_version_ids,
|
||||
current_external_files,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let latest = shared_content_snapshot(
|
||||
latest_version_ids,
|
||||
latest_external_files,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let options = ContentSetDiffOptions {
|
||||
removed_disabled_project_ids: removed_disabled_project_ids.clone(),
|
||||
removed_disabled_external_files: removed_disabled_external_files
|
||||
.clone(),
|
||||
common_external_files_are_updated,
|
||||
};
|
||||
let content_diffs = diff_content_sets(¤t, &latest, &options);
|
||||
let project_ids = content_diffs
|
||||
.iter()
|
||||
.filter_map(|diff| match diff {
|
||||
ContentSetDiffEntry::Project { project_id, .. } => {
|
||||
Some(project_id.clone())
|
||||
}
|
||||
ContentSetDiffEntry::ExternalFile { .. } => None,
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
let project_names = shared_project_names(&project_ids, state).await?;
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
for diff in content_diffs {
|
||||
match diff {
|
||||
ContentSetDiffEntry::Project {
|
||||
kind,
|
||||
project_id,
|
||||
current_version_name,
|
||||
new_version_name,
|
||||
disabled,
|
||||
} => {
|
||||
let project_name = Some(
|
||||
project_names
|
||||
.get(&project_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| project_id.clone()),
|
||||
);
|
||||
diffs.push(SharedInstanceUpdateDiff {
|
||||
type_: shared_update_diff_type(kind),
|
||||
project_id: Some(project_id),
|
||||
project_name,
|
||||
file_name: None,
|
||||
current_version_name,
|
||||
new_version_name,
|
||||
config_file_count: None,
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
ContentSetDiffEntry::ExternalFile {
|
||||
kind,
|
||||
file_name,
|
||||
disabled,
|
||||
} => {
|
||||
diffs.push(SharedInstanceUpdateDiff {
|
||||
type_: shared_update_diff_type(kind),
|
||||
project_id: None,
|
||||
project_name: None,
|
||||
file_name: Some(file_name),
|
||||
current_version_name: None,
|
||||
new_version_name: None,
|
||||
config_file_count: None,
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
diffs.sort_by(|a, b| {
|
||||
a.project_name
|
||||
.as_deref()
|
||||
.or(a.file_name.as_deref())
|
||||
.cmp(&b.project_name.as_deref().or(b.file_name.as_deref()))
|
||||
});
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
pub(super) fn shared_update_diff_type(
|
||||
kind: ContentSetDiffKind,
|
||||
) -> SharedInstanceUpdateDiffType {
|
||||
match kind {
|
||||
ContentSetDiffKind::Added => SharedInstanceUpdateDiffType::Added,
|
||||
ContentSetDiffKind::Removed => SharedInstanceUpdateDiffType::Removed,
|
||||
ContentSetDiffKind::Updated => SharedInstanceUpdateDiffType::Updated,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn shared_content_snapshot(
|
||||
version_ids: &[String],
|
||||
external_files: &HashSet<String>,
|
||||
state: &State,
|
||||
) -> crate::Result<ContentSetSnapshot> {
|
||||
let versions = shared_versions_by_project(version_ids, state)
|
||||
.await?
|
||||
.into_values()
|
||||
.map(ContentSetSnapshotVersion::from)
|
||||
.collect();
|
||||
|
||||
Ok(ContentSetSnapshot {
|
||||
versions,
|
||||
external_files: external_files.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn remote_shared_content(
|
||||
version: &InstanceVersionResponse,
|
||||
) -> (Vec<String>, HashSet<String>) {
|
||||
let mut version_ids = version.modrinth_ids.clone();
|
||||
if let Some(modpack_id) = version.modpack_id.as_deref() {
|
||||
version_ids.retain(|id| id != modpack_id);
|
||||
}
|
||||
dedupe_strings(&mut version_ids);
|
||||
|
||||
(
|
||||
version_ids,
|
||||
version
|
||||
.external_files
|
||||
.iter()
|
||||
.filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
|
||||
.map(|file| file.file_name.clone())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
use super::client::*;
|
||||
use super::diff::*;
|
||||
use super::publish::*;
|
||||
use super::types::*;
|
||||
use super::*;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn install_shared_instance(
|
||||
shared_instance_id: &str,
|
||||
name: String,
|
||||
manager_id: Option<String>,
|
||||
server_manager_name: Option<String>,
|
||||
server_manager_icon_url: Option<String>,
|
||||
instance_icon_url: Option<String>,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
let state = State::get().await?;
|
||||
let version = get_latest_remote_version(shared_instance_id, &state).await?;
|
||||
let data = shared_instance_install_data(
|
||||
shared_instance_id,
|
||||
manager_id,
|
||||
server_manager_name,
|
||||
server_manager_icon_url,
|
||||
instance_icon_url,
|
||||
name,
|
||||
version,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
crate::install::create_shared_instance(data).await
|
||||
}
|
||||
|
||||
pub(super) fn shared_instance_invite_install_name(
|
||||
shared_instance_id: &str,
|
||||
name: String,
|
||||
) -> String {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name == "Shared instance" {
|
||||
return format!("Shared instance {shared_instance_id}");
|
||||
}
|
||||
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_shared_instance_install_preview(
|
||||
shared_instance_id: &str,
|
||||
name: String,
|
||||
) -> crate::Result<SharedInstanceInstallPreview> {
|
||||
let state = State::get().await?;
|
||||
let version = get_latest_remote_version(shared_instance_id, &state).await?;
|
||||
shared_instance_install_preview_from_version(
|
||||
shared_instance_id.to_string(),
|
||||
name,
|
||||
version,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(invite_id))]
|
||||
pub async fn accept_shared_instance_invite_for_install(
|
||||
invite_id: &str,
|
||||
) -> crate::Result<SharedInstanceInviteInstallPreview> {
|
||||
let state = State::get().await?;
|
||||
let invite = get_shared_instance_invite_info(invite_id, &state).await?;
|
||||
let shared_instance_id = invite.instance_id;
|
||||
let instance_icon_url = invite.instance_icon;
|
||||
let (manager_id, server_manager_name, server_manager_icon_url) =
|
||||
shared_instance_invite_manager(invite.managers);
|
||||
let name = shared_instance_invite_install_name(
|
||||
&shared_instance_id,
|
||||
invite.instance_name,
|
||||
);
|
||||
accept_shared_instance_invite(&shared_instance_id, invite_id, &state)
|
||||
.await?;
|
||||
let version =
|
||||
get_latest_remote_version(&shared_instance_id, &state).await?;
|
||||
let mut preview = shared_instance_install_preview_from_version(
|
||||
shared_instance_id.clone(),
|
||||
name,
|
||||
version,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
if instance_icon_url.is_some() {
|
||||
preview.icon_url.clone_from(&instance_icon_url);
|
||||
}
|
||||
|
||||
Ok(SharedInstanceInviteInstallPreview {
|
||||
shared_instance_id,
|
||||
manager_id,
|
||||
server_manager_name,
|
||||
server_manager_icon_url,
|
||||
instance_icon_url,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn shared_instance_invite_manager(
|
||||
managers: Vec<InstanceInviteManagerResponse>,
|
||||
) -> (Option<String>, Option<String>, Option<String>) {
|
||||
match managers.into_iter().next() {
|
||||
Some(InstanceInviteManagerResponse::User { id }) => {
|
||||
(Some(id), None, None)
|
||||
}
|
||||
Some(InstanceInviteManagerResponse::Server { name, icon }) => {
|
||||
(None, Some(name), icon)
|
||||
}
|
||||
None => (None, None, None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn shared_instance_install_preview_from_version(
|
||||
shared_instance_id: String,
|
||||
name: String,
|
||||
version: InstanceVersionResponse,
|
||||
state: &State,
|
||||
) -> crate::Result<SharedInstanceInstallPreview> {
|
||||
let name = match name.trim() {
|
||||
"" => "Shared instance".to_string(),
|
||||
name => name.to_string(),
|
||||
};
|
||||
let mut content_version_ids = version.modrinth_ids.clone();
|
||||
let mut seen_content_version_ids = HashSet::new();
|
||||
content_version_ids
|
||||
.retain(|id| seen_content_version_ids.insert(id.clone()));
|
||||
let modpack = shared_instance_install_modpack(&version, state).await?;
|
||||
let modpack_dependency_count = modpack
|
||||
.as_ref()
|
||||
.map(|modpack| modpack.dependency_count)
|
||||
.unwrap_or_default();
|
||||
let modpack_version_id =
|
||||
modpack.as_ref().map(|modpack| modpack.version_id.clone());
|
||||
if let Some(modpack_version_id) = modpack_version_id.as_deref() {
|
||||
content_version_ids.retain(|id| id != modpack_version_id);
|
||||
}
|
||||
let icon_url = modpack
|
||||
.as_ref()
|
||||
.and_then(|modpack| modpack.icon_url.clone());
|
||||
|
||||
let external_files = version
|
||||
.external_files
|
||||
.iter()
|
||||
.filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
|
||||
.map(|file| SharedInstanceExternalFilePreview {
|
||||
file_name: file.file_name.clone(),
|
||||
file_type: file.file_type.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let external_file_count = external_files.len();
|
||||
|
||||
Ok(SharedInstanceInstallPreview {
|
||||
shared_instance_id,
|
||||
version: version.version,
|
||||
name,
|
||||
icon_url,
|
||||
game_version: version.game_version,
|
||||
loader: version.loader,
|
||||
mod_count: modpack_dependency_count
|
||||
+ content_version_ids.len()
|
||||
+ external_file_count,
|
||||
external_file_count,
|
||||
modpack_version_id,
|
||||
content_version_ids,
|
||||
external_files,
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_shared_instance_update_preview(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Option<SharedInstanceUpdatePreview>> {
|
||||
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);
|
||||
};
|
||||
if !attachment.role.is_member() {
|
||||
if let SharedInstanceRemoteResponse::Unavailable(reason) =
|
||||
get_remote_instance_access(&attachment.id, &state).await?
|
||||
{
|
||||
handle_unavailable_shared_instance_if_current_user(
|
||||
instance_id,
|
||||
&attachment,
|
||||
reason,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
return Err(shared_instance_unavailable_error(reason));
|
||||
}
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let version =
|
||||
get_latest_remote_member_version(instance_id, &attachment, &state)
|
||||
.await?;
|
||||
if !version.ready {
|
||||
return Ok(Some(SharedInstanceUpdatePreview {
|
||||
shared_instance_id: attachment.id,
|
||||
current_version: attachment.applied_version,
|
||||
latest_version: version.version,
|
||||
update_available: false,
|
||||
diffs: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
let update_available = attachment
|
||||
.applied_version
|
||||
.is_none_or(|current| current < version.version);
|
||||
let diffs = if update_available {
|
||||
shared_instance_update_diffs(&metadata, &version, &state).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Some(SharedInstanceUpdatePreview {
|
||||
shared_instance_id: attachment.id,
|
||||
current_version: attachment.applied_version,
|
||||
latest_version: version.version,
|
||||
update_available,
|
||||
diffs,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn check_shared_instance_availability_before_launch(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
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 else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let availability =
|
||||
match get_remote_instance_access(&attachment.id, state).await {
|
||||
Ok(availability) => availability,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.raw.as_ref(),
|
||||
crate::ErrorKind::NoCredentialsError
|
||||
| crate::ErrorKind::FetchError(_)
|
||||
) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
shared_instance_id = %attachment.id,
|
||||
error = %error,
|
||||
"Could not check shared instance availability before launch"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
if let SharedInstanceRemoteResponse::Unavailable(reason) = availability {
|
||||
handle_unavailable_shared_instance_if_current_user(
|
||||
instance_id,
|
||||
&attachment,
|
||||
reason,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
return Err(shared_instance_unavailable_error(reason));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_shared_instance(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
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 attachment = metadata.shared_instance.clone().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance is not attached to a shared instance".to_string(),
|
||||
)
|
||||
})?;
|
||||
if !attachment.role.is_member() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Only shared instance members can update from shared instances"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let version =
|
||||
get_latest_remote_member_version(instance_id, &attachment, &state)
|
||||
.await?;
|
||||
let data = shared_instance_install_data(
|
||||
&attachment.id,
|
||||
attachment.manager_id.clone(),
|
||||
attachment.server_manager_name.clone(),
|
||||
attachment.server_manager_icon_url.clone(),
|
||||
None,
|
||||
metadata.instance.name,
|
||||
version,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
crate::install::update_shared_instance(instance_id.to_string(), data).await
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_ready_remote_version_for_invite(
|
||||
instance_id: &str,
|
||||
attachment: &SharedInstanceAttachment,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
match get_latest_remote_version_optional_unavailable(&attachment.id, state)
|
||||
.await?
|
||||
{
|
||||
SharedInstanceRemoteResponse::Available(_) => Ok(()),
|
||||
SharedInstanceRemoteResponse::Unavailable(
|
||||
SharedInstanceUnavailableReason::Deleted,
|
||||
) => {
|
||||
tracing::info!(
|
||||
instance_id,
|
||||
shared_instance_id = %attachment.id,
|
||||
"Shared instance has no ready version before invite; publishing current content"
|
||||
);
|
||||
publish_shared_instance_inner(instance_id, &[], state).await
|
||||
}
|
||||
SharedInstanceRemoteResponse::Unavailable(reason) => {
|
||||
handle_unavailable_shared_instance_if_current_user(
|
||||
instance_id,
|
||||
attachment,
|
||||
reason,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
Err(shared_instance_unavailable_error(reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn get_latest_remote_member_version(
|
||||
instance_id: &str,
|
||||
attachment: &SharedInstanceAttachment,
|
||||
state: &State,
|
||||
) -> crate::Result<InstanceVersionResponse> {
|
||||
if let SharedInstanceRemoteResponse::Unavailable(reason) =
|
||||
get_remote_instance_access(&attachment.id, state).await?
|
||||
{
|
||||
if shared_attachment_matches_current_user(attachment, state).await? {
|
||||
handle_unavailable_shared_instance_if_current_user(
|
||||
instance_id,
|
||||
attachment,
|
||||
reason,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
return Err(shared_instance_unavailable_error(reason));
|
||||
}
|
||||
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"{}: {}",
|
||||
shared_instance_unavailable_message(reason),
|
||||
attachment.id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let version = match get_latest_remote_version_optional_unavailable(
|
||||
&attachment.id,
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SharedInstanceRemoteResponse::Available(version) => version,
|
||||
SharedInstanceRemoteResponse::Unavailable(reason) => {
|
||||
if shared_attachment_matches_current_user(attachment, state).await?
|
||||
{
|
||||
handle_unavailable_shared_instance_if_current_user(
|
||||
instance_id,
|
||||
attachment,
|
||||
reason,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
return Err(shared_instance_unavailable_error(reason));
|
||||
}
|
||||
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"{}: {}",
|
||||
shared_instance_unavailable_message(reason),
|
||||
attachment.id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
pub(super) async fn shared_attachment_matches_current_user(
|
||||
attachment: &SharedInstanceAttachment,
|
||||
state: &State,
|
||||
) -> crate::Result<bool> {
|
||||
let Some(linked_user_id) = attachment.linked_user_id.as_deref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(linked_modrinth_user_id(state)
|
||||
.await?
|
||||
.as_deref()
|
||||
.is_some_and(|current_user_id| current_user_id == linked_user_id))
|
||||
}
|
||||
|
||||
pub(super) async fn has_shared_instance_recipients(
|
||||
users: &SharedInstanceUsers,
|
||||
attachment: &SharedInstanceAttachment,
|
||||
has_pending_recipients: bool,
|
||||
state: &State,
|
||||
) -> crate::Result<bool> {
|
||||
if has_pending_recipients || users.tokens > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let current_user_id = linked_modrinth_user_id(state).await?;
|
||||
|
||||
Ok(users.user_ids.iter().any(|user_id| {
|
||||
Some(user_id.as_str()) != attachment.linked_user_id.as_deref()
|
||||
&& Some(user_id.as_str()) != attachment.manager_id.as_deref()
|
||||
&& Some(user_id.as_str()) != current_user_id.as_deref()
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_unavailable_shared_instance_if_current_user(
|
||||
instance_id: &str,
|
||||
attachment: &SharedInstanceAttachment,
|
||||
reason: SharedInstanceUnavailableReason,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
if reason != SharedInstanceUnavailableReason::Quarantined
|
||||
&& !shared_attachment_matches_current_user(attachment, state).await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match reason {
|
||||
SharedInstanceUnavailableReason::Quarantined => {
|
||||
crate::state::quarantine_shared_instance(instance_id, &state.pool)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
crate::state::clear_shared_instance(instance_id, &state.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn shared_instance_unavailable_error(
|
||||
reason: SharedInstanceUnavailableReason,
|
||||
) -> crate::Error {
|
||||
crate::ErrorKind::SharedInstanceUnavailable(reason).into()
|
||||
}
|
||||
|
||||
pub(super) fn shared_instance_unavailable_message(
|
||||
reason: SharedInstanceUnavailableReason,
|
||||
) -> &'static str {
|
||||
match reason {
|
||||
SharedInstanceUnavailableReason::Deleted => {
|
||||
"Shared instance was deleted"
|
||||
}
|
||||
SharedInstanceUnavailableReason::AccessRevoked => {
|
||||
"Shared instance access was revoked"
|
||||
}
|
||||
SharedInstanceUnavailableReason::Quarantined => {
|
||||
"Shared instance was quarantined"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_instance_external_file_data(
|
||||
file: ExternalFileResponse,
|
||||
) -> crate::Result<SharedInstanceExternalFileData> {
|
||||
let file_size = file.file_size.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Shared instance external file {} is missing its size",
|
||||
file.file_name
|
||||
))
|
||||
})?;
|
||||
let file_size = u64::try_from(file_size).map_err(|_| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Shared instance external file {} has an invalid size",
|
||||
file.file_name
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(SharedInstanceExternalFileData {
|
||||
file_name: file.file_name,
|
||||
file_type: file.file_type,
|
||||
url: file.url,
|
||||
file_size,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn shared_instance_install_data(
|
||||
shared_instance_id: &str,
|
||||
manager_id: Option<String>,
|
||||
server_manager_name: Option<String>,
|
||||
server_manager_icon_url: Option<String>,
|
||||
instance_icon_url: Option<String>,
|
||||
name: String,
|
||||
version: InstanceVersionResponse,
|
||||
state: &State,
|
||||
) -> crate::Result<SharedInstanceInstallData> {
|
||||
if !version.ready {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Shared instance version is not ready to install".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let name = shared_instance_name(name);
|
||||
let linked_user_id = linked_modrinth_user_id(state).await?;
|
||||
let modpack = shared_instance_install_modpack(&version, state).await?;
|
||||
let modpack_version_id =
|
||||
modpack.as_ref().map(|modpack| modpack.version_id.as_str());
|
||||
let modrinth_ids = version
|
||||
.modrinth_ids
|
||||
.into_iter()
|
||||
.filter(|id| Some(id.as_str()) != modpack_version_id)
|
||||
.collect();
|
||||
|
||||
Ok(SharedInstanceInstallData {
|
||||
shared_instance_id: shared_instance_id.to_string(),
|
||||
manager_id,
|
||||
server_manager_name,
|
||||
server_manager_icon_url,
|
||||
instance_icon_url,
|
||||
linked_user_id,
|
||||
name,
|
||||
version: version.version,
|
||||
modrinth_ids,
|
||||
external_files: version
|
||||
.external_files
|
||||
.into_iter()
|
||||
.map(shared_instance_external_file_data)
|
||||
.collect::<crate::Result<Vec<_>>>()?,
|
||||
modpack,
|
||||
game_version: version.game_version,
|
||||
loader: version.loader,
|
||||
loader_version: shared_instance_loader_version(version.loader_version),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn linked_modrinth_user_id(
|
||||
state: &State,
|
||||
) -> crate::Result<Option<String>> {
|
||||
Ok(
|
||||
ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
|
||||
.await?
|
||||
.map(|credentials| credentials.user_id),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
const SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS: i32 = 604800;
|
||||
const SHARED_INSTANCE_INVITE_MAX_USES: i32 = 10;
|
||||
|
||||
use super::client::*;
|
||||
use super::install::*;
|
||||
use super::publish::*;
|
||||
use super::types::*;
|
||||
use super::*;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_shared_instance_users(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<SharedInstanceUsers> {
|
||||
let state = State::get().await?;
|
||||
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
|
||||
return Ok(SharedInstanceUsers::empty());
|
||||
};
|
||||
|
||||
get_remote_users(&attachment.id, &state).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn invite_shared_instance_users(
|
||||
instance_id: &str,
|
||||
user_ids: Vec<String>,
|
||||
) -> crate::Result<SharedInstanceUsers> {
|
||||
let state = State::get().await?;
|
||||
let (metadata, attachment) =
|
||||
shared_instance_for_invites(instance_id, user_ids.len(), &state)
|
||||
.await?;
|
||||
|
||||
ensure_owner(&attachment)?;
|
||||
if !user_ids.is_empty() {
|
||||
ensure_ready_remote_version_for_invite(
|
||||
instance_id,
|
||||
&attachment,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
update_remote_instance(
|
||||
&attachment.id,
|
||||
shared_instance_name(metadata.instance.name.clone()),
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
instance_id,
|
||||
shared_instance_id = %attachment.id,
|
||||
user_count = user_ids.len(),
|
||||
"Adding users to shared instance"
|
||||
);
|
||||
add_remote_users(&attachment.id, user_ids.clone(), &state).await?;
|
||||
}
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
get_remote_users(&attachment.id, &state).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(replace_invite_id))]
|
||||
pub async fn create_shared_instance_invite_link(
|
||||
instance_id: &str,
|
||||
max_age_seconds: Option<i32>,
|
||||
max_uses: Option<i32>,
|
||||
replace_invite_id: Option<String>,
|
||||
) -> crate::Result<SharedInstanceInviteLink> {
|
||||
let state = State::get().await?;
|
||||
let (metadata, attachment) =
|
||||
shared_instance_for_invites(instance_id, 0, &state).await?;
|
||||
ensure_owner(&attachment)?;
|
||||
ensure_ready_remote_version_for_invite(instance_id, &attachment, &state)
|
||||
.await?;
|
||||
update_remote_instance(
|
||||
&attachment.id,
|
||||
shared_instance_name(metadata.instance.name),
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let max_age_seconds =
|
||||
max_age_seconds.unwrap_or(SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS);
|
||||
let max_uses = max_uses.unwrap_or(SHARED_INSTANCE_INVITE_MAX_USES);
|
||||
if max_age_seconds <= 0
|
||||
|| max_age_seconds > SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Invite expiry must be between now and seven days".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if max_uses <= 0 {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Invite max uses must be greater than zero".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if let Some(invite_id) = replace_invite_id {
|
||||
delete_remote_invite(&attachment.id, &invite_id, &state).await?;
|
||||
}
|
||||
|
||||
let created_at = Utc::now();
|
||||
|
||||
let response = request_json::<CreateInstanceInviteResponse>(
|
||||
"create_instance_invite",
|
||||
Method::POST,
|
||||
&format!("/instances/{}/invites", attachment.id),
|
||||
Some(json!({
|
||||
"max_age": max_age_seconds,
|
||||
"max_uses": max_uses,
|
||||
})),
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(SharedInstanceInviteLink {
|
||||
invite_id: response.id,
|
||||
expires_at: created_at
|
||||
+ chrono::Duration::seconds(i64::from(max_age_seconds)),
|
||||
max_uses,
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_shared_instance_users(
|
||||
instance_id: &str,
|
||||
user_ids: Vec<String>,
|
||||
has_pending_recipients: bool,
|
||||
) -> crate::Result<SharedInstanceUsers> {
|
||||
let state = State::get().await?;
|
||||
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
|
||||
return Ok(SharedInstanceUsers::empty());
|
||||
};
|
||||
ensure_owner(&attachment)?;
|
||||
|
||||
if !user_ids.is_empty() {
|
||||
remove_remote_users(&attachment.id, user_ids, &state).await?;
|
||||
}
|
||||
|
||||
let remaining_users = get_remote_users(&attachment.id, &state).await?;
|
||||
if !has_shared_instance_recipients(
|
||||
&remaining_users,
|
||||
&attachment,
|
||||
has_pending_recipients,
|
||||
&state,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
delete_remote_instance(&attachment.id, &state).await?;
|
||||
crate::state::clear_shared_instance(instance_id, &state.pool).await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
return Ok(SharedInstanceUsers::empty());
|
||||
}
|
||||
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(remaining_users)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn accept_pending_shared_instance_invite(
|
||||
shared_instance_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
if accept_pending_remote_invite(shared_instance_id, &state).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(crate::ErrorKind::InputError(
|
||||
"No pending invite found for shared instance".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn decline_pending_shared_instance_invite(
|
||||
shared_instance_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
decline_pending_remote_invite(shared_instance_id, &state).await
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use super::content_set_diff::{
|
||||
ContentSetDiffEntry, ContentSetDiffKind, ContentSetDiffOptions,
|
||||
ContentSetSnapshot, ContentSetSnapshotVersion, diff_content_sets,
|
||||
};
|
||||
use crate::SharedInstanceUnavailableReason;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::install::{
|
||||
InstallJobSnapshot, SharedInstanceExternalFileData,
|
||||
SharedInstanceInstallData,
|
||||
};
|
||||
use crate::state::instances::{InstanceLink, SharedInstanceAttachment};
|
||||
use crate::state::{
|
||||
AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSetSyncStatus,
|
||||
ContentSourceKind, EditInstance, ModLoader, ModrinthCredentials,
|
||||
ProjectType, SharedInstanceRole, State,
|
||||
};
|
||||
use crate::util::fetch::{INSECURE_REQWEST_CLIENT, REQWEST_CLIENT};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
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_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 CONFIG_FILE_EXTENSIONS: [&str; 13] = [
|
||||
"json",
|
||||
"json5",
|
||||
"jsonc",
|
||||
"yml",
|
||||
"yaml",
|
||||
"css",
|
||||
"toml",
|
||||
"txt",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"properties",
|
||||
"xml",
|
||||
];
|
||||
|
||||
pub(crate) fn read_bounded_config_bundle_entry(
|
||||
reader: impl Read,
|
||||
declared_size: u64,
|
||||
total_size: &mut u64,
|
||||
) -> crate::Result<Vec<u8>> {
|
||||
let remaining = MAX_CONFIG_BUNDLE_TOTAL_SIZE.saturating_sub(*total_size);
|
||||
let entry_limit = MAX_CONFIG_BUNDLE_FILE_SIZE.min(remaining);
|
||||
if declared_size > entry_limit {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Shared instance config bundle exceeds the uncompressed size limit"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let capacity = usize::try_from(declared_size).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Shared instance config bundle entry is too large".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut bytes = Vec::with_capacity(capacity);
|
||||
reader
|
||||
.take(entry_limit.saturating_add(1))
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Failed to read shared instance config bundle: {error}"
|
||||
))
|
||||
})?;
|
||||
let actual_size = bytes.len() as u64;
|
||||
if actual_size > entry_limit {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Shared instance config bundle exceeds the uncompressed size limit"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
*total_size = total_size.checked_add(actual_size).ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Shared instance config bundle size overflowed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
mod client;
|
||||
mod diff;
|
||||
mod install;
|
||||
mod invites;
|
||||
mod publish;
|
||||
mod types;
|
||||
|
||||
pub(crate) use self::install::check_shared_instance_availability_before_launch;
|
||||
pub(crate) use self::publish::sync_shared_instance_icon;
|
||||
|
||||
pub use self::install::{
|
||||
accept_shared_instance_invite_for_install,
|
||||
get_shared_instance_install_preview, get_shared_instance_update_preview,
|
||||
install_shared_instance, update_shared_instance,
|
||||
};
|
||||
pub use self::invites::{
|
||||
accept_pending_shared_instance_invite, create_shared_instance_invite_link,
|
||||
decline_pending_shared_instance_invite, get_shared_instance_users,
|
||||
invite_shared_instance_users, remove_shared_instance_users,
|
||||
};
|
||||
pub use self::publish::{
|
||||
get_shared_instance_publish_preview, publish_shared_instance,
|
||||
unlink_shared_instance, unpublish_shared_instance,
|
||||
};
|
||||
pub use self::types::{
|
||||
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
|
||||
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
|
||||
SharedInstanceJoinType, SharedInstancePublishPreview,
|
||||
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
|
||||
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
|
||||
};
|
||||
|
||||
pub async fn can_active_user_use_shared_instances() -> crate::Result<bool> {
|
||||
let state = State::get().await?;
|
||||
let Some(credentials) =
|
||||
ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
|
||||
.await?
|
||||
else {
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
let status =
|
||||
client::get_user_blacklist_status(&credentials.user_id, &state).await?;
|
||||
Ok(!status.blacklisted)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SharedInstanceUsers {
|
||||
pub user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub users: Vec<SharedInstanceUser>,
|
||||
#[serde(default)]
|
||||
pub tokens: i32,
|
||||
}
|
||||
|
||||
impl SharedInstanceUsers {
|
||||
pub(super) fn empty() -> Self {
|
||||
Self {
|
||||
user_ids: Vec::new(),
|
||||
users: Vec::new(),
|
||||
tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_users(
|
||||
users: Vec<SharedInstanceUser>,
|
||||
tokens: i32,
|
||||
) -> Self {
|
||||
let user_ids = users.iter().map(|user| user.id.clone()).collect();
|
||||
|
||||
Self {
|
||||
user_ids,
|
||||
users,
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_user_ids(user_ids: Vec<String>) -> Self {
|
||||
let users = user_ids
|
||||
.iter()
|
||||
.map(|user_id| SharedInstanceUser {
|
||||
id: user_id.clone(),
|
||||
joined_at: None,
|
||||
join_type: SharedInstanceJoinType::Invite,
|
||||
last_played: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
user_ids,
|
||||
users,
|
||||
tokens: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SharedInstanceUser {
|
||||
pub id: String,
|
||||
pub joined_at: Option<DateTime<Utc>>,
|
||||
pub join_type: SharedInstanceJoinType,
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SharedInstanceJoinType {
|
||||
Owner,
|
||||
Invite,
|
||||
Link,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceInstallPreview {
|
||||
pub shared_instance_id: String,
|
||||
pub version: i32,
|
||||
pub name: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
pub mod_count: usize,
|
||||
pub external_file_count: usize,
|
||||
pub modpack_version_id: Option<String>,
|
||||
pub content_version_ids: Vec<String>,
|
||||
pub external_files: Vec<SharedInstanceExternalFilePreview>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceExternalFilePreview {
|
||||
pub file_name: String,
|
||||
pub file_type: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceUpdatePreview {
|
||||
pub shared_instance_id: String,
|
||||
pub current_version: Option<i32>,
|
||||
pub latest_version: i32,
|
||||
pub update_available: bool,
|
||||
pub diffs: Vec<SharedInstanceUpdateDiff>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstancePublishPreview {
|
||||
pub shared_instance_id: String,
|
||||
pub latest_version: i32,
|
||||
pub diffs: Vec<SharedInstanceUpdateDiff>,
|
||||
pub config_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceInviteLink {
|
||||
pub invite_id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub max_uses: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceInviteInstallPreview {
|
||||
pub shared_instance_id: String,
|
||||
pub manager_id: Option<String>,
|
||||
pub server_manager_name: Option<String>,
|
||||
pub server_manager_icon_url: Option<String>,
|
||||
pub instance_icon_url: Option<String>,
|
||||
pub preview: SharedInstanceInstallPreview,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharedInstanceUpdateDiff {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: SharedInstanceUpdateDiffType,
|
||||
pub project_id: Option<String>,
|
||||
pub project_name: Option<String>,
|
||||
pub file_name: Option<String>,
|
||||
pub current_version_name: Option<String>,
|
||||
pub new_version_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_file_count: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SharedInstanceUpdateDiffType {
|
||||
Added,
|
||||
Removed,
|
||||
Updated,
|
||||
ModpackLinked,
|
||||
ModpackUpdated,
|
||||
ModpackUnlinked,
|
||||
GameVersionUpdated,
|
||||
LoaderUpdated,
|
||||
ConfigFilesUpdated,
|
||||
}
|
||||
|
||||
pub(super) fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
@@ -11,9 +11,11 @@ pub mod minecraft_skins;
|
||||
pub mod mr_auth;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
pub mod reports;
|
||||
pub mod server_address;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod users;
|
||||
pub mod worlds;
|
||||
|
||||
pub mod data {
|
||||
@@ -26,7 +28,8 @@ pub mod data {
|
||||
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
|
||||
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
|
||||
Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize,
|
||||
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
|
||||
Theme, User, UserFriend, Version, WindowSize,
|
||||
};
|
||||
pub use ariadne::users::UserStatus;
|
||||
pub use modrinth_content_management::{
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use crate::state::ModrinthCredentials;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ModrinthAuthFlow {
|
||||
SignIn,
|
||||
SignUp,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn authenticate_begin_flow() -> &'static str {
|
||||
crate::state::get_login_url()
|
||||
pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str {
|
||||
match flow {
|
||||
ModrinthAuthFlow::SignIn => crate::state::get_login_url(),
|
||||
ModrinthAuthFlow::SignUp => crate::state::get_signup_url(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
use crate::State;
|
||||
use crate::util::fetch::fetch_json;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReportItemType {
|
||||
Project,
|
||||
Version,
|
||||
User,
|
||||
SharedInstance,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CreateReportRequest {
|
||||
pub report_type: String,
|
||||
pub item_id: String,
|
||||
pub item_type: ReportItemType,
|
||||
pub body: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub uploaded_images: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CreateReportResponse {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(request))]
|
||||
pub async fn create_report(
|
||||
request: CreateReportRequest,
|
||||
) -> crate::Result<CreateReportResponse> {
|
||||
let state = State::get().await?;
|
||||
|
||||
fetch_json(
|
||||
Method::POST,
|
||||
&format!("{}report", env!("MODRINTH_API_URL_V3")),
|
||||
None,
|
||||
Some(serde_json::to_value(request)?),
|
||||
Some("/v3/report"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use crate::State;
|
||||
use crate::util::fetch::fetch_json;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SearchUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
|
||||
let state = State::get().await?;
|
||||
let query = urlencoding::encode(query);
|
||||
|
||||
fetch_json(
|
||||
Method::GET,
|
||||
&format!(
|
||||
"{}users/search?query={}",
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
query
|
||||
),
|
||||
None,
|
||||
None,
|
||||
Some("/v3/users/search"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Reference in New Issue
Block a user