From d5b4f87accf6ffc802e53ee8654fef186524b3e3 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 8 Jul 2026 17:46:30 +0100 Subject: [PATCH 1/7] feat: rough invite links impl temp --- apps/app-frontend/src/App.vue | 8 + apps/app-frontend/src/helpers/install.ts | 12 + apps/app-frontend/src/helpers/instance.ts | 11 + .../app-frontend/src/pages/instance/Share.vue | 32 ++ apps/app/build.rs | 2 + apps/app/src/api/install.rs | 15 + apps/app/src/api/instance.rs | 9 + apps/frontend/src/pages/share/[inviteId].vue | 30 ++ ...708120000_shared-instance-access-token.sql | 2 + packages/app-lib/src/api/handler.rs | 39 ++ packages/app-lib/src/api/instance.rs | 7 +- packages/app-lib/src/api/instance/shared.rs | 484 +++++++++++++++--- packages/app-lib/src/event/mod.rs | 4 + packages/app-lib/src/install/model.rs | 2 + packages/app-lib/src/install/runner.rs | 3 + .../adapters/sqlite/instance_rows.rs | 76 ++- .../instances/commands/shared_instance.rs | 2 + .../app-lib/src/state/instances/model/link.rs | 1 + .../sharing/invite-players-modal/index.vue | 3 - 19 files changed, 650 insertions(+), 92 deletions(-) create mode 100644 apps/frontend/src/pages/share/[inviteId].vue create mode 100644 packages/app-lib/migrations/20260708120000_shared-instance-access-token.sql diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index e45485dbd6..7eb1071ed7 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -100,6 +100,7 @@ import { install_get_modpack_preview, install_get_shared_instance_preview, install_shared_instance, + install_shared_instance_invite, } from '@/helpers/install' import { list, run } from '@/helpers/instance' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' @@ -1032,6 +1033,13 @@ async function handleCommand(e) { } else { await run(e.id).catch(handleError) } + } else if (e.event === 'InstallSharedInstanceInvite') { + await install_shared_instance_invite( + e.instance_id, + e.invite_id, + 'Shared instance', + ).catch(handleError) + queryClient.invalidateQueries({ queryKey: ['instances'] }) } else if (e.event === 'InstallServer') { await router.push(`/project/${e.id}`) await playServerProject(e.id).catch(handleError) diff --git a/apps/app-frontend/src/helpers/install.ts b/apps/app-frontend/src/helpers/install.ts index 7b8ceb8d35..668beb90f7 100644 --- a/apps/app-frontend/src/helpers/install.ts +++ b/apps/app-frontend/src/helpers/install.ts @@ -259,6 +259,18 @@ export async function install_shared_instance( }) } +export async function install_shared_instance_invite( + sharedInstanceId: string, + inviteId: string, + name: string, +) { + return await invoke('plugin:install|install_shared_instance_invite', { + sharedInstanceId, + inviteId, + name, + }) +} + export async function install_update_shared_instance(instanceId: string) { return await invoke('plugin:install|install_update_shared_instance', { instanceId, diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts index 45997ba8c7..188c309191 100644 --- a/apps/app-frontend/src/helpers/instance.ts +++ b/apps/app-frontend/src/helpers/instance.ts @@ -354,6 +354,11 @@ export interface SharedInstancePublishPreview { diffs: SharedInstanceUpdateDiff[] } +export interface SharedInstanceInviteLink { + sharedInstanceId: string + inviteId: string +} + export async function get_shared_instance_users(instanceId: string): Promise { return await invoke('plugin:instance|instance_share_get_users', { instanceId }) } @@ -365,6 +370,12 @@ export async function invite_shared_instance_users( return await invoke('plugin:instance|instance_share_invite_users', { instanceId, userIds }) } +export async function create_shared_instance_invite_link( + instanceId: string, +): Promise { + return await invoke('plugin:instance|instance_share_create_invite_link', { instanceId }) +} + export async function remove_shared_instance_users( instanceId: string, userIds: string[], diff --git a/apps/app-frontend/src/pages/instance/Share.vue b/apps/app-frontend/src/pages/instance/Share.vue index bca0aac177..8f005aad79 100644 --- a/apps/app-frontend/src/pages/instance/Share.vue +++ b/apps/app-frontend/src/pages/instance/Share.vue @@ -5,6 +5,7 @@ :header="inviteModalHeader" :friends="inviteFriends" :search-users="searchInviteUsers" + :link="inviteLink" :user-profile-link="userProfileLink" @invite="invitePlayer" @cancel="cancelInvite" @@ -357,6 +358,7 @@ import { openUrl } from '@tauri-apps/plugin-opener' import { computed, onUnmounted, ref, watch } from 'vue' import { get_user, get_user_many } from '@/helpers/cache.js' +import { config } from '@/config' import { friend_listener } from '@/helpers/events.js' import { add_friend, @@ -370,11 +372,13 @@ import { type SharedInstanceUnavailableReason, } from '@/helpers/install' import { + create_shared_instance_invite_link, edit, get_shared_instance_users, invite_shared_instance_users, list, remove_shared_instance_users, + type SharedInstanceInviteLink, type SharedInstanceUser, type SharedInstanceUsers, } from '@/helpers/instance' @@ -415,6 +419,8 @@ const sortDirection = ref('desc') const usernameRefs = ref>({}) const importedModpackUnlinkedForShare = ref(false) const pendingRemovalRow = ref(null) +const inviteLink = ref() +const inviteLinkPending = ref(false) const pendingRows = ref>(loadPendingRows(props.instance.id)) @@ -1042,6 +1048,7 @@ function showInvitePlayers(event?: MouseEvent) { } invitePlayersModal.value?.show(event) + void ensureInviteLink() } async function unlinkImportedModpackForShare() { @@ -1052,11 +1059,34 @@ async function unlinkImportedModpackForShare() { importedModpackUnlinkedForShare.value = true await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] }) invitePlayersModal.value?.show() + void ensureInviteLink() } catch (error) { handleError(toError(error)) } } +async function ensureInviteLink() { + if (inviteLink.value || inviteLinkPending.value) return + + inviteLinkPending.value = true + try { + const invite = await create_shared_instance_invite_link(props.instance.id) + inviteLink.value = buildInviteLink(invite) + } catch (error) { + handleError(toError(error)) + } finally { + inviteLinkPending.value = false + } +} + +function buildInviteLink(invite: SharedInstanceInviteLink) { + const params = new URLSearchParams({ + instance_id: invite.sharedInstanceId, + }) + + return `${config.siteUrl}/share/${encodeURIComponent(invite.inviteId)}?${params.toString()}` +} + function showRemoveRowModal(row: ShareRow) { if (props.sharedInstanceActionsLocked) return @@ -1285,6 +1315,8 @@ watch( () => props.instance.id, (instanceId) => { importedModpackUnlinkedForShare.value = false + inviteLink.value = undefined + inviteLinkPending.value = false pendingRows.value = loadPendingRows(instanceId) }, ) diff --git a/apps/app/build.rs b/apps/app/build.rs index 05144558b4..5fb6c4fdda 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -151,6 +151,7 @@ fn main() { "install_get_shared_instance_preview", "install_get_shared_instance_update_preview", "install_shared_instance", + "install_shared_instance_invite", "install_update_shared_instance", "install_import_instance", "install_duplicate_instance", @@ -215,6 +216,7 @@ fn main() { "instance_edit_icon", "instance_share_get_users", "instance_share_invite_users", + "instance_share_create_invite_link", "instance_share_remove_users", "instance_share_get_publish_preview", "instance_share_publish", diff --git a/apps/app/src/api/install.rs b/apps/app/src/api/install.rs index 2810c748e8..0787c2badc 100644 --- a/apps/app/src/api/install.rs +++ b/apps/app/src/api/install.rs @@ -22,6 +22,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { install_get_shared_instance_preview, install_get_shared_instance_update_preview, install_shared_instance, + install_shared_instance_invite, install_update_shared_instance, install_import_instance, install_duplicate_instance, @@ -143,6 +144,20 @@ pub async fn install_shared_instance( .await?) } +#[tauri::command] +pub async fn install_shared_instance_invite( + shared_instance_id: String, + invite_id: String, + name: String, +) -> Result { + Ok(theseus::instance::install_shared_instance_invite( + &shared_instance_id, + &invite_id, + name, + ) + .await?) +} + #[tauri::command] pub async fn install_update_shared_instance( instance_id: String, diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index 9e29e0048c..68f4e81212 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -53,6 +53,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_edit_icon, instance_share_get_users, instance_share_invite_users, + instance_share_create_invite_link, instance_share_remove_users, instance_share_get_publish_preview, instance_share_publish, @@ -789,6 +790,14 @@ pub async fn instance_share_invite_users( ) } +#[tauri::command] +pub async fn instance_share_create_invite_link( + instance_id: &str, +) -> Result { + Ok(theseus::instance::create_shared_instance_invite_link(instance_id) + .await?) +} + #[tauri::command] pub async fn instance_share_remove_users( instance_id: &str, diff --git a/apps/frontend/src/pages/share/[inviteId].vue b/apps/frontend/src/pages/share/[inviteId].vue new file mode 100644 index 0000000000..b25832ef58 --- /dev/null +++ b/apps/frontend/src/pages/share/[inviteId].vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/app-lib/migrations/20260708120000_shared-instance-access-token.sql b/packages/app-lib/migrations/20260708120000_shared-instance-access-token.sql new file mode 100644 index 0000000000..aa0ce78d70 --- /dev/null +++ b/packages/app-lib/migrations/20260708120000_shared-instance-access-token.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_links + ADD COLUMN shared_instance_access_token TEXT NULL; diff --git a/packages/app-lib/src/api/handler.rs b/packages/app-lib/src/api/handler.rs index 8a5351c031..794978a8e8 100644 --- a/packages/app-lib/src/api/handler.rs +++ b/packages/app-lib/src/api/handler.rs @@ -30,6 +30,45 @@ pub async fn handle_url(sublink: &str) -> crate::Result { Some(("server", id)) => { CommandPayload::InstallServer { id: id.to_string() } } + // /share/{invite_id}?instance_id={shared_instance_id} + Some(("share", raw)) => { + let (raw, query) = raw.split_once('?').unwrap_or((raw, "")); + let mut instance_id = None; + + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + if &*key == "instance_id" { + instance_id = Some(value.into_owned()); + } + } + + let Some(instance_id) = instance_id else { + emit_warning( + "Invalid command, shared instance invite is missing instance_id", + ) + .await?; + return Err(crate::ErrorKind::InputError( + "Shared instance invite is missing instance_id".to_string(), + ) + .into()); + }; + + match decode(raw) { + Ok(decoded) => CommandPayload::InstallSharedInstanceInvite { + invite_id: decoded.to_string(), + instance_id, + }, + 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/"); diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index 715eef755e..195946f0e4 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -38,14 +38,17 @@ pub use self::run::{ pub(crate) use self::shared::mark_shared_instance_stale; pub use self::shared::{ SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, + SharedInstanceInviteLink, SharedInstancePublishPreview, SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview, SharedInstanceJoinType, SharedInstanceUser, SharedInstanceUsers, accept_pending_shared_instance_invite, + 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, + install_shared_instance, install_shared_instance_invite, + invite_shared_instance_users, publish_shared_instance, + remove_shared_instance_users, unpublish_shared_instance, update_shared_instance, }; diff --git a/packages/app-lib/src/api/instance/shared.rs b/packages/app-lib/src/api/instance/shared.rs index 64745f9c55..4dc3a016e1 100644 --- a/packages/app-lib/src/api/instance/shared.rs +++ b/packages/app-lib/src/api/instance/shared.rs @@ -25,6 +25,25 @@ use std::collections::{HashMap, HashSet}; const SHARED_INSTANCE_DELETED_ERROR_CODE: &str = "shared_instance_deleted"; const SHARED_INSTANCE_ACCESS_REVOKED_ERROR_CODE: &str = "shared_instance_access_revoked"; +const SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS: i32 = 604800; +const SHARED_INSTANCE_INVITE_MAX_USES: i32 = 10; + +#[derive(Clone, Copy, Debug)] +enum SharedInstancesRequestAuth<'a> { + ModrinthSession, + Bearer(&'a str), + None, +} + +impl SharedInstancesRequestAuth<'_> { + fn label(self) -> &'static str { + match self { + Self::ModrinthSession => "modrinth_session", + Self::Bearer(_) => "bearer", + Self::None => "none", + } + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SharedInstanceUnavailableReason { @@ -173,6 +192,13 @@ pub struct SharedInstancePublishPreview { pub diffs: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceInviteLink { + pub shared_instance_id: String, + pub invite_id: String, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SharedInstanceUpdateDiff { @@ -215,6 +241,31 @@ struct CreateInstanceResponse { id: String, } +#[derive(Clone, Debug, Deserialize)] +struct CreateInstanceInviteResponse { + id: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct AcceptInstanceInviteResponse { + access_token: String, +} + +#[derive(Clone, Debug)] +struct AcceptedSharedInstanceInvite { + linked_user_id: Option, + access_token: Option, +} + +impl AcceptedSharedInstanceInvite { + fn request_auth(&self) -> SharedInstancesRequestAuth<'_> { + match self.access_token.as_deref() { + Some(access_token) => SharedInstancesRequestAuth::Bearer(access_token), + None => SharedInstancesRequestAuth::ModrinthSession, + } + } +} + #[derive(Clone, Debug, Deserialize)] struct InstanceVersionResponse { version: i32, @@ -281,68 +332,9 @@ pub async fn invite_shared_instance_users( user_ids: Vec, ) -> crate::Result { let state = State::get().await?; - let metadata = crate::state::get_instance(instance_id, &state.pool) - .await? - .ok_or_else(|| { - crate::ErrorKind::InputError("Unknown instance".to_string()) - })?; - let attachment = match metadata.shared_instance.clone() { - Some(attachment) => { - tracing::debug!( - instance_id, - shared_instance_id = %attachment.id, - role = attachment.role.as_str(), - user_count = user_ids.len(), - "Using existing shared instance attachment for invite" - ); - attachment - } - None => { - ensure_shareable_link(&metadata.link)?; - tracing::info!( - instance_id, - user_count = user_ids.len(), - "Creating shared instance before first invite" - ); - let remote = create_remote_instance( - shared_instance_name(metadata.instance.name.clone()), - &state, - ) - .await?; - let linked_user_id = linked_modrinth_user_id(&state).await?; - tracing::info!( - instance_id, - shared_instance_id = %remote.id, - "Created remote shared instance" - ); - crate::state::attach_shared_instance( - instance_id, - &remote.id, - SharedInstanceRole::Owner, - None, - linked_user_id, - ContentSetSyncStatus::Unknown, - None, - None, - &state.pool, - ) + let (metadata, attachment) = + shared_instance_for_invites(instance_id, user_ids.len(), &state) .await?; - tracing::debug!( - instance_id, - shared_instance_id = %remote.id, - "Attached local instance as shared instance owner" - ); - publish_shared_instance_inner(instance_id, &state).await?; - shared_attachment(instance_id, &state) - .await? - .ok_or_else(|| { - crate::ErrorKind::InputError( - "Shared instance attachment was not persisted" - .to_string(), - ) - })? - } - }; ensure_owner(&attachment)?; if !user_ids.is_empty() { @@ -369,6 +361,43 @@ pub async fn invite_shared_instance_users( Ok(users) } +#[tracing::instrument] +pub async fn create_shared_instance_invite_link( + instance_id: &str, +) -> crate::Result { + 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 response = request_json::( + "create_instance_invite", + Method::POST, + &format!("/instances/{}/invites", attachment.id), + Some(json!({ + "max_age": SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS, + "max_uses": SHARED_INSTANCE_INVITE_MAX_USES, + })), + &state, + ) + .await?; + + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(SharedInstanceInviteLink { + shared_instance_id: attachment.id, + invite_id: response.id, + }) +} + #[tracing::instrument] pub async fn remove_shared_instance_users( instance_id: &str, @@ -542,6 +571,49 @@ pub async fn install_shared_instance( crate::install::create_shared_instance(data).await } +#[tracing::instrument] +pub async fn install_shared_instance_invite( + shared_instance_id: &str, + invite_id: &str, + name: String, +) -> crate::Result { + let state = State::get().await?; + let name = shared_instance_invite_install_name(shared_instance_id, name); + let accepted = + accept_shared_instance_invite(shared_instance_id, invite_id, &state) + .await?; + let version = get_latest_remote_version_with_auth( + shared_instance_id, + &state, + accepted.request_auth(), + ) + .await?; + let mut data = shared_instance_install_data( + shared_instance_id, + None, + name, + version, + &state, + ) + .await?; + data.linked_user_id = accepted.linked_user_id; + data.access_token = accepted.access_token; + + crate::install::create_shared_instance(data).await +} + +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, @@ -670,7 +742,7 @@ pub async fn update_shared_instance( let version = get_latest_remote_member_version(instance_id, &attachment, &state) .await?; - let data = shared_instance_install_data( + let mut data = shared_instance_install_data( &attachment.id, attachment.manager_id.clone(), metadata.instance.name, @@ -678,6 +750,7 @@ pub async fn update_shared_instance( &state, ) .await?; + data.access_token = attachment.access_token.clone(); crate::install::update_shared_instance(instance_id.to_string(), data).await } @@ -742,15 +815,18 @@ async fn get_latest_remote_member_version( attachment: &SharedInstanceAttachment, state: &State, ) -> crate::Result { - let version = match get_latest_remote_version_optional_unavailable( + let version = match get_latest_remote_version_optional_unavailable_with_auth( &attachment.id, state, + auth_for_attachment(attachment), ) .await? { SharedInstanceRemoteResponse::Available(version) => version, SharedInstanceRemoteResponse::Unavailable(reason) => { - if shared_attachment_matches_current_user(attachment, state).await? + if attachment.access_token.is_some() + || shared_attachment_matches_current_user(attachment, state) + .await? { clear_shared_instance_if_current_user( instance_id, @@ -773,6 +849,15 @@ async fn get_latest_remote_member_version( Ok(version) } +fn auth_for_attachment( + attachment: &SharedInstanceAttachment, +) -> SharedInstancesRequestAuth<'_> { + match attachment.access_token.as_deref() { + Some(access_token) => SharedInstancesRequestAuth::Bearer(access_token), + None => SharedInstancesRequestAuth::ModrinthSession, + } +} + async fn shared_attachment_matches_current_user( attachment: &SharedInstanceAttachment, state: &State, @@ -810,7 +895,9 @@ async fn clear_shared_instance_if_current_user( attachment: &SharedInstanceAttachment, state: &State, ) -> crate::Result<()> { - if shared_attachment_matches_current_user(attachment, state).await? { + if attachment.access_token.is_some() + || shared_attachment_matches_current_user(attachment, state).await? + { crate::state::clear_shared_instance(instance_id, &state.pool).await?; emit_instance(instance_id, InstancePayloadType::Edited).await?; } @@ -866,6 +953,7 @@ async fn shared_instance_install_data( shared_instance_id: shared_instance_id.to_string(), manager_id, linked_user_id, + access_token: None, name, version: version.version, modrinth_ids, @@ -1264,12 +1352,12 @@ async fn current_shared_content( external_files.insert(file.file_name.clone()); } } - if include_linked_modpack_content { - if let Some(modpack_id) = shared_modpack_id(&metadata.link) { - version_ids.extend( - modpack_dependency_version_ids(&modpack_id, state).await?, - ); - } + if include_linked_modpack_content + && let Some(modpack_id) = shared_modpack_id(&metadata.link) + { + version_ids.extend( + modpack_dependency_version_ids(&modpack_id, state).await?, + ); } dedupe_strings(&mut version_ids); @@ -1638,6 +1726,7 @@ async fn publish_current_content( .unwrap_or_default(), })), state, + SharedInstancesRequestAuth::ModrinthSession, ) .await?; let response = match response { @@ -1821,6 +1910,81 @@ async fn shared_attachment( .and_then(|metadata| metadata.shared_instance)) } +async fn shared_instance_for_invites( + instance_id: &str, + user_count: usize, + state: &State, +) -> crate::Result<( + crate::state::InstanceMetadata, + SharedInstanceAttachment, +)> { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let attachment = match metadata.shared_instance.clone() { + Some(attachment) => { + tracing::debug!( + instance_id, + shared_instance_id = %attachment.id, + role = attachment.role.as_str(), + user_count, + "Using existing shared instance attachment for invite" + ); + attachment + } + None => { + ensure_shareable_link(&metadata.link)?; + tracing::info!( + instance_id, + user_count, + "Creating shared instance before first invite" + ); + let remote = create_remote_instance( + shared_instance_name(metadata.instance.name.clone()), + state, + ) + .await?; + let linked_user_id = linked_modrinth_user_id(state).await?; + tracing::info!( + instance_id, + shared_instance_id = %remote.id, + "Created remote shared instance" + ); + crate::state::attach_shared_instance( + instance_id, + &remote.id, + SharedInstanceRole::Owner, + None, + linked_user_id, + None, + ContentSetSyncStatus::Unknown, + None, + None, + &state.pool, + ) + .await?; + tracing::debug!( + instance_id, + shared_instance_id = %remote.id, + "Attached local instance as shared instance owner" + ); + publish_shared_instance_inner(instance_id, state).await?; + shared_attachment(instance_id, state) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Shared instance attachment was not persisted" + .to_string(), + ) + })? + } + }; + + Ok((metadata, attachment)) +} + fn ensure_owner(attachment: &SharedInstanceAttachment) -> crate::Result<()> { if attachment.role == SharedInstanceRole::Owner { return Ok(()); @@ -1953,9 +2117,41 @@ async fn get_latest_remote_version( } } +async fn get_latest_remote_version_with_auth( + shared_instance_id: &str, + state: &State, + auth: SharedInstancesRequestAuth<'_>, +) -> crate::Result { + match get_latest_remote_version_optional_unavailable_with_auth( + shared_instance_id, + state, + auth, + ) + .await? + { + SharedInstanceRemoteResponse::Available(version) => Ok(version), + SharedInstanceRemoteResponse::Unavailable(reason) => { + Err(shared_instance_unavailable_error(reason)) + } + } +} + async fn get_latest_remote_version_optional_unavailable( shared_instance_id: &str, state: &State, +) -> crate::Result> { + get_latest_remote_version_optional_unavailable_with_auth( + shared_instance_id, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await +} + +async fn get_latest_remote_version_optional_unavailable_with_auth( + shared_instance_id: &str, + state: &State, + auth: SharedInstancesRequestAuth<'_>, ) -> crate::Result> { request_json_optional_unavailable( "get_latest_instance_version", @@ -1963,6 +2159,7 @@ async fn get_latest_remote_version_optional_unavailable( &format!("/instances/{shared_instance_id}/versions"), None, state, + auth, ) .await } @@ -2018,6 +2215,87 @@ async fn accept_pending_remote_invite( } } +async fn accept_shared_instance_invite( + shared_instance_id: &str, + invite_id: &str, + state: &State, +) -> crate::Result { + let path = format!("/instances/{shared_instance_id}/invites/{invite_id}"); + let operation = "accept_instance_invite"; + let method = Method::POST; + + if let Some(credentials) = + ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) + .await? + { + let response = send_request_with_auth( + operation, + method.clone(), + &path, + None, + state, + SharedInstancesRequestAuth::Bearer(&credentials.session), + ) + .await?; + + if response.status().is_success() { + return Ok(AcceptedSharedInstanceInvite { + linked_user_id: Some(credentials.user_id), + access_token: None, + }); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if status == StatusCode::BAD_REQUEST + && body.contains("already has access") + { + return Ok(AcceptedSharedInstanceInvite { + linked_user_id: Some(credentials.user_id), + access_token: None, + }); + } + + tracing::warn!( + operation, + method = method.as_str(), + path, + status = status.as_u16(), + response_body = %body, + "Shared instances API request failed" + ); + return Err(crate::ErrorKind::OtherError(format!( + "Shared instances API request {operation} {method} {path} failed with status {status}: {body}" + )) + .into()); + } + + let response = send_request_with_auth( + operation, + method.clone(), + &path, + None, + state, + SharedInstancesRequestAuth::None, + ) + .await?; + if !response.status().is_success() { + return shared_instances_request_error( + operation, method, &path, response, + ) + .await; + } + + let response = + decode_json_response::(operation, &path, response) + .await?; + + Ok(AcceptedSharedInstanceInvite { + linked_user_id: None, + access_token: Some(response.access_token), + }) +} + async fn decline_pending_remote_invite( shared_instance_id: &str, state: &State, @@ -2052,16 +2330,19 @@ async fn request_json_optional_unavailable( path: &str, body: Option, state: &State, + auth: SharedInstancesRequestAuth<'_>, ) -> crate::Result> where T: DeserializeOwned, { let response = - send_request(operation, method.clone(), path, body, state).await?; + send_request_with_auth(operation, method.clone(), path, body, state, auth) + .await?; if let Some(reason) = SharedInstanceUnavailableReason::from_status(response.status()) { if reason == SharedInstanceUnavailableReason::AccessRevoked + && matches!(auth, SharedInstancesRequestAuth::ModrinthSession) && !active_modrinth_session_is_valid(state).await? { tracing::warn!( @@ -2188,25 +2469,68 @@ async fn send_request( body: Option, state: &State, ) -> crate::Result { - let credentials = - ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore) - .await? - .ok_or(crate::ErrorKind::NoCredentialsError)?; + send_request_with_auth( + operation, + method, + path, + body, + state, + SharedInstancesRequestAuth::ModrinthSession, + ) + .await +} + +async fn send_request_with_auth( + operation: &'static str, + method: Method, + path: &str, + body: Option, + state: &State, + auth: SharedInstancesRequestAuth<'_>, +) -> crate::Result { + let modrinth_credentials = + if matches!(auth, SharedInstancesRequestAuth::ModrinthSession) { + Some( + ModrinthCredentials::get_and_refresh( + &state.pool, + &state.api_semaphore, + ) + .await? + .ok_or(crate::ErrorKind::NoCredentialsError)?, + ) + } else { + None + }; let _permit = state.api_semaphore.0.acquire().await?; let base_url = service_base_url(); let url = service_url(base_url, path); + let mut request = + shared_instances_client(base_url).request(method.clone(), &url); + let mut user_id = None; + + match auth { + SharedInstancesRequestAuth::ModrinthSession => { + let credentials = modrinth_credentials + .expect("Modrinth session credentials were loaded"); + user_id = Some(credentials.user_id); + request = request.bearer_auth(credentials.session); + } + SharedInstancesRequestAuth::Bearer(token) => { + request = request.bearer_auth(token); + } + SharedInstancesRequestAuth::None => {} + } + tracing::debug!( operation, method = method.as_str(), path, url = %url, - user_id = %credentials.user_id, + user_id = user_id.as_deref(), + auth = auth.label(), has_body = body.is_some(), "Sending shared instances API request" ); - let mut request = shared_instances_client(base_url) - .request(method.clone(), &url) - .bearer_auth(credentials.session); if let Some(body) = body { request = request.json(&body); diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index e61dded89b..0cdfd72d62 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -234,6 +234,10 @@ pub enum CommandPayload { server: Option, singleplayer_world: Option, }, + InstallSharedInstanceInvite { + invite_id: String, + instance_id: String, + }, RunMRPack { // run or install .mrpack path: PathBuf, diff --git a/packages/app-lib/src/install/model.rs b/packages/app-lib/src/install/model.rs index 8b2ef9de07..f0f5c5c174 100644 --- a/packages/app-lib/src/install/model.rs +++ b/packages/app-lib/src/install/model.rs @@ -109,6 +109,8 @@ pub struct SharedInstanceInstallData { pub manager_id: Option, #[serde(default)] pub linked_user_id: Option, + #[serde(default)] + pub access_token: Option, pub name: String, pub version: i32, pub modrinth_ids: Vec, diff --git a/packages/app-lib/src/install/runner.rs b/packages/app-lib/src/install/runner.rs index 3aba17e59e..8b05ea6d6b 100644 --- a/packages/app-lib/src/install/runner.rs +++ b/packages/app-lib/src/install/runner.rs @@ -325,6 +325,7 @@ async fn prepare_initial_instance( SharedInstanceRole::Member, data.manager_id.clone(), data.linked_user_id.clone(), + data.access_token.clone(), ContentSetSyncStatus::NotReady, None, Some(data.version), @@ -572,6 +573,7 @@ async fn run_request( SharedInstanceRole::Member, data.manager_id.clone(), data.linked_user_id.clone(), + data.access_token.clone(), ContentSetSyncStatus::UpToDate, Some(data.version), Some(data.version), @@ -741,6 +743,7 @@ async fn run_request( SharedInstanceRole::Member, data.manager_id.clone(), data.linked_user_id.clone(), + data.access_token.clone(), ContentSetSyncStatus::UpToDate, Some(data.version), Some(data.version), diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index e3374cc9e4..7319dedb90 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -521,7 +521,13 @@ pub(crate) async fn get_instance_metadata_by_id( .fetch_optional(pool) .await?; - row.map(InstanceMetadataRow::into_record).transpose() + let Some(row) = row else { + return Ok(None); + }; + let record = hydrate_shared_instance_access_token(row.into_record()?, pool) + .await?; + + Ok(Some(record)) } pub(crate) async fn get_instance_metadata_many( @@ -614,9 +620,15 @@ pub(crate) async fn get_instance_metadata_many( .fetch_all(pool) .await?; - rows.into_iter() - .map(InstanceMetadataRow::into_record) - .collect() + let mut records = Vec::with_capacity(rows.len()); + for row in rows { + records.push( + hydrate_shared_instance_access_token(row.into_record()?, pool) + .await?, + ); + } + + Ok(records) } pub(crate) async fn list_instance_metadata( @@ -695,9 +707,15 @@ pub(crate) async fn list_instance_metadata( .fetch_all(pool) .await?; - rows.into_iter() - .map(InstanceMetadataRow::into_record) - .collect() + let mut records = Vec::with_capacity(rows.len()); + for row in rows { + records.push( + hydrate_shared_instance_access_token(row.into_record()?, pool) + .await?, + ); + } + + Ok(records) } pub(crate) async fn get_instance_launch_context( @@ -1080,6 +1098,8 @@ pub(crate) async fn set_shared_instance_attachment( attachment.and_then(|value| value.manager_id.as_deref()); let shared_instance_linked_user_id = attachment.and_then(|value| value.linked_user_id.as_deref()); + let shared_instance_access_token = + attachment.and_then(|value| value.access_token.as_deref()); sqlx::query!( " @@ -1113,6 +1133,18 @@ pub(crate) async fn set_shared_instance_attachment( .execute(&mut **tx) .await?; + sqlx::query( + " + UPDATE instance_links + SET shared_instance_access_token = ? + WHERE instance_id = ? + ", + ) + .bind(shared_instance_access_token) + .bind(instance_id) + .execute(&mut **tx) + .await?; + Ok(()) } @@ -1373,6 +1405,35 @@ fn launch_overrides_from_json( } } +async fn hydrate_shared_instance_access_token( + mut record: InstanceMetadataRecord, + pool: &SqlitePool, +) -> crate::Result { + if let Some(attachment) = record.shared_instance.as_mut() { + attachment.access_token = + shared_instance_access_token(&record.instance.id, pool).await?; + } + + Ok(record) +} + +async fn shared_instance_access_token( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_scalar::<_, Option>( + " + SELECT shared_instance_access_token + FROM instance_links + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .fetch_optional(pool) + .await? + .flatten()) +} + fn shared_instance_attachment( shared_instance_id: Option, shared_instance_role: Option, @@ -1400,6 +1461,7 @@ fn shared_instance_attachment( role, manager_id: shared_instance_manager_id, linked_user_id: shared_instance_linked_user_id, + access_token: None, status, applied_version: optional_i32(applied_update_id, "applied_update_id")?, latest_version: optional_i32( diff --git a/packages/app-lib/src/state/instances/commands/shared_instance.rs b/packages/app-lib/src/state/instances/commands/shared_instance.rs index 61af543b93..a7f0e0d5b0 100644 --- a/packages/app-lib/src/state/instances/commands/shared_instance.rs +++ b/packages/app-lib/src/state/instances/commands/shared_instance.rs @@ -13,6 +13,7 @@ pub(crate) async fn attach_shared_instance( role: SharedInstanceRole, manager_id: Option, linked_user_id: Option, + access_token: Option, status: ContentSetSyncStatus, applied_version: Option, latest_version: Option, @@ -30,6 +31,7 @@ pub(crate) async fn attach_shared_instance( role, manager_id, linked_user_id, + access_token, status, applied_version, latest_version, diff --git a/packages/app-lib/src/state/instances/model/link.rs b/packages/app-lib/src/state/instances/model/link.rs index 07d5cb5b88..92865fd3dd 100644 --- a/packages/app-lib/src/state/instances/model/link.rs +++ b/packages/app-lib/src/state/instances/model/link.rs @@ -70,6 +70,7 @@ pub struct SharedInstanceAttachment { pub role: SharedInstanceRole, pub manager_id: Option, pub linked_user_id: Option, + pub access_token: Option, pub status: ContentSetSyncStatus, pub applied_version: Option, pub latest_version: Option, diff --git a/packages/ui/src/components/sharing/invite-players-modal/index.vue b/packages/ui/src/components/sharing/invite-players-modal/index.vue index 059cf09e06..0f884a8005 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/index.vue +++ b/packages/ui/src/components/sharing/invite-players-modal/index.vue @@ -110,9 +110,6 @@