Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for the Roblox Open Cloud API #8

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add open cloud implementation
  • Loading branch information
kennethloeffler committed Nov 29, 2023
commit fbf9e66595b6dd98a70e2853b8cc5e56307c447b
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ serde_json = "1.0"
structopt = { version = "0.3", default-features = false }
thiserror = "1.0.13"
toml = "0.5.3"
tokio = "1.20.1"
walkdir = "2.2.9"
19 changes: 4 additions & 15 deletions src/commands/upload_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,11 @@ use std::borrow::Cow;

use crate::{
alpha_bleed::alpha_bleed,
auth_cookie::get_auth_cookie,
options::{GlobalOptions, UploadImageOptions},
roblox_web_api::{ImageUploadData, RobloxApiClient, RobloxApiError, RobloxCredentials},
roblox_web_api::{ImageUploadData, RobloxApiClient, RobloxCredentials},
};

pub fn upload_image(
global: GlobalOptions,
options: UploadImageOptions,
) -> Result<(), RobloxApiError> {
let auth = global
.auth
.or_else(get_auth_cookie)
.expect("no auth cookie found");

pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) -> anyhow::Result<()> {
let image_data = fs::read(options.path).expect("couldn't read input file");

let mut img = image::load_from_memory(&image_data).expect("couldn't load image");
Expand All @@ -34,7 +25,7 @@ pub fn upload_image(
.unwrap();

let mut client = RobloxApiClient::new(RobloxCredentials {
token: Some(auth),
token: global.auth,
api_key: global.api_key,
user_id: None,
group_id: None,
Expand All @@ -46,9 +37,7 @@ pub fn upload_image(
description: &options.description,
};

let response = client
.upload_image(upload_data)
.expect("Roblox API request failed");
let response = client.upload_image(&upload_data)?;

eprintln!("Image uploaded successfully!");
println!("{}", response.backing_asset_id);
Expand Down
179 changes: 143 additions & 36 deletions src/roblox_web_api.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
use std::{
borrow::Cow,
fmt::{self, Write},
time::Duration,
};

use rbxcloud::rbx::assets::{AssetCreator, AssetGroupCreator, AssetUserCreator};
use rbxcloud::rbx::{
assets::{
AssetCreation, AssetCreationContext, AssetCreator, AssetGroupCreator, AssetType,
AssetUserCreator,
},
error::Error as RbxCloudError,
CreateAssetWithContents, GetAsset, RbxCloud,
};
use reqwest::{
header::{HeaderValue, COOKIE},
Client, Request, Response, StatusCode,
};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::runtime::Runtime;

use crate::auth_cookie::get_csrf_token;

Expand Down Expand Up @@ -45,6 +54,7 @@ pub struct RobloxApiClient {
csrf_token: Option<HeaderValue>,
credentials: RobloxCredentials,
client: Client,
runtime: Runtime,
}

#[derive(Debug)]
Expand Down Expand Up @@ -114,6 +124,7 @@ If you mean to use the Open Cloud API, make sure to provide an API key!
csrf_token,
creator,
credentials,
runtime: Runtime::new().unwrap(),
client: Client::new(),
})
}
Expand All @@ -137,51 +148,39 @@ If you mean to use the Open Cloud API, make sure to provide an API key!
&mut self,
data: ImageUploadData,
) -> Result<UploadResponse, RobloxApiError> {
let response = self.upload_image_raw(&data)?;
let name = "image";
let warn = || {
log::warn!(
"Image name '{}' was moderated, retrying with different name...",
data.name
);
};

// Some other errors will be reported inside the response, even
// though we received a successful HTTP response.
if response.success {
let asset_id = response.asset_id.unwrap();
let backing_asset_id = response.backing_asset_id.unwrap();
match self.upload_image(&data) {
Ok(response) => Ok(response),

Ok(UploadResponse {
asset_id,
backing_asset_id,
})
} else {
let message = response.message.unwrap();
Err(RobloxApiError::ApiError { message }) if message.contains("inappropriate") => {
warn();
self.upload_image(&ImageUploadData { name, ..data })
}

// There are no status codes for this API, so we pattern match
// on the returned error message.
//
// If the error message text mentions something being
// inappropriate, we assume the title was problematic and
// attempt to re-upload.
if message.contains("inappropriate") {
log::warn!(
"Image name '{}' was moderated, retrying with different name...",
data.name
);

let new_data = ImageUploadData {
name: "image",
..data
};

self.upload_image(new_data)
} else {
Err(RobloxApiError::ApiError { message })
Err(RobloxApiError::ResponseError { status, body })
if status == 400 && body.contains("moderated") =>
{
warn();
self.upload_image(&ImageUploadData { name, ..data })
}

Err(e) => Err(e),
}
}

/// Upload an image, returning an error if anything goes wrong.
pub fn upload_image(
&mut self,
data: ImageUploadData,
data: &ImageUploadData,
) -> Result<UploadResponse, RobloxApiError> {
let response = self.upload_image_raw(&data)?;
let response = self.upload_image_with_preferred_api(&data)?;

// Some other errors will be reported inside the response, even
// though we received a successful HTTP response.
Expand All @@ -200,9 +199,105 @@ If you mean to use the Open Cloud API, make sure to provide an API key!
}
}

fn upload_image_with_preferred_api(
&mut self,
data: &ImageUploadData,
) -> Result<RawUploadResponse, RobloxApiError> {
match &self.credentials.api_key {
Some(_) => {
let api_key = self
.credentials
.api_key
.as_ref()
.ok_or(RobloxApiError::MissingAuth)?;

let creator = self
.creator
.as_ref()
.ok_or(RobloxApiError::ApiKeyNeedsCreatorId)?;

self.upload_image_open_cloud(api_key, creator, data)
}
None => self.upload_image_legacy(data),
}
}

fn upload_image_open_cloud(
&self,
api_key: &SecretString,
creator: &AssetCreator,
data: &ImageUploadData,
) -> Result<RawUploadResponse, RobloxApiError> {
let assets = RbxCloud::new(api_key.expose_secret()).assets();

let map_response_error = |e| match e {
RbxCloudError::HttpStatusError { code, msg } => RobloxApiError::ResponseError {
status: StatusCode::from_u16(code).unwrap_or_default(),
body: msg,
},
_ => RobloxApiError::RbxCloud(e),
};

let asset_info = CreateAssetWithContents {
asset: AssetCreation {
asset_type: AssetType::DecalPng,
display_name: data.name.to_string(),
description: data.description.to_string(),
creation_context: AssetCreationContext {
creator: creator.clone(),
expected_price: None,
},
},
contents: &data.image_data,
};

let operation_id = self
.runtime
.block_on(async { assets.create_with_contents(&asset_info).await })
.map_err(map_response_error)
.map(|response| response.path)?
.ok_or(RobloxApiError::MissingOperationPath)?
.strip_prefix("operations/")
.ok_or(RobloxApiError::MalformedOperationPath)?
.to_string();

const MAX_RETRIES: u32 = 5;
const INITIAL_SLEEP_DURATION: Duration = Duration::from_millis(50);
const BACKOFF: u32 = 2;

let mut retry_count = 0;
let asset_id = loop {
let operation_id = operation_id.clone();
let maybe_asset_id = self
.runtime
.block_on(async { assets.get(&GetAsset { operation_id }).await })
.map_err(map_response_error)?
.response
.map(|response| response.asset_id)
.map(|id| id.parse::<u64>().map_err(RobloxApiError::MalformedAssetId));

match maybe_asset_id {
Some(id) => break id,
None if retry_count > MAX_RETRIES => break Err(RobloxApiError::AssetGetFailed),

_ => {
retry_count += 1;
std::thread::sleep(INITIAL_SLEEP_DURATION * retry_count.pow(BACKOFF));
}
}
}?;

Ok(RawUploadResponse {
success: true,
message: None,
asset_id: Some(asset_id),
backing_asset_id: Some(asset_id),
})
}

/// Upload an image, returning the raw response returned by the endpoint,
/// which may have further failures to handle.
fn upload_image_raw(
fn upload_image_legacy(
&mut self,
data: &ImageUploadData,
) -> Result<RawUploadResponse, RobloxApiError> {
Expand Down Expand Up @@ -322,4 +417,16 @@ pub enum RobloxApiError {

#[error("Group ID and user ID cannot both be specified")]
AmbiguousCreatorType,

#[error("Operation path is missing")]
MissingOperationPath,

#[error("Operation path is malformed")]
MalformedOperationPath,

#[error("Open Cloud API error")]
RbxCloud(#[from] RbxCloudError),

#[error("Failed to parse asset ID from asset get response")]
MalformedAssetId(#[from] std::num::ParseIntError),
}