From bb7f8d0e0fd80a1e13bef75d2fae066d1829ddac Mon Sep 17 00:00:00 2001 From: seliayeu Date: Sun, 21 Jul 2024 07:01:21 -0600 Subject: [PATCH 1/3] finish functional implementation --- .../Instance/InstanceCreateForm.tsx | 90 +++++++++++++++---- dashboard/src/utils/apis.ts | 55 ++++++++++-- 2 files changed, 121 insertions(+), 24 deletions(-) diff --git a/dashboard/src/components/Instance/InstanceCreateForm.tsx b/dashboard/src/components/Instance/InstanceCreateForm.tsx index 73b34ade..d838cedf 100644 --- a/dashboard/src/components/Instance/InstanceCreateForm.tsx +++ b/dashboard/src/components/Instance/InstanceCreateForm.tsx @@ -1,9 +1,9 @@ import Button from 'components/Atoms/Button'; import { Form, Formik, FormikHelpers, FormikProps } from 'formik'; -import { useRef, useState, useEffect, useMemo } from 'react'; +import { useRef, useState, useEffect, useMemo, useContext } from 'react'; import { useEffectOnce } from 'usehooks-ts'; import useAnalyticsEventTracker from 'utils/hooks'; -import { axiosWrapper, catchAsyncToString } from 'utils/util'; +import { axiosWrapper, catchAsyncToString, chooseFile, chooseFiles } from 'utils/util'; import { autoSettingPageObject, basicSettingsPageObject, @@ -27,6 +27,10 @@ import { SetupManifest } from 'bindings/SetupManifest'; import { SetupValue } from 'bindings/SetupValue'; import { SectionManifestValue } from 'bindings/SectionManifestValue'; import { toast } from 'react-toastify'; +import { faUpload } from '@fortawesome/free-solid-svg-icons'; +import { Base64 } from 'js-base64'; +import { getTmpPath, uploadFile } from 'utils/apis'; +import { NotificationContext } from 'data/NotificationContext'; export type GenericHandlerGameType = 'Generic' | HandlerGameType; export type FormPage = { @@ -35,6 +39,8 @@ export type FormPage = { page: SetupManifest; }; +let submitAction: string | undefined = undefined; + export default function InstanceCreateForm({ onComplete, }: { @@ -48,14 +54,15 @@ export default function InstanceCreateForm({ const [setupManifest, setSetupManifest] = useState( null ); + const { ongoingNotifications } = useContext(NotificationContext); const { data: setup_manifest, isLoading, error, } = gameType === 'Generic' - ? SetupGenericInstanceManifest(gameType, url, genericFetchReady) - : SetupInstanceManifest(gameType as HandlerGameType); + ? SetupGenericInstanceManifest(gameType, url, genericFetchReady) + : SetupInstanceManifest(gameType as HandlerGameType); const gaEventTracker = useAnalyticsEventTracker('Create Instance'); const formikRef = @@ -150,9 +157,32 @@ export default function InstanceCreateForm({ } }; + const uploadInstance = async (value: SetupValue) => { + try { + if (gameType === 'Generic') { + await axiosWrapper({ + method: 'post', + url: `/instance/create_generic`, + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify({ url: url, setup_value: value }), + }); + } else { + await axiosWrapper({ + method: 'post', + url: `/instance/create/${gameType}`, + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(value), + }); + } + } catch (e) { + toast.error('Error creating instance: ' + e); + } + }; + + async function submitForm( values: Record, - actions: FormikHelpers> + actions: FormikHelpers>, ) { const sectionValues = parseValues(values); @@ -164,7 +194,10 @@ export default function InstanceCreateForm({ setting_sections: sectionValues, }; - await createInstance(parsedValues); + if (submitAction == "upload") + await uploadInstance(parsedValues); + else + await createInstance(parsedValues); actions.setSubmitting(false); gaEventTracker('Create Instance Complete'); @@ -195,10 +228,22 @@ export default function InstanceCreateForm({ async function handleSubmit( values: Record, - actions: FormikHelpers> + actions: FormikHelpers>, ) { if (formReady) { + if (submitAction === "upload") { + const file = await chooseFiles(false); + if (!file) { + return; + } + console.log(ongoingNotifications) + const fileArray = Array.from(file); + const tmpDir = await getTmpPath(); + uploadFile(tmpDir, fileArray, true); + onComplete(); + } submitForm(values, actions); + onComplete(); } else { if (setup_manifest) { if (activeStep === 0) actions.setValues(initialValues); @@ -210,7 +255,7 @@ export default function InstanceCreateForm({ } function handleBack() { - if(activeStep === 1) { + if (activeStep === 1) { setGenericFetchReady(false); setUrlValid(false); setUrl(''); @@ -242,14 +287,14 @@ export default function InstanceCreateForm({ {({ isSubmitting, status }) => (
{sections.map((section, i) => (
@@ -286,12 +331,25 @@ export default function InstanceCreateForm({ ) : (
)} -
diff --git a/dashboard/src/utils/apis.ts b/dashboard/src/utils/apis.ts index f7176029..05011624 100644 --- a/dashboard/src/utils/apis.ts +++ b/dashboard/src/utils/apis.ts @@ -30,6 +30,45 @@ import { PlayitTunnelInfo } from 'bindings/PlayitTunnelInfo'; * Start Files API ***********************/ +export const getTmpPath = async () => { + const tmpPath = await axiosWrapper({ + method: 'get', + url: `/fs/tmp_path`, + }); + + return tmpPath; +}; + +export const uploadFile = async ( + directory: string, + file: Array, + overwrite = false, +) => { + // upload all files using multipart form data + const formData = new FormData(); + file.forEach((f) => { + formData.append('file', f); + }); + toast.info(`Uploading ${file.length} ${file.length > 1 ? 'files' : 'file'}`); + console.log(`/fs/${Base64.encode(directory, true)}/upload` + (overwrite ? "/overwrite" : "")) + const error = await catchAsyncToString( + axiosWrapper({ + method: 'put', + url: `/fs/${Base64.encode(directory, true)}/upload` + (overwrite ? "/overwrite" : ""), + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 0, + }) + ); + if (error) { + toast.error(error); + return; + } +}; + + export const saveInstanceFile = async ( uuid: string, directory: string, @@ -463,15 +502,15 @@ export const getMacroConfig = async (uuid: string, macro_name: string) => { // write macro local config export const storeMacroConfig = async ( - uuid: string, - macro_name: string, + uuid: string, + macro_name: string, config: Record) => { - const serverResponse = await axiosWrapper({ - method: 'post', - url: `/instance/${uuid}/macro/config/store/${macro_name}`, - data: config - }) - return serverResponse; + const serverResponse = await axiosWrapper({ + method: 'post', + url: `/instance/${uuid}/macro/config/store/${macro_name}`, + data: config + }) + return serverResponse; } export const getInstanceHistory = async (uuid: string) => { From 7e9d17620cd8139e8f6b89fd419d37bb279a0fe5 Mon Sep 17 00:00:00 2001 From: seliayeu Date: Sun, 21 Jul 2024 07:25:40 -0600 Subject: [PATCH 2/3] fix bugs --- core/src/handlers/global_fs.rs | 92 +++++++++-- core/src/handlers/instance.rs | 143 ++++++++++++++++++ .../Instance/InstanceCreateForm.tsx | 42 ++--- dashboard/src/utils/apis.ts | 1 - dashboard/src/utils/util.ts | 17 ++- 5 files changed, 251 insertions(+), 44 deletions(-) diff --git a/core/src/handlers/global_fs.rs b/core/src/handlers/global_fs.rs index 386c23b7..2db47763 100644 --- a/core/src/handlers/global_fs.rs +++ b/core/src/handlers/global_fs.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use axum::{ body::{Bytes, StreamBody}, - extract::{Multipart, Path}, + extract::{DefaultBodyLimit, Multipart, Path}, http, routing::{delete, get, put}, Json, Router, @@ -103,6 +103,12 @@ impl From<&std::path::Path> for FileEntry { } } +#[derive(Deserialize)] +struct UploadFilePathParams { + base64_absolute_path: String, + overwrite: Option, +} + async fn list_files( axum::extract::State(state): axum::extract::State, Path(base64_absolute_path): Path, @@ -119,7 +125,10 @@ async fn list_files( source: eyre!("Token error"), })?; - requester.try_action(&UserAction::ReadGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::ReadGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); let caused_by = CausedBy::User { @@ -158,7 +167,10 @@ async fn read_file( kind: ErrorKind::Unauthorized, source: eyre!("Token error"), })?; - requester.try_action(&UserAction::ReadGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::ReadGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); let ret = tokio::fs::read_to_string(&path).await.context( @@ -178,6 +190,19 @@ async fn read_file( Ok(ret) } +async fn get_tmp_path( + axum::extract::State(state): axum::extract::State, + AuthBearer(token): AuthBearer, +) -> Result, Error> { + let tmp_path = path_to_tmp() + .clone() + .into_os_string() + .into_string() + .expect("Failed to get path to tmp directory"); + + Ok(Json(tmp_path)) +} + async fn write_file( axum::extract::State(state): axum::extract::State, Path(base64_absolute_path): Path, @@ -195,7 +220,10 @@ async fn write_file( kind: ErrorKind::Unauthorized, source: eyre!("Token error"), })?; - requester.try_action(&UserAction::WriteGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); @@ -231,7 +259,10 @@ async fn make_directory( kind: ErrorKind::Unauthorized, source: eyre!("Token error"), })?; - requester.try_action(&UserAction::WriteGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); tokio::fs::create_dir(&path).await.context(format!( @@ -271,7 +302,10 @@ async fn move_file( source: eyre!("Token error"), })?; - requester.try_action(&UserAction::WriteGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; crate::util::fs::rename(&path_source, &path_dest).await?; @@ -306,7 +340,10 @@ async fn remove_file( kind: ErrorKind::Unauthorized, source: eyre!("Token error"), })?; - requester.try_action(&UserAction::WriteGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); @@ -341,7 +378,10 @@ async fn remove_dir( kind: ErrorKind::Unauthorized, source: eyre!("Token error"), })?; - requester.try_action(&UserAction::WriteGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); @@ -377,7 +417,10 @@ async fn new_file( kind: ErrorKind::Unauthorized, source: eyre!("Token error"), })?; - requester.try_action(&UserAction::WriteGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); @@ -413,7 +456,10 @@ async fn download_file( kind: ErrorKind::Unauthorized, source: eyre!("Token error"), })?; - requester.try_action(&UserAction::ReadGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::ReadGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; let path = PathBuf::from(absolute_path); let downloadable_file_path: PathBuf; let downloadable_file = if fs::metadata(path.clone()).unwrap().is_dir() { @@ -452,7 +498,10 @@ async fn download_file( async fn upload_file( axum::extract::State(state): axum::extract::State, - Path(base64_absolute_path): Path, + Path(UploadFilePathParams { + base64_absolute_path, + overwrite, + }): Path, headers: HeaderMap, AuthBearer(token): AuthBearer, mut multipart: Multipart, @@ -468,7 +517,15 @@ async fn upload_file( source: eyre!("Token error"), })?; - requester.try_action(&UserAction::WriteGlobalFile, state.global_settings.lock().await.safe_mode())?; + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; + + let overwrite = match overwrite { + Some(should_overwrite) => should_overwrite == "overwrite", + None => false, + }; let path_to_dir = PathBuf::from(absolute_path); @@ -504,7 +561,7 @@ async fn upload_file( })? .to_owned(); let path = path_to_dir.join(&name); - let path = if path.exists() { + let path = if path.exists() && !overwrite { // add a postfix to the file name let mut postfix = 1; // get the file name without the extension @@ -574,7 +631,7 @@ async fn upload_file( .send(Event::new_progression_event_end( event_id, true, - Some("Upload complete"), + Some("Upload complete: "), None, )); @@ -651,6 +708,13 @@ pub fn get_global_fs_routes(state: AppState) -> Router { .route("/fs/:base64_absolute_path/new", put(new_file)) .route("/fs/:base64_absolute_path/download", get(download_file)) .route("/fs/:base64_absolute_path/upload", put(upload_file)) + .layer(DefaultBodyLimit::disable()) + .route( + "/fs/:base64_absolute_path/upload/:overwrite", + put(upload_file), + ) + .layer(DefaultBodyLimit::disable()) + .route("/fs/tmp_path", get(get_tmp_path)) .route("/file/:key", get(download)) .with_state(state) } diff --git a/core/src/handlers/instance.rs b/core/src/handlers/instance.rs index ffa9553a..0a26c8a1 100644 --- a/core/src/handlers/instance.rs +++ b/core/src/handlers/instance.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use axum::routing::{delete, get, post}; use axum::Router; use axum::{extract::Path, Json}; @@ -22,9 +24,11 @@ use crate::traits::t_configurable::manifest::SetupValue; use crate::traits::t_configurable::Game::Generic; use crate::traits::{t_configurable::TConfigurable, t_server::TServer, InstanceInfo, TInstance}; use crate::types::{DotLodestoneConfig, InstanceUuid}; +use crate::util::{unzip_file, unzip_file_async, UnzipOption}; use crate::{implementations::minecraft, traits::t_server::State, AppState}; use super::instance_setup_configs::HandlerGameType; +use super::util::decode_base64; pub async fn get_instance_list( axum::extract::State(state): axum::extract::State, @@ -67,12 +71,147 @@ pub async fn get_instance_info( Ok(Json(instance.get_instance_info().await)) } +pub async fn create_minecraft_instance_from_zip( + axum::extract::State(state): axum::extract::State, + AuthBearer(token): AuthBearer, + Path((game_type, base64_absolute_path)): Path<(HandlerGameType, String)>, + Json(manifest_value): Json, +) -> Result, Error> { + let zip_path = decode_base64(&base64_absolute_path)?; + let requester = state.users_manager.read().await.try_auth_or_err(&token)?; + requester.try_action( + &UserAction::CreateInstance, + state.global_settings.lock().await.safe_mode(), + )?; + let mut perm = requester.permissions; + + let mut instance_uuid = InstanceUuid::default(); + + for entry in state.instances.iter() { + if let Some(uuid) = entry.key().as_ref().get(0..8) { + if uuid == &instance_uuid.no_prefix()[0..8] { + instance_uuid = InstanceUuid::default(); + } + } + } + + let instance_uuid = instance_uuid; + + let flavour = game_type.try_into()?; + + let setup_config = MinecraftInstance::construct_setup_config(manifest_value, flavour).await?; + + let setup_path = path_to_instances().join(format!( + "{}-{}", + setup_config.name, + &instance_uuid.no_prefix()[0..8] + )); + + tokio::fs::create_dir_all(&setup_path) + .await + .context("Failed to create instance directory")?; + + let zip = PathBuf::from(&zip_path); + + unzip_file_async(&zip, UnzipOption::ToDir(setup_path.clone())) + .await + .context("Failed to unzip given file")?; + + let dot_lodestone_config = DotLodestoneConfig::new(instance_uuid.clone(), game_type.into()); + + // write dot lodestone config + + tokio::fs::write( + setup_path.join(".lodestone_config"), + serde_json::to_string_pretty(&dot_lodestone_config).unwrap(), + ) + .await + .context("Failed to write .lodestone_config file")?; + + tokio::task::spawn({ + let uuid = instance_uuid.clone(); + let instance_name = setup_config.name.clone(); + let event_broadcaster = state.event_broadcaster.clone(); + let caused_by = CausedBy::User { + user_id: requester.uid.clone(), + user_name: requester.username.clone(), + }; + async move { + let (progression_start_event, event_id) = Event::new_progression_event_start( + format!("Setting up Minecraft server {instance_name}"), + Some(10.0), + Some(ProgressionStartValue::InstanceCreation { + instance_uuid: uuid.clone(), + }), + caused_by, + ); + event_broadcaster.send(progression_start_event); + let minecraft_instance = match minecraft::MinecraftInstance::restore( + setup_path.clone(), + dot_lodestone_config, + state.event_broadcaster.clone(), + state.macro_executor.clone(), + ) + .await + { + Ok(v) => { + event_broadcaster.send(Event::new_progression_event_end( + event_id, + true, + Some("Instance created successfully"), + Some(ProgressionEndValue::InstanceCreation( + v.get_instance_info().await, + )), + )); + v + } + Err(e) => { + event_broadcaster.send(Event::new_progression_event_end( + event_id, + false, + Some(&format!("Instance creation failed: {e}")), + None, + )); + crate::util::fs::remove_dir_all(setup_path) + .await + .context("Failed to remove directory after instance creation failed") + .unwrap(); + return; + } + }; + let mut port_manager = state.port_manager.lock().await; + port_manager.add_port(setup_config.port); + perm.can_start_instance.insert(uuid.clone()); + perm.can_stop_instance.insert(uuid.clone()); + perm.can_view_instance.insert(uuid.clone()); + perm.can_read_instance_file.insert(uuid.clone()); + perm.can_write_instance_file.insert(uuid.clone()); + // ignore errors since we don't care if the permissions update fails + let _ = state + .users_manager + .write() + .await + .update_permissions(&requester.uid, perm, CausedBy::System) + .await + .map_err(|e| { + error!("Failed to update permissions: {:?}", e); + e + }); + state + .instances + .insert(uuid.clone(), minecraft_instance.into()); + } + }); + Ok(Json(instance_uuid)) +} + pub async fn create_minecraft_instance( axum::extract::State(state): axum::extract::State, AuthBearer(token): AuthBearer, Path(game_type): Path, Json(manifest_value): Json, ) -> Result, Error> { + println!("wowie"); let requester = state.users_manager.read().await.try_auth_or_err(&token)?; requester.try_action( &UserAction::CreateInstance, @@ -399,6 +538,10 @@ pub fn get_instance_routes(state: AppState) -> Router { "/instance/create/:game_type", post(create_minecraft_instance), ) + .route( + "/instance/create_from_zip/:game_type/:base64_absolute_path", + post(create_minecraft_instance_from_zip), + ) .route("/instance/create_generic", post(create_generic_instance)) .route("/instance/:uuid", delete(delete_instance)) .route("/instance/:uuid/info", get(get_instance_info)) diff --git a/dashboard/src/components/Instance/InstanceCreateForm.tsx b/dashboard/src/components/Instance/InstanceCreateForm.tsx index d838cedf..222fa8fd 100644 --- a/dashboard/src/components/Instance/InstanceCreateForm.tsx +++ b/dashboard/src/components/Instance/InstanceCreateForm.tsx @@ -157,23 +157,18 @@ export default function InstanceCreateForm({ } }; - const uploadInstance = async (value: SetupValue) => { + const uploadInstance = async (value: SetupValue, path: string) => { + path = Base64.encode(path, true) + console.log(path, Base64.decode(path)) + + try { - if (gameType === 'Generic') { - await axiosWrapper({ - method: 'post', - url: `/instance/create_generic`, - headers: { 'Content-Type': 'application/json' }, - data: JSON.stringify({ url: url, setup_value: value }), - }); - } else { - await axiosWrapper({ - method: 'post', - url: `/instance/create/${gameType}`, - headers: { 'Content-Type': 'application/json' }, - data: JSON.stringify(value), - }); - } + await axiosWrapper({ + method: 'post', + url: `/instance/create_from_zip/${gameType}/${path}`, + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(value), + }); } catch (e) { toast.error('Error creating instance: ' + e); } @@ -183,6 +178,7 @@ export default function InstanceCreateForm({ async function submitForm( values: Record, actions: FormikHelpers>, + path: string | null = null ) { const sectionValues = parseValues(values); @@ -194,8 +190,8 @@ export default function InstanceCreateForm({ setting_sections: sectionValues, }; - if (submitAction == "upload") - await uploadInstance(parsedValues); + if (submitAction == "upload" && path) + await uploadInstance(parsedValues, path); else await createInstance(parsedValues); actions.setSubmitting(false); @@ -236,14 +232,18 @@ export default function InstanceCreateForm({ if (!file) { return; } - console.log(ongoingNotifications) const fileArray = Array.from(file); const tmpDir = await getTmpPath(); + let directorySeparator = '\\'; + // assume only linux paths contain / + if (tmpDir.includes('/')) directorySeparator = '/'; uploadFile(tmpDir, fileArray, true); + submitForm(values, actions, tmpDir + directorySeparator + fileArray[0].name); + onComplete(); + } else { + submitForm(values, actions); onComplete(); } - submitForm(values, actions); - onComplete(); } else { if (setup_manifest) { if (activeStep === 0) actions.setValues(initialValues); diff --git a/dashboard/src/utils/apis.ts b/dashboard/src/utils/apis.ts index 05011624..ac36d085 100644 --- a/dashboard/src/utils/apis.ts +++ b/dashboard/src/utils/apis.ts @@ -50,7 +50,6 @@ export const uploadFile = async ( formData.append('file', f); }); toast.info(`Uploading ${file.length} ${file.length > 1 ? 'files' : 'file'}`); - console.log(`/fs/${Base64.encode(directory, true)}/upload` + (overwrite ? "/overwrite" : "")) const error = await catchAsyncToString( axiosWrapper({ method: 'put', diff --git a/dashboard/src/utils/util.ts b/dashboard/src/utils/util.ts index c13ba71c..2fe039a9 100644 --- a/dashboard/src/utils/util.ts +++ b/dashboard/src/utils/util.ts @@ -216,7 +216,7 @@ export function parseFloatStrict(value: string): number { return parsed; } - + export function getWidth( el: HTMLElement, @@ -280,11 +280,9 @@ export const formatDuration = (duration: number) => { const hours = Math.floor((duration % 86400) / 3600); const minutes = Math.floor((duration % 3600) / 60); const seconds = Math.floor(duration % 60); - return `${days < 10 ? '0' + days : days}:${ - hours < 10 ? '0' + hours : hours - }:${minutes < 10 ? '0' + minutes : minutes}:${ - seconds < 10 ? '0' + seconds : seconds - }`; + return `${days < 10 ? '0' + days : days}:${hours < 10 ? '0' + hours : hours + }:${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds + }`; }; // format message time for notifications @@ -355,15 +353,18 @@ export const getSnowflakeTimestamp = (snowflake: string) => { }; -export const chooseFiles = async () => { +export const chooseFiles = async (multiple = true) => { const input = document.createElement('input'); input.type = 'file'; - input.multiple = true; + input.multiple = multiple; input.click(); return new Promise((resolve) => { input.onchange = () => { resolve(input.files); }; + input.oncancel = () => { + resolve(null); + }; }); }; From 7ea12c5e18aa6be5ddf405d3fd3264d67be6222f Mon Sep 17 00:00:00 2001 From: seliayeu Date: Sun, 28 Jul 2024 14:13:37 -0600 Subject: [PATCH 3/3] add support for non-lodestone instances --- core/src/handlers/global_fs.rs | 122 ++++++++++++++++-- core/src/handlers/instance.rs | 65 +++++++++- .../Instance/InstanceCreateForm.tsx | 27 ++-- dashboard/src/utils/apis.ts | 29 ++++- 4 files changed, 206 insertions(+), 37 deletions(-) diff --git a/core/src/handlers/global_fs.rs b/core/src/handlers/global_fs.rs index 2db47763..0c854715 100644 --- a/core/src/handlers/global_fs.rs +++ b/core/src/handlers/global_fs.rs @@ -496,12 +496,114 @@ async fn download_file( Ok(key) } +async fn upload_tmp_file( + axum::extract::State(state): axum::extract::State, + headers: HeaderMap, + AuthBearer(token): AuthBearer, + mut multipart: Multipart, +) -> Result, Error> { + let requester = state + .users_manager + .read() + .await + .try_auth(&token) + .ok_or_else(|| Error { + kind: ErrorKind::Unauthorized, + source: eyre!("Token error"), + })?; + + requester.try_action( + &UserAction::WriteGlobalFile, + state.global_settings.lock().await.safe_mode(), + )?; + + let tmp_path = path_to_tmp().clone(); + + let total = headers + .get(CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + + let (progression_start_event, event_id) = Event::new_progression_event_start( + "Uploading file(s)", + total, + None, + CausedBy::User { + user_id: requester.uid.clone(), + user_name: requester.username.clone(), + }, + ); + state.event_broadcaster.send(progression_start_event); + + while let Ok(Some(mut field)) = multipart.next_field().await { + let name = field + .file_name() + .ok_or_else(|| Error { + kind: ErrorKind::BadRequest, + source: eyre!("Missing file name"), + })? + .to_owned(); + let path = tmp_path.join(&name); + let mut file = tokio::fs::File::create(&path) + .await + .context(format!("Failed to create file {}", path.display()))?; + + while let Some(chunk) = match field.chunk().await { + Ok(v) => v, + Err(e) => { + tokio::fs::remove_file(&path).await.ok(); + state + .event_broadcaster + .send(Event::new_progression_event_end( + event_id, + false, + Some(&e.to_string()), + None, + )); + return Err(Error { + kind: ErrorKind::BadRequest, + source: eyre!("Failed to read chunk: {}", e), + }); + } + } { + state + .event_broadcaster + .send(Event::new_progression_event_update( + &event_id, + format!("Uploading {name}"), + chunk.len() as f64, + )); + file.write_all(&chunk).await.map_err(|_| { + std::fs::remove_file(&path).ok(); + eyre!("Failed to write chunk") + })?; + } + + let caused_by = CausedBy::User { + user_id: requester.uid.clone(), + user_name: requester.username.clone(), + }; + state.event_broadcaster.send(new_fs_event( + FSOperation::Upload, + FSTarget::File(path), + caused_by, + )); + } + state + .event_broadcaster + .send(Event::new_progression_event_end( + event_id, + true, + Some("Upload complete: "), + None, + )); + + Ok(Json(())) +} + async fn upload_file( axum::extract::State(state): axum::extract::State, - Path(UploadFilePathParams { - base64_absolute_path, - overwrite, - }): Path, + Path(base64_absolute_path): Path, headers: HeaderMap, AuthBearer(token): AuthBearer, mut multipart: Multipart, @@ -522,11 +624,6 @@ async fn upload_file( state.global_settings.lock().await.safe_mode(), )?; - let overwrite = match overwrite { - Some(should_overwrite) => should_overwrite == "overwrite", - None => false, - }; - let path_to_dir = PathBuf::from(absolute_path); tokio::fs::create_dir_all(&path_to_dir) @@ -561,7 +658,7 @@ async fn upload_file( })? .to_owned(); let path = path_to_dir.join(&name); - let path = if path.exists() && !overwrite { + let path = if path.exists() { // add a postfix to the file name let mut postfix = 1; // get the file name without the extension @@ -709,10 +806,7 @@ pub fn get_global_fs_routes(state: AppState) -> Router { .route("/fs/:base64_absolute_path/download", get(download_file)) .route("/fs/:base64_absolute_path/upload", put(upload_file)) .layer(DefaultBodyLimit::disable()) - .route( - "/fs/:base64_absolute_path/upload/:overwrite", - put(upload_file), - ) + .route("/fs/upload_tmp", put(upload_tmp_file)) .layer(DefaultBodyLimit::disable()) .route("/fs/tmp_path", get(get_tmp_path)) .route("/file/:key", get(download)) diff --git a/core/src/handlers/instance.rs b/core/src/handlers/instance.rs index 0a26c8a1..2adc41b5 100644 --- a/core/src/handlers/instance.rs +++ b/core/src/handlers/instance.rs @@ -9,6 +9,7 @@ use bollard::container::ListContainersOptions; use bollard::Docker; use color_eyre::eyre::{eyre, Context}; use serde::Deserialize; +use serde_json::to_string_pretty; use tracing::{error, info}; use crate::auth::user::UserAction; @@ -16,10 +17,11 @@ use crate::error::{Error, ErrorKind}; use crate::events::{CausedBy, Event, ProgressionEndValue, ProgressionStartValue}; use crate::implementations::generic; +use crate::implementations::minecraft::util::get_jre_url; use crate::traits::t_configurable::GameType; -use crate::implementations::minecraft::MinecraftInstance; -use crate::prelude::{path_to_instances, GameInstance}; +use crate::implementations::minecraft::{MinecraftInstance, RestoreConfig}; +use crate::prelude::{path_to_binaries, path_to_instances, path_to_tmp, GameInstance}; use crate::traits::t_configurable::manifest::SetupValue; use crate::traits::t_configurable::Game::Generic; use crate::traits::{t_configurable::TConfigurable, t_server::TServer, InstanceInfo, TInstance}; @@ -74,10 +76,10 @@ pub async fn get_instance_info( pub async fn create_minecraft_instance_from_zip( axum::extract::State(state): axum::extract::State, AuthBearer(token): AuthBearer, - Path((game_type, base64_absolute_path)): Path<(HandlerGameType, String)>, + Path((game_type, filename)): Path<(HandlerGameType, String)>, Json(manifest_value): Json, ) -> Result, Error> { - let zip_path = decode_base64(&base64_absolute_path)?; + let zip_name = decode_base64(&filename)?; let requester = state.users_manager.read().await.try_auth_or_err(&token)?; requester.try_action( &UserAction::CreateInstance, @@ -111,13 +113,15 @@ pub async fn create_minecraft_instance_from_zip( .await .context("Failed to create instance directory")?; - let zip = PathBuf::from(&zip_path); + let tmp_path = path_to_tmp().clone(); + let zip = tmp_path.join(zip_name); unzip_file_async(&zip, UnzipOption::ToDir(setup_path.clone())) .await .context("Failed to unzip given file")?; let dot_lodestone_config = DotLodestoneConfig::new(instance_uuid.clone(), game_type.into()); + let path_to_config = setup_path.join(".lodestone_minecraft_config.json"); // write dot lodestone config @@ -128,6 +132,57 @@ pub async fn create_minecraft_instance_from_zip( .await .context("Failed to write .lodestone_config file")?; + let res = get_jre_url(setup_config.version.as_str()).await; + + let (_, jre_major_version) = if let Some((url, jre_major_version)) = res { + (url, jre_major_version) + } else { + return Err(Error { + kind: ErrorKind::BadRequest, + source: eyre!("Invalid server"), + }); + }; + + let path_to_runtimes = path_to_binaries().to_owned(); + let jre = path_to_runtimes + .join("java") + .join(format!("jre{}", jre_major_version)) + .join(if std::env::consts::OS == "macos" { + "Contents/Home/bin" + } else { + "bin" + }) + .join("java"); + + let restore_config = RestoreConfig { + name: setup_config.name.clone(), + version: setup_config.version, + flavour: flavour.into(), + description: setup_config.description.unwrap_or_default(), + cmd_args: setup_config.cmd_args, + port: setup_config.port, + min_ram: setup_config.min_ram.unwrap_or(2048), + max_ram: setup_config.max_ram.unwrap_or(4096), + auto_start: setup_config.auto_start.unwrap_or(false), + restart_on_crash: setup_config.restart_on_crash.unwrap_or(false), + backup_period: setup_config.backup_period, + jre_major_version, + has_started: false, + java_cmd: Some(jre.to_string_lossy().to_string()), + }; + + // create config file + tokio::fs::write( + &path_to_config, + to_string_pretty(&restore_config) + .context("Failed to serialize config to string. This is a bug, please report it.")?, + ) + .await + .context(format!( + "Failed to write config file at {}", + &path_to_config.display() + ))?; + tokio::task::spawn({ let uuid = instance_uuid.clone(); let instance_name = setup_config.name.clone(); diff --git a/dashboard/src/components/Instance/InstanceCreateForm.tsx b/dashboard/src/components/Instance/InstanceCreateForm.tsx index 222fa8fd..0511d271 100644 --- a/dashboard/src/components/Instance/InstanceCreateForm.tsx +++ b/dashboard/src/components/Instance/InstanceCreateForm.tsx @@ -3,7 +3,7 @@ import { Form, Formik, FormikHelpers, FormikProps } from 'formik'; import { useRef, useState, useEffect, useMemo, useContext } from 'react'; import { useEffectOnce } from 'usehooks-ts'; import useAnalyticsEventTracker from 'utils/hooks'; -import { axiosWrapper, catchAsyncToString, chooseFile, chooseFiles } from 'utils/util'; +import { axiosWrapper, chooseFiles } from 'utils/util'; import { autoSettingPageObject, basicSettingsPageObject, @@ -29,7 +29,7 @@ import { SectionManifestValue } from 'bindings/SectionManifestValue'; import { toast } from 'react-toastify'; import { faUpload } from '@fortawesome/free-solid-svg-icons'; import { Base64 } from 'js-base64'; -import { getTmpPath, uploadFile } from 'utils/apis'; +import { uploadTmpFile } from 'utils/apis'; import { NotificationContext } from 'data/NotificationContext'; export type GenericHandlerGameType = 'Generic' | HandlerGameType; @@ -157,15 +157,13 @@ export default function InstanceCreateForm({ } }; - const uploadInstance = async (value: SetupValue, path: string) => { - path = Base64.encode(path, true) - console.log(path, Base64.decode(path)) - + const uploadInstance = async (value: SetupValue, filename: string) => { + filename = Base64.encode(filename, true) try { await axiosWrapper({ method: 'post', - url: `/instance/create_from_zip/${gameType}/${path}`, + url: `/instance/create_from_zip/${gameType}/${filename}`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(value), }); @@ -178,7 +176,7 @@ export default function InstanceCreateForm({ async function submitForm( values: Record, actions: FormikHelpers>, - path: string | null = null + server_name: string | null = null ) { const sectionValues = parseValues(values); @@ -190,8 +188,8 @@ export default function InstanceCreateForm({ setting_sections: sectionValues, }; - if (submitAction == "upload" && path) - await uploadInstance(parsedValues, path); + if (submitAction == "upload" && server_name) + await uploadInstance(parsedValues, server_name); else await createInstance(parsedValues); actions.setSubmitting(false); @@ -233,12 +231,8 @@ export default function InstanceCreateForm({ return; } const fileArray = Array.from(file); - const tmpDir = await getTmpPath(); - let directorySeparator = '\\'; - // assume only linux paths contain / - if (tmpDir.includes('/')) directorySeparator = '/'; - uploadFile(tmpDir, fileArray, true); - submitForm(values, actions, tmpDir + directorySeparator + fileArray[0].name); + await uploadTmpFile(fileArray); + submitForm(values, actions, fileArray[0].name); onComplete(); } else { submitForm(values, actions); @@ -345,6 +339,7 @@ export default function InstanceCreateForm({ }