diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97372cd..5c2718a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: with: toolchain: 1.79.0 components: clippy,rustfmt - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/ diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 9291a11..4920e36 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -12,7 +12,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: 1.79.0 - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index beceb26..f7f97a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: 1.79.0 - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/ @@ -43,7 +43,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: 1.79.0 - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/ @@ -78,7 +78,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: 1.79.0 - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/ diff --git a/docs/site/pages/docs/authentication.mdx b/docs/site/pages/docs/authentication.mdx index 9008a0b..69ba345 100644 --- a/docs/site/pages/docs/authentication.mdx +++ b/docs/site/pages/docs/authentication.mdx @@ -99,7 +99,8 @@ To get your `.ROBLOSECURITY` cookie manually, you have a few options: Follow the [official Roblox guide](https://create.roblox.com/docs/cloud/open-cloud/api-keys) for creating a Cloud API key. You must grant the key access to the following services: -* Place Publishing for all experiences you want to manage with Mantle +* `universe-places:write` for all experiences you want to manage with Mantle +* `asset:read` and `asset:write` in order to manage assets with Mantle To authenticate with the Cloud API, you must provide the `MANTLE_OPEN_CLOUD_API_KEY` environment variable. You can set your environment variable in various ways, like the following: diff --git a/mantle/rbx_api/src/assets/mod.rs b/mantle/rbx_api/src/assets/mod.rs index 80197d9..2881d1b 100644 --- a/mantle/rbx_api/src/assets/mod.rs +++ b/mantle/rbx_api/src/assets/mod.rs @@ -7,45 +7,14 @@ use serde_json::json; use crate::{ errors::{RobloxApiError, RobloxApiResult}, - helpers::{handle, handle_as_json, handle_as_json_with_status}, + helpers::{handle, handle_as_json}, models::{AssetId, AssetTypeId, CreatorType}, RobloxApi, }; -use self::models::{ - CreateAssetQuota, CreateAssetQuotasResponse, CreateAudioAssetResponse, CreateImageAssetResponse, -}; +use self::models::{CreateAssetQuota, CreateAssetQuotasResponse, CreateAudioAssetResponse}; impl RobloxApi { - pub async fn create_image_asset( - &self, - file_path: PathBuf, - group_id: Option, - ) -> RobloxApiResult { - let data = fs::read(&file_path)?; - - let file_name = format!( - "Images/{}", - file_path.file_stem().and_then(OsStr::to_str).unwrap() - ); - - let mut req = self - .client - .post("https://data.roblox.com/data/upload/json") - .header(reqwest::header::CONTENT_TYPE, "*/*") - .body(data) - .query(&[ - ("assetTypeId", &AssetTypeId::Decal.to_string()), - ("name", &file_name), - ("description", &"madewithmantle".to_owned()), - ]); - if let Some(group_id) = group_id { - req = req.query(&[("groupId", &group_id.to_string())]); - } - - handle_as_json_with_status(req).await - } - pub async fn get_create_asset_quota( &self, asset_type: AssetTypeId, diff --git a/mantle/rbx_api/src/lib.rs b/mantle/rbx_api/src/lib.rs index e4a5937..d59c538 100644 --- a/mantle/rbx_api/src/lib.rs +++ b/mantle/rbx_api/src/lib.rs @@ -14,9 +14,9 @@ pub mod places; pub mod social_links; pub mod spatial_voice; pub mod thumbnails; +pub mod user; use errors::{RobloxApiError, RobloxApiResult}; -use helpers::handle; use rbx_auth::{RobloxAuth, WithRobloxAuth}; pub struct RobloxApi { @@ -35,14 +35,7 @@ impl RobloxApi { } pub async fn validate_auth(&self) -> RobloxApiResult<()> { - let req = self - .client - .get("https://users.roblox.com/v1/users/authenticated"); - - handle(req) - .await - .map_err(|_| RobloxApiError::Authorization)?; - + self.get_authenticated_user().await?; Ok(()) } } diff --git a/mantle/rbx_api/src/user/mod.rs b/mantle/rbx_api/src/user/mod.rs new file mode 100644 index 0000000..ed007b9 --- /dev/null +++ b/mantle/rbx_api/src/user/mod.rs @@ -0,0 +1,15 @@ +use models::GetAuthenticatedUserResponse; + +use crate::{errors::RobloxApiResult, helpers::handle_as_json, RobloxApi}; + +pub mod models; + +impl RobloxApi { + pub async fn get_authenticated_user(&self) -> RobloxApiResult { + let req = self + .client + .get("https://users.roblox.com/v1/users/authenticated"); + + handle_as_json(req).await + } +} diff --git a/mantle/rbx_api/src/user/models.rs b/mantle/rbx_api/src/user/models.rs new file mode 100644 index 0000000..db0dcaf --- /dev/null +++ b/mantle/rbx_api/src/user/models.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use crate::models::AssetId; + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthenticatedUserResponse { + pub id: AssetId, + pub name: String, + pub display_name: String, +} diff --git a/mantle/rbx_mantle/src/roblox_resource_manager.rs b/mantle/rbx_mantle/src/roblox_resource_manager.rs index 713ea16..4414f97 100644 --- a/mantle/rbx_mantle/src/roblox_resource_manager.rs +++ b/mantle/rbx_mantle/src/roblox_resource_manager.rs @@ -11,9 +11,7 @@ use rbx_api::{ GrantAssetPermissionRequestAction, GrantAssetPermissionRequestSubjectType, GrantAssetPermissionsRequestRequest, }, - assets::models::{ - CreateAssetQuota, CreateAudioAssetResponse, CreateImageAssetResponse, QuotaDuration, - }, + assets::models::{CreateAssetQuota, CreateAudioAssetResponse, QuotaDuration}, badges::models::CreateBadgeResponse, developer_products::models::{ CreateDeveloperProductIconResponse, CreateDeveloperProductResponse, @@ -26,12 +24,19 @@ use rbx_api::{ places::models::PlaceConfigurationModel, social_links::models::{CreateSocialLinkResponse, SocialLinkType}, spatial_voice::models::UpdateSpatialVoiceSettingsRequest, + user::models::GetAuthenticatedUserResponse, RobloxApi, }; use rbx_auth::RobloxAuth; use rbxcloud::rbx::{ types::{PlaceId, UniverseId}, - v1::{PublishVersionType, RbxCloud}, + v1::{ + assets::{ + AssetCreation, AssetCreationContext, AssetCreator, AssetGroupCreator, AssetType, + AssetUserCreator, + }, + CreateAsset, GetAsset, PublishVersionType, RbxCloud, + }, }; use serde::{Deserialize, Serialize}; use yansi::Paint; @@ -331,13 +336,31 @@ pub struct RobloxResourceManager { roblox_cloud: Option, project_path: PathBuf, payment_source: CreatorType, + user: GetAuthenticatedUserResponse, } impl RobloxResourceManager { pub async fn new(project_path: &Path, payment_source: CreatorType) -> Result { let roblox_auth = RobloxAuth::new().await?; let roblox_api = RobloxApi::new(roblox_auth)?; - roblox_api.validate_auth().await?; + + logger::start_action("Logging in:"); + let user = match roblox_api.get_authenticated_user().await { + Ok(user) => { + logger::log(format!("User ID: {}", user.id)); + logger::log(format!("User name: {}", user.name)); + logger::log(format!("User display name: {}", user.display_name)); + logger::end_action_without_message(); + user + } + Err(err) => { + return { + logger::log(Paint::red("Failed to login")); + logger::end_action_without_message(); + Err(err.into()) + } + } + }; let open_cloud_api_key = match env::var("MANTLE_OPEN_CLOUD_API_KEY") { Ok(v) => { @@ -354,11 +377,12 @@ impl RobloxResourceManager { roblox_cloud, project_path: project_path.to_path_buf(), payment_source, + user, }) } - fn get_path(&self, file: String) -> PathBuf { - self.project_path.join(file) + fn get_path>(&self, file: S) -> PathBuf { + self.project_path.join(file.into()) } } @@ -640,19 +664,77 @@ impl ResourceManager for RobloxResourceManager { })) } RobloxInputs::ImageAsset(inputs) => { - let CreateImageAssetResponse { - asset_id, - backing_asset_id, - .. - } = self - .roblox_api - .create_image_asset(self.get_path(inputs.file_path), inputs.group_id) - .await?; + if let Some(roblox_cloud) = &self.roblox_cloud { + let file = self.get_path(&inputs.file_path); + + let asset_type = file + .extension() + .map(|ext| ext.to_str().unwrap()) + .and_then(|ext| AssetType::try_from_extension(ext).ok()) + .ok_or("Unable to determine image asset type")?; + + let creator = match inputs.group_id { + Some(group_id) => AssetCreator::Group(AssetGroupCreator { + group_id: group_id.to_string(), + }), + None => AssetCreator::User(AssetUserCreator { + user_id: self.user.id.to_string(), + }), + }; + + let operation = roblox_cloud + .assets() + .create(&CreateAsset { + asset: AssetCreation { + asset_type, + display_name: inputs.file_path.clone(), + description: inputs.file_path, + creation_context: AssetCreationContext { + creator, + expected_price: None, + }, + }, + filepath: file.into_os_string().into_string().unwrap(), + }) + .await + .map_err(|e| e.to_string())?; - Ok(RobloxOutputs::ImageAsset(ImageAssetOutputs { - asset_id: backing_asset_id, - decal_asset_id: Some(asset_id), - })) + let operation_id = operation + .path + .as_ref() + .and_then(|path| path.split_once('/')) + .map(|(_, id)| id.to_string()) + .ok_or("Unable to parse operation ID from create asset response")?; + + // TODO: cast from generic operation.response to avoid potential + let mut operation_response = None; + + while operation_response.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + operation_response = roblox_cloud + .assets() + .get(&GetAsset { + operation_id: operation_id.clone(), + }) + .await + .map_err(|e| e.to_string())? + .response; + } + + let asset_id = operation_response + .unwrap() + .asset_id + .parse() + .map_err(|_| "Invalid asset ID")?; + + Ok(RobloxOutputs::ImageAsset(ImageAssetOutputs { + asset_id, + // TODO: This breaks archiving assets. + decal_asset_id: None, + })) + } else { + Err("Image asset uploads require Open Cloud authentication. Find out more here: https://mantledeploy.vercel.app/docs/authentication#roblox-open-cloud-api-key".to_string()) + } } RobloxInputs::AudioAsset(inputs) => { let CreateAssetQuota { @@ -1072,6 +1154,7 @@ impl ResourceManager for RobloxResourceManager { if let Some(decal_asset_id) = outputs.decal_asset_id { self.roblox_api.archive_asset(decal_asset_id).await?; } + // TODO: if no decal ID is available use Open Cloud API to archive. rbx_cloud currently doesn't support this API } RobloxOutputs::AudioAsset(outputs) => { self.roblox_api.archive_asset(outputs.asset_id).await?;