Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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/
Expand Down
3 changes: 2 additions & 1 deletion docs/site/pages/docs/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 2 additions & 33 deletions mantle/rbx_api/src/assets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssetId>,
) -> RobloxApiResult<CreateImageAssetResponse> {
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,
Expand Down
11 changes: 2 additions & 9 deletions mantle/rbx_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(())
}
}
15 changes: 15 additions & 0 deletions mantle/rbx_api/src/user/mod.rs
Original file line number Diff line number Diff line change
@@ -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<GetAuthenticatedUserResponse> {
let req = self
.client
.get("https://users.roblox.com/v1/users/authenticated");

handle_as_json(req).await
}
}
11 changes: 11 additions & 0 deletions mantle/rbx_api/src/user/models.rs
Original file line number Diff line number Diff line change
@@ -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,
}
121 changes: 102 additions & 19 deletions mantle/rbx_mantle/src/roblox_resource_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -331,13 +336,31 @@ pub struct RobloxResourceManager {
roblox_cloud: Option<RbxCloud>,
project_path: PathBuf,
payment_source: CreatorType,
user: GetAuthenticatedUserResponse,
}

impl RobloxResourceManager {
pub async fn new(project_path: &Path, payment_source: CreatorType) -> Result<Self, String> {
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) => {
Expand All @@ -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<S: Into<String>>(&self, file: S) -> PathBuf {
self.project_path.join(file.into())
}
}

Expand Down Expand Up @@ -640,19 +664,77 @@ impl ResourceManager<RobloxInputs, RobloxOutputs> 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 {
Expand Down Expand Up @@ -1072,6 +1154,7 @@ impl ResourceManager<RobloxInputs, RobloxOutputs> 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?;
Expand Down