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:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+21 -1
View File
@@ -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
+23
View File
@@ -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(&current.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(&current.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()
}
+16 -1
View File
@@ -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(())
+102 -9
View File
@@ -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,
+28
View File
@@ -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(
&current_version_ids,
&current_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,
&current_version_ids,
&current_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(&current, &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
}
+4 -1
View File
@@ -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::{
+13 -2
View File
@@ -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]
+46
View File
@@ -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
}
+32
View File
@@ -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
}