mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 10:34:53 +00:00
feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
@@ -7,7 +7,257 @@ use super::model::{
|
||||
use super::store;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::State;
|
||||
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
|
||||
use crate::state::{
|
||||
ContentEntry, ContentSetRemoteRef, ContentSetRemoteRefType,
|
||||
ContentSetSyncProvider, ContentSetSyncState, InstanceFile,
|
||||
InstanceMetadata, State,
|
||||
};
|
||||
use async_walkdir::WalkDir;
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
const SHARED_INSTANCE_ROLLBACK_FILE: &str = "rollback.json";
|
||||
const SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR: &str = "instance";
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct SharedInstanceUpdateRollback {
|
||||
files: Vec<InstanceFile>,
|
||||
entries: Vec<ContentEntry>,
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_shared_instance_update_backup(
|
||||
job_id: Uuid,
|
||||
metadata: &InstanceMetadata,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let staging_dir = state
|
||||
.directories
|
||||
.metadata_dir()
|
||||
.join("install_job_backups")
|
||||
.join(job_id.to_string());
|
||||
if tokio::fs::try_exists(&staging_dir).await? {
|
||||
crate::util::io::remove_dir_all(&staging_dir).await?;
|
||||
}
|
||||
crate::util::io::create_dir_all(&staging_dir).await?;
|
||||
|
||||
let result = async {
|
||||
let files = content_rows::get_instance_files(
|
||||
&metadata.instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let entries = content_rows::get_content_entries(
|
||||
&metadata.applied_content_set.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let snapshot = SharedInstanceUpdateRollback { files, entries };
|
||||
let instance_path = state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path);
|
||||
copy_directory(
|
||||
&instance_path,
|
||||
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
crate::util::io::write(
|
||||
staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE),
|
||||
serde_json::to_vec(&snapshot)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = crate::util::io::remove_dir_all(&staging_dir).await;
|
||||
}
|
||||
result?;
|
||||
Ok(staging_dir)
|
||||
}
|
||||
|
||||
pub(super) async fn clear_staging_dir(job_state: &InstallJobState) {
|
||||
let Some(staging_dir) = &job_state.paths.staging_dir else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = crate::util::io::remove_dir_all(staging_dir).await
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(
|
||||
path = %staging_dir.display(),
|
||||
"Failed to remove install rollback backup: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_shared_instance_update(
|
||||
staging_dir: &Path,
|
||||
rollback: &super::model::InstallRollbackState,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let snapshot = serde_json::from_slice::<SharedInstanceUpdateRollback>(
|
||||
&crate::util::io::read(staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE))
|
||||
.await?,
|
||||
)?;
|
||||
let instance_path = state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&rollback.instance.instance.path);
|
||||
if tokio::fs::try_exists(&instance_path).await? {
|
||||
crate::util::io::remove_dir_all(&instance_path).await?;
|
||||
}
|
||||
copy_directory(
|
||||
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
|
||||
&instance_path,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
content_rows::restore_instance_content_snapshot(
|
||||
&rollback.instance.instance.id,
|
||||
&snapshot.files,
|
||||
&snapshot.entries,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
restore_instance_metadata(&rollback.instance, state).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_instance_metadata(
|
||||
metadata: &InstanceMetadata,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let content_set_id = metadata.applied_content_set.id.as_str();
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::update_instance(&metadata.instance, &mut tx).await?;
|
||||
content_rows::update_content_set(&metadata.applied_content_set, &mut tx)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_link(
|
||||
&metadata.instance.id,
|
||||
&metadata.link,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::set_shared_instance_attachment(
|
||||
&metadata.instance.id,
|
||||
metadata.shared_instance.as_ref(),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(
|
||||
&metadata.instance.id,
|
||||
&metadata.groups,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_launch_overrides(
|
||||
&metadata.launch_overrides,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::delete_content_set_remote_ref(
|
||||
content_set_id,
|
||||
ContentSetRemoteRefType::SharedContentSet,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::delete_content_set_sync_state(content_set_id, &mut tx)
|
||||
.await?;
|
||||
if let Some(attachment) = &metadata.shared_instance {
|
||||
content_rows::upsert_content_set_remote_ref(
|
||||
&ContentSetRemoteRef {
|
||||
content_set_id: content_set_id.to_string(),
|
||||
ref_type: ContentSetRemoteRefType::SharedContentSet,
|
||||
ref_id: attachment.id.clone(),
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::upsert_content_set_sync_state(
|
||||
&ContentSetSyncState {
|
||||
content_set_id: content_set_id.to_string(),
|
||||
provider: ContentSetSyncProvider::SharedInstance,
|
||||
applied_update_id: attachment
|
||||
.applied_version
|
||||
.map(|value| value.to_string()),
|
||||
latest_available_update_id: attachment
|
||||
.latest_version
|
||||
.map(|value| value.to_string()),
|
||||
checked_at: Some(Utc::now()),
|
||||
status: attachment.status,
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_directory(
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
crate::util::io::create_dir_all(target).await?;
|
||||
let mut walker = WalkDir::new(source);
|
||||
while let Some(entry) = walker.next().await {
|
||||
let entry = entry.map_err(|error| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Failed to read instance backup path: {error}"
|
||||
))
|
||||
})?;
|
||||
let entry_path = entry.path();
|
||||
let relative_path = entry_path.strip_prefix(source)?;
|
||||
let target_path = target.join(relative_path);
|
||||
let file_type = entry.file_type().await?;
|
||||
if file_type.is_dir() {
|
||||
crate::util::io::create_dir_all(&target_path).await?;
|
||||
} else if file_type.is_file() {
|
||||
crate::util::fetch::copy(
|
||||
&entry_path,
|
||||
&target_path,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
} else if file_type.is_symlink() {
|
||||
copy_symlink(&entry_path, &target_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
|
||||
if let Some(parent) = target.parent() {
|
||||
crate::util::io::create_dir_all(parent).await?;
|
||||
}
|
||||
let link_target = tokio::fs::read_link(source).await?;
|
||||
|
||||
#[cfg(unix)]
|
||||
tokio::fs::symlink(link_target, target).await?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let metadata = tokio::fs::metadata(source).await?;
|
||||
if metadata.is_dir() {
|
||||
tokio::fs::symlink_dir(link_target, target).await?;
|
||||
} else {
|
||||
tokio::fs::symlink_file(link_target, target).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
||||
let jobs = store::list_interrupted_candidates(state).await?;
|
||||
@@ -61,6 +311,9 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
if job.state.rollback_error.is_none() {
|
||||
clear_staging_dir(&job.state).await;
|
||||
}
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
|
||||
@@ -96,6 +349,15 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||
..
|
||||
} => None,
|
||||
},
|
||||
InstallRequest::CreateSharedInstance { data } => {
|
||||
Some(InstallJobDisplay {
|
||||
title: data.name.clone(),
|
||||
icon: data
|
||||
.modpack
|
||||
.as_ref()
|
||||
.and_then(|modpack| modpack.icon_url.clone()),
|
||||
})
|
||||
}
|
||||
InstallRequest::ImportInstance {
|
||||
instance_folder, ..
|
||||
} => Some(InstallJobDisplay {
|
||||
@@ -104,7 +366,8 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||
}),
|
||||
InstallRequest::DuplicateInstance { .. }
|
||||
| InstallRequest::InstallExistingInstance { .. }
|
||||
| InstallRequest::InstallPackToExistingInstance { .. } => {
|
||||
| InstallRequest::InstallPackToExistingInstance { .. }
|
||||
| InstallRequest::UpdateSharedInstance { .. } => {
|
||||
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
|
||||
title: rollback.instance.instance.name.clone(),
|
||||
icon: rollback.instance.instance.icon_path.clone(),
|
||||
@@ -128,12 +391,25 @@ pub async fn apply_cleanup(
|
||||
}
|
||||
InstallCleanup::RestoreExistingInstance { instance_id } => {
|
||||
if let Some(rollback) = &job_state.rollback {
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
instance_id,
|
||||
rollback.install_stage,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
if matches!(
|
||||
&job_state.request,
|
||||
InstallRequest::UpdateSharedInstance { .. }
|
||||
) && let Some(staging_dir) = &job_state.paths.staging_dir
|
||||
{
|
||||
restore_shared_instance_update(
|
||||
staging_dir,
|
||||
rollback,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
instance_id,
|
||||
rollback.install_stage,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user