Deploy Updates #19

Merged
Quaternions merged 41 commits from staging into master 2025-08-26 03:44:24 +00:00
11 changed files with 1220 additions and 393 deletions

784
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
workspace = { members = ["rbx_asset", "rox_compiler"] }
[package]
name = "asset-tool"
version = "0.4.12"
version = "0.5.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -1,6 +1,6 @@
[package]
name = "rbx_asset"
version = "0.4.4"
version = "0.5.0"
edition = "2021"
publish = ["strafesnet"]
repository = "https://git.itzana.me/StrafesNET/asset-tool"
@@ -11,14 +11,21 @@ authors = ["Rhys Lloyd <krakow20@gmail.com>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
default = ["gzip"]
default = ["gzip", "default-tls"]
gzip = ["dep:flate2"]
default-tls = ["reqwest/default-tls"]
rustls-tls = ["reqwest/rustls-tls"]
[dependencies]
bytes = "1.10.1"
chrono = { version = "0.4.38", features = ["serde"] }
flate2 = { version = "1.0.29", optional = true }
reqwest = { version = "0.12.4", features = ["json","multipart"] }
reqwest = { version = "0.12.4", features = [
"json", "multipart",
# default features
"charset", "http2", "system-proxy"
], default-features = false }
serde = { version = "1.0.199", features = ["derive"] }
serde_json = "1.0.111"
url = "2.5.0"

44
rbx_asset/src/body.rs Normal file
View File

@@ -0,0 +1,44 @@
use reqwest::Body;
pub trait ContentType:Into<Body>{
fn content_type(&self)->&'static str;
}
#[derive(Clone,Copy,Debug)]
pub struct Json<T>(pub(crate)T);
impl<T:Into<Body>> From<Json<T>> for Body{
fn from(Json(value):Json<T>)->Self{
value.into()
}
}
impl<T:Into<Body>> ContentType for Json<T>{
fn content_type(&self)->&'static str{
"application/json"
}
}
#[derive(Clone,Copy,Debug)]
pub struct Text<T>(pub(crate)T);
impl<T:Into<Body>> From<Text<T>> for Body{
fn from(Text(value):Text<T>)->Self{
value.into()
}
}
impl<T:Into<Body>> ContentType for Text<T>{
fn content_type(&self)->&'static str{
"text/plain"
}
}
#[derive(Clone,Copy,Debug)]
pub struct Binary<T>(pub(crate)T);
impl<T:Into<Body>> From<Binary<T>> for Body{
fn from(Binary(value):Binary<T>)->Self{
value.into()
}
}
impl<T:Into<Body>> ContentType for Binary<T>{
fn content_type(&self)->&'static str{
"application/octet-stream"
}
}

View File

@@ -1,3 +1,4 @@
use crate::body::{Binary,ContentType,Json};
use crate::util::{serialize_u64,deserialize_u64,response_ok};
use crate::types::{ResponseError,MaybeGzippedBytes};
@@ -117,8 +118,8 @@ impl std::fmt::Display for UpdateError{
}
impl std::error::Error for UpdateError{}
struct GetAssetOperationRequest{
operation_id:String,
struct GetAssetOperationRequest<'a>{
operation_id:&'a str,
}
pub struct GetAssetLatestRequest{
pub asset_id:u64,
@@ -172,7 +173,7 @@ pub struct GetAssetVersionRequest{
}
#[derive(Debug)]
pub enum GetError{
ParseError(url::ParseError),
Parse(url::ParseError),
Response(ResponseError),
Reqwest(reqwest::Error),
}
@@ -195,15 +196,22 @@ impl AssetLocation{
}
}
#[derive(Debug,serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetMetadata{
pub metadataType:u32,
pub value:String,
}
#[derive(Debug,serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetLocationInfo{
pub location:Option<AssetLocation>,
pub requestId:String,
pub IsHashDynamic:bool,
pub IsCopyrightProtected:bool,
pub isArchived:bool,
pub assetTypeId:u32,
#[serde(default)]
pub assetMetadatas:Vec<AssetMetadata>,
pub isRecordable:bool,
}
pub struct AssetVersionsRequest{
@@ -313,13 +321,119 @@ impl RobloxOperation{
pub async fn try_get_reponse(&self,context:&Context)->Result<serde_json::Value,OperationError>{
context.get_asset_operation(GetAssetOperationRequest{
operation_id:self.operation_id()
.ok_or(OperationError::NoOperationId)?
.to_owned(),
.ok_or(OperationError::NoOperationId)?,
}).await.map_err(OperationError::Get)?
.response.ok_or(OperationError::NotDone)
}
}
#[derive(Debug)]
pub enum LuauSessionError{
Get(GetError),
Unspecified,
NotDone,
NoOutput,
NoError,
}
impl std::fmt::Display for LuauSessionError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for LuauSessionError{}
#[derive(Debug,serde::Serialize)]
#[expect(nonstandard_style)]
pub struct LuauSessionCreate<'a>{
pub script:&'a str,
#[serde(skip_serializing_if="Option::is_none")]
pub user:Option<&'a str>,
#[serde(skip_serializing_if="Option::is_none")]
pub timeout:Option<&'a str>,
#[serde(skip_serializing_if="Option::is_none")]
pub binaryInput:Option<&'a str>,
#[serde(skip_serializing_if="Option::is_none")]
pub enableBinaryOutput:Option<bool>,
#[serde(skip_serializing_if="Option::is_none")]
pub binaryOutputUri:Option<&'a str>,
}
#[derive(Debug,serde::Deserialize)]
#[expect(nonstandard_style)]
pub enum LuauSessionState{
STATE_UNSPECIFIED,
PROCESSING,
COMPLETE,
FAILED,
}
#[derive(Debug,serde::Deserialize)]
pub struct LuauError{
pub code:String,
pub message:String,
}
#[derive(Debug,serde::Deserialize)]
pub struct LuauResults{
pub results:Vec<serde_json::Value>,
}
#[derive(Debug,serde::Deserialize)]
#[expect(nonstandard_style)]
pub struct LuauSessionResponse{
path:String,
#[serde(deserialize_with="deserialize_u64")]
pub user:u64,
pub state:LuauSessionState,
pub script:String,
pub error:Option<LuauError>,
pub output:Option<LuauResults>,
pub binaryInput:String,
pub enableBinaryOutput:bool,
pub binaryOutputUri:String,
}
impl LuauSessionResponse{
pub fn path(&self)->&str{
&self.path
}
pub async fn try_get_result(&self,context:&Context)->Result<Result<LuauResults,LuauError>,LuauSessionError>{
let response=context.get_luau_session(self).await.map_err(LuauSessionError::Get)?;
match response.state{
LuauSessionState::STATE_UNSPECIFIED=>Err(LuauSessionError::Unspecified),
LuauSessionState::PROCESSING=>Err(LuauSessionError::NotDone),
LuauSessionState::COMPLETE=>Ok(Ok(response.output.ok_or(LuauSessionError::NoOutput)?)),
LuauSessionState::FAILED=>Ok(Err(response.error.ok_or(LuauSessionError::NoError)?)),
}
}
}
pub trait AsSessionPath{
fn into_session_path(&self)->impl AsRef<str>;
}
impl AsSessionPath for LuauSessionResponse{
fn into_session_path(&self)->impl AsRef<str>{
&self.path
}
}
pub struct LuauSessionLatestRequest{
pub universe_id:u64,
pub place_id:u64,
}
impl AsSessionPath for LuauSessionLatestRequest{
fn into_session_path(&self)->impl AsRef<str>{
let universe_id=self.universe_id;
let place_id=self.place_id;
format!("universes/{universe_id}/places/{place_id}/luau-execution-session-tasks")
}
}
pub struct LuauSessionVersionRequest{
pub universe_id:u64,
pub place_id:u64,
pub version_id:u64,
}
impl AsSessionPath for LuauSessionVersionRequest{
fn into_session_path(&self)->impl AsRef<str>{
let universe_id=self.universe_id;
let place_id=self.place_id;
let version_id=self.version_id;
format!("universes/{universe_id}/places/{place_id}/versions/{version_id}/luau-execution-session-tasks")
}
}
#[derive(Clone)]
pub struct ApiKey(String);
impl ApiKey{
@@ -349,9 +463,10 @@ impl Context{
.header("x-api-key",self.api_key.as_str())
.send().await
}
async fn post(&self,url:url::Url,body:impl Into<reqwest::Body>+Clone)->Result<reqwest::Response,reqwest::Error>{
async fn post(&self,url:url::Url,body:impl ContentType)->Result<reqwest::Response,reqwest::Error>{
self.client.post(url)
.header("x-api-key",self.api_key.as_str())
.header("Content-Type",body.content_type())
.body(body)
.send().await
}
@@ -408,18 +523,38 @@ impl Context{
operation,
})
}
async fn get_asset_operation(&self,config:GetAssetOperationRequest)->Result<RobloxOperation,GetError>{
async fn get_asset_operation(&self,config:GetAssetOperationRequest<'_>)->Result<RobloxOperation,GetError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/operations/{}",config.operation_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json::<RobloxOperation>().await.map_err(GetError::Reqwest)
}
pub async fn create_luau_session(&self,config:&impl AsSessionPath,session:LuauSessionCreate<'_>)->Result<LuauSessionResponse,CreateError>{
let raw_url=format!("https://apis.roblox.com/cloud/v2/{}",config.into_session_path().as_ref());
let url=reqwest::Url::parse(raw_url.as_str()).map_err(CreateError::Parse)?;
let body=serde_json::to_string(&session).map_err(CreateError::Serialize)?;
response_ok(
self.post(url,Json(body)).await.map_err(CreateError::Reqwest)?
).await.map_err(CreateError::Response)?
.json::<LuauSessionResponse>().await.map_err(CreateError::Reqwest)
}
pub async fn get_luau_session(&self,config:&impl AsSessionPath)->Result<LuauSessionResponse,GetError>{
let raw_url=format!("https://apis.roblox.com/cloud/v2/{}",config.into_session_path().as_ref());
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json::<LuauSessionResponse>().await.map_err(GetError::Reqwest)
}
pub async fn get_asset_info(&self,config:GetAssetLatestRequest)->Result<AssetResponse,GetError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}",config.asset_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
@@ -428,7 +563,7 @@ impl Context{
}
pub async fn get_asset_version_info(&self,config:GetAssetVersionRequest)->Result<AssetResponse,GetError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}/versions/{}",config.asset_id,config.version);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
@@ -437,7 +572,7 @@ impl Context{
}
pub async fn get_asset_location(&self,config:GetAssetLatestRequest)->Result<AssetLocationInfo,GetError>{
let raw_url=format!("https://apis.roblox.com/asset-delivery-api/v1/assetId/{}",config.asset_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
@@ -446,7 +581,7 @@ impl Context{
}
pub async fn get_asset_version_location(&self,config:GetAssetVersionRequest)->Result<AssetLocationInfo,GetError>{
let raw_url=format!("https://apis.roblox.com/asset-delivery-api/v1/assetId/{}/version/{}",config.asset_id,config.version);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
@@ -454,7 +589,7 @@ impl Context{
.json().await.map_err(GetError::Reqwest)
}
pub async fn get_asset(&self,config:&AssetLocation)->Result<MaybeGzippedBytes,GetError>{
let url=reqwest::Url::parse(config.location()).map_err(GetError::ParseError)?;
let url=reqwest::Url::parse(config.location()).map_err(GetError::Parse)?;
let bytes=response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
@@ -497,7 +632,7 @@ impl Context{
}
response_ok(
self.post(url,body).await.map_err(UpdateError::Reqwest)?
self.post(url,Binary(body)).await.map_err(UpdateError::Reqwest)?
).await.map_err(UpdateError::Response)?
.json::<UpdatePlaceResponse>().await.map_err(UpdateError::Reqwest)
}

View File

@@ -1,3 +1,4 @@
use crate::body::{ContentType,Json};
use crate::util::response_ok;
use crate::types::{ResponseError,MaybeGzippedBytes};
@@ -140,10 +141,11 @@ impl GetAssetV2Location{
pub struct GetAssetV2Info{
pub locations:Vec<GetAssetV2Location>,
pub requestId:String,
pub IsHashDynamic:bool,
pub IsCopyrightProtected:bool,
pub isArchived:bool,
pub assetTypeId:u32,
pub isRecordable:Option<bool>,
pub IsHashDynamic:Option<bool>,
pub IsCopyrightProtected:Option<bool>,
}
pub struct GetAssetV2{
@@ -282,21 +284,21 @@ pub struct UserInventoryPageRequest{
#[derive(serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct UserInventoryItemOwner{
userId:u64,
username:String,
buildersClubMembershipType:u64,
pub userId:u64,
pub username:String,
pub buildersClubMembershipType:String,
}
#[derive(serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct UserInventoryItem{
userAssetId:u64,
assetId:u64,
assetName:String,
collectibleItemId:Option<String>,
collectibleItemInstanceId:Option<String>,
owner:UserInventoryItemOwner,
created:chrono::DateTime<chrono::Utc>,
updated:chrono::DateTime<chrono::Utc>,
pub userAssetId:u64,
pub assetId:u64,
pub assetName:String,
pub collectibleItemId:Option<String>,
pub collectibleItemInstanceId:Option<String>,
pub owner:UserInventoryItemOwner,
pub created:chrono::DateTime<chrono::Utc>,
pub updated:chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
@@ -306,6 +308,58 @@ pub struct UserInventoryPageResponse{
pub data:Vec<UserInventoryItem>,
}
#[derive(Debug)]
pub enum SetAssetsPermissionsError{
Parse(url::ParseError),
JSONEncode(serde_json::Error),
Patch(PostError),
Response(ResponseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for SetAssetsPermissionsError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for SetAssetsPermissionsError{}
#[derive(serde::Serialize)]
#[allow(nonstandard_style)]
struct AssetPermissions{
assetId:u64,
grantToDependencies:bool,//true
}
#[derive(serde::Serialize)]
#[allow(nonstandard_style)]
struct SetAssetsPermissions<'a>{
subjectType:&'a str,// "Universe"
subjectId:&'a str,// "4422715291"
action:&'a str,// "Use",
enableDeepAccessCheck:bool,//true,
requests:&'a [AssetPermissions],
}
pub struct SetAssetsPermissionsRequest<'a>{
pub universe_id:u64,
pub asset_ids:&'a [u64],
}
impl SetAssetsPermissionsRequest<'_>{
fn serialize(&self)->Result<String,serde_json::Error>{
let ref requests:Vec<_>=self.asset_ids.iter().map(|&asset_id|AssetPermissions{
assetId:asset_id,
grantToDependencies:true,
}).collect();
let ref subject_id=self.universe_id.to_string();
let ref permissions=SetAssetsPermissions{
subjectType:"Universe",
subjectId:subject_id,
action:"Use",
enableDeepAccessCheck:true,
requests,
};
serde_json::to_string(permissions)
}
}
#[derive(Clone)]
pub struct Cookie(String);
impl Cookie{
@@ -356,6 +410,29 @@ impl Context{
Ok(resp)
}
async fn patch(&self,url:url::Url,body:impl ContentType+Clone)->Result<reqwest::Response,PostError>{
let mut resp=self.client.patch(url.clone())
.header("Cookie",self.cookie.as_str())
.header("Content-Type",body.content_type())
.body(body.clone())
.send().await.map_err(PostError::Reqwest)?;
//This is called a CSRF challenge apparently
if resp.status()==reqwest::StatusCode::FORBIDDEN{
if let Some(csrf_token)=resp.headers().get("X-CSRF-Token"){
resp=self.client.patch(url)
.header("X-CSRF-Token",csrf_token)
.header("Cookie",self.cookie.as_str())
.header("Content-Type",body.content_type())
.body(body)
.send().await.map_err(PostError::Reqwest)?;
}else{
Err(PostError::CSRF)?;
}
}
Ok(resp)
}
pub async fn create(&self,config:CreateRequest,body:impl Into<reqwest::Body>+Clone)->Result<UploadResponse,CreateError>{
let mut url=reqwest::Url::parse("https://data.roblox.com/Data/Upload.ashx?json=1&type=Model&genreTypeId=1").map_err(CreateError::ParseError)?;
//url borrow scope
@@ -514,7 +591,7 @@ impl Context{
).await.map_err(GetError::Response)?
.json().await.map_err(GetError::Reqwest)
}
pub async fn get_asset_versions_page(&self,config:AssetVersionsPageRequest)->Result<AssetVersionsPageResponse,PageError>{
pub async fn get_asset_versions_page(&self,config:&AssetVersionsPageRequest)->Result<AssetVersionsPageResponse,PageError>{
let mut url=reqwest::Url::parse(format!("https://develop.roblox.com/v1/assets/{}/saved-versions",config.asset_id).as_str()).map_err(PageError::ParseError)?;
//url borrow scope
{
@@ -560,4 +637,16 @@ impl Context{
).await.map_err(PageError::Response)?
.json::<UserInventoryPageResponse>().await.map_err(PageError::Reqwest)
}
/// Used to enable an asset to be loaded onto a group game.
pub async fn set_assets_permissions(&self,config:SetAssetsPermissionsRequest<'_>)->Result<(),SetAssetsPermissionsError>{
let url=reqwest::Url::parse("https://apis.roblox.com/asset-permissions-api/v1/assets/permissions").map_err(SetAssetsPermissionsError::Parse)?;
let body=config.serialize().map_err(SetAssetsPermissionsError::JSONEncode)?;
response_ok(
self.patch(url,Json(body)).await.map_err(SetAssetsPermissionsError::Patch)?
).await.map_err(SetAssetsPermissionsError::Response)?;
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
pub mod cloud;
pub mod cookie;
pub mod types;
mod body;
mod util;

View File

@@ -1,14 +1,15 @@
#[allow(dead_code)]
#[derive(Debug)]
pub struct StatusCodeWithUrlAndBody{
pub status_code:reqwest::StatusCode,
pub struct UrlAndBody{
pub url:url::Url,
pub body:String,
}
#[derive(Debug)]
pub enum ResponseError{
Reqwest(reqwest::Error),
StatusCodeWithUrlAndBody(StatusCodeWithUrlAndBody),
Details{
status_code:reqwest::StatusCode,
url_and_body:Box<UrlAndBody>,
},
}
impl std::fmt::Display for ResponseError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -17,8 +18,6 @@ impl std::fmt::Display for ResponseError{
}
impl std::error::Error for ResponseError{}
#[cfg(feature="gzip")]
use std::io::Cursor;
#[cfg(feature="gzip")]
use flate2::read::GzDecoder;
@@ -44,7 +43,7 @@ impl MaybeGzippedBytes{
match self.bytes.get(0..2){
Some(b"\x1f\x8b")=>{
let mut buf=Vec::new();
GzDecoder::new(Cursor::new(self.bytes.as_ref())).read_to_end(&mut buf)?;
GzDecoder::new(self.bytes.as_ref()).read_to_end(&mut buf)?;
Ok(buf)
},
_=>Ok(self.bytes.to_vec())
@@ -57,12 +56,12 @@ impl MaybeGzippedBytes{
#[cfg(feature="gzip")]
pub fn read_with<'a,ReadGzip,ReadRaw,T>(&'a self,read_gzip:ReadGzip,read_raw:ReadRaw)->T
where
ReadGzip:Fn(GzDecoder<Cursor<&'a [u8]>>)->T,
ReadRaw:Fn(Cursor<&'a [u8]>)->T,
ReadGzip:Fn(GzDecoder<&'a [u8]>)->T,
ReadRaw:Fn(&'a [u8])->T,
{
match self.bytes.get(0..2){
Some(b"\x1f\x8b")=>read_gzip(GzDecoder::new(Cursor::new(self.bytes.as_ref()))),
_=>read_raw(Cursor::new(self.bytes.as_ref()))
Some(b"\x1f\x8b")=>read_gzip(GzDecoder::new(self.bytes.as_ref())),
_=>read_raw(self.bytes.as_ref())
}
}
}

View File

@@ -1,4 +1,4 @@
use crate::types::{ResponseError,StatusCodeWithUrlAndBody};
use crate::types::{ResponseError,UrlAndBody};
// lazy function to draw out meaningful info from http response on failure
pub(crate) async fn response_ok(response:reqwest::Response)->Result<reqwest::Response,ResponseError>{
@@ -9,11 +9,10 @@ pub(crate) async fn response_ok(response:reqwest::Response)->Result<reqwest::Res
let url=response.url().to_owned();
let bytes=response.bytes().await.map_err(ResponseError::Reqwest)?;
let body=String::from_utf8_lossy(&bytes).to_string();
Err(ResponseError::StatusCodeWithUrlAndBody(StatusCodeWithUrlAndBody{
Err(ResponseError::Details{
status_code,
url,
body,
}))
url_and_body:Box::new(UrlAndBody{url,body})
})
}
}

View File

@@ -16,7 +16,6 @@ use crate::common::{sanitize,Style,PropertiesOverride};
//I could use a function!
//eventually:
#[derive(Debug)]
#[allow(dead_code)]//idk why this thinks it's dead code, the errors are printed out in various places
pub enum QueryResolveError{
NotFound,//0 results
Ambiguous,//>1 results

View File

@@ -1,7 +1,8 @@
use std::{io::Read,path::PathBuf};
use std::io::Read;
use std::path::{Path,PathBuf};
use clap::{Args,Parser,Subcommand};
use anyhow::{anyhow,Result as AResult};
use futures::StreamExt;
use futures::{StreamExt,TryStreamExt};
use rbx_asset::cloud::{ApiKey,Context as CloudContext};
use rbx_asset::cookie::{Cookie,Context as CookieContext,AssetVersion,CreationsItem};
@@ -9,6 +10,7 @@ type AssetID=u64;
type AssetIDFileMap=Vec<(AssetID,PathBuf)>;
const CONCURRENT_DECODE:usize=8;
const CONCURRENT_REQUESTS:usize=32;
const CONCURRENT_FS:usize=64;
#[derive(Parser)]
#[command(author,version,about,long_about=None)]
@@ -24,9 +26,12 @@ enum Commands{
DownloadHistory(DownloadHistorySubcommand),
Download(DownloadSubcommand),
DownloadVersion(DownloadVersionSubcommand),
DownloadLocation(DownloadLocationSubcommand),
DownloadVersionLocation(DownloadVersionLocationSubcommand),
DownloadVersionV2(DownloadVersionSubcommand),
DownloadDecompile(DownloadDecompileSubcommand),
DownloadCreationsJson(DownloadCreationsJsonSubcommand),
DownloadCreationsHistory(DownloadCreationsHistorySubcommand),
DownloadUserInventoryJson(DownloadUserInventoryJsonSubcommand),
CreateAsset(CreateAssetSubcommand),
CreateAssetMedia(CreateAssetMediaSubcommand),
@@ -40,6 +45,7 @@ enum Commands{
Decompile(DecompileSubcommand),
DecompileHistoryIntoGit(DecompileHistoryIntoGitSubcommand),
DownloadAndDecompileHistoryIntoGit(DownloadAndDecompileHistoryIntoGitSubcommand),
RunLuau(RunLuauSubcommand),
}
/// Download a range of assets from the asset version history. Download summary is saved to `output_folder/versions.json`, and can be optionally used to download only new versions the next time.
@@ -104,6 +110,32 @@ struct DownloadVersionSubcommand{
#[arg(long)]
asset_version:Option<u64>,
}
/// Get download urls for a list of assets by id.
#[derive(Args)]
struct DownloadLocationSubcommand{
#[arg(long,group="api_key",required=true)]
api_key_literal:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_envvar:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_file:Option<PathBuf>,
#[arg(required=true)]
asset_ids:Vec<AssetID>,
}
/// Get a download url for a single asset by id, optionally specifying the version to download.
#[derive(Args)]
struct DownloadVersionLocationSubcommand{
#[arg(long,group="api_key",required=true)]
api_key_literal:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_envvar:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_file:Option<PathBuf>,
#[arg(long)]
asset_id:AssetID,
#[arg(long)]
asset_version:Option<u64>,
}
/// Download the list of asset ids (not the assets themselves) created by a group or user. The output is written to `output_folder/versions.json`
#[derive(Args)]
struct DownloadCreationsJsonSubcommand{
@@ -119,6 +151,8 @@ struct DownloadCreationsJsonSubcommand{
group_id:Option<u64>,
#[arg(long,group="owner",required=true)]
user_id:Option<u64>,
#[arg(long)]
continue_from_cursor:Option<bool>,
}
/// Download the list of asset ids (not the assets themselves) in a user's inventory. The output is written to `output_folder/versions.json`
#[derive(Args)]
@@ -399,6 +433,24 @@ struct DownloadAndDecompileHistoryIntoGitSubcommand{
#[arg(long)]
write_scripts:Option<bool>,
}
/// Run a Luau script.
#[derive(Args)]
struct RunLuauSubcommand{
#[arg(long,group="api_key",required=true)]
api_key_literal:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_envvar:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_file:Option<PathBuf>,
#[arg(long,group="script",required=true)]
script_literal:Option<String>,
#[arg(long,group="script",required=true)]
script_file:Option<PathBuf>,
#[arg(long)]
universe_id:u64,
#[arg(long)]
place_id:u64,
}
#[derive(Clone,Copy,Debug,clap::ValueEnum)]
enum Style{
@@ -489,6 +541,27 @@ async fn main()->AResult<()>{
},
).await
},
Commands::DownloadLocation(subcommand)=>{
download_list_locations(
api_key_from_args(
subcommand.api_key_literal,
subcommand.api_key_envvar,
subcommand.api_key_file,
).await?,
&subcommand.asset_ids
).await
},
Commands::DownloadVersionLocation(subcommand)=>{
download_location(
api_key_from_args(
subcommand.api_key_literal,
subcommand.api_key_envvar,
subcommand.api_key_file,
).await?,
subcommand.asset_id,
subcommand.asset_version,
).await
},
Commands::DownloadVersionV2(subcommand)=>{
let output_folder=subcommand.output_folder.unwrap_or_else(||std::env::current_dir().unwrap());
download_version_v2(
@@ -532,7 +605,9 @@ async fn main()->AResult<()>{
subcommand.group_id,
)?,
subcommand.output_folder.unwrap_or_else(||std::env::current_dir().unwrap()),
subcommand.continue_from_cursor.unwrap_or(false),
).await,
Commands::DownloadCreationsHistory(subcommand)=>subcommand.run().await,
Commands::DownloadUserInventoryJson(subcommand)=>download_user_inventory_json(
cookie_from_args(
subcommand.cookie_literal,
@@ -689,6 +764,21 @@ async fn main()->AResult<()>{
write_models:subcommand.write_models.unwrap_or(false),
write_scripts:subcommand.write_scripts.unwrap_or(true),
}).await,
Commands::RunLuau(subcommand)=>run_luau(RunLuauConfig{
api_key:api_key_from_args(
subcommand.api_key_literal,
subcommand.api_key_envvar,
subcommand.api_key_file,
).await?,
script:match subcommand.script_literal{
Some(script)=>script,
None=>std::fs::read_to_string(subcommand.script_file.unwrap())?,
},
request:rbx_asset::cloud::LuauSessionLatestRequest{
place_id:subcommand.place_id,
universe_id:subcommand.universe_id,
},
}).await,
}
}
@@ -756,10 +846,10 @@ async fn get_asset_exp_backoff(
context:&CloudContext,
asset_operation:&rbx_asset::cloud::AssetOperation
)->Result<rbx_asset::cloud::AssetResponse,rbx_asset::cloud::AssetOperationError>{
const BACKOFF_MUL:f32=1.3956124250860895286;//exp(1/3)
const BACKOFF_MUL:f32=1.395_612_5;//exp(1/3)
let mut backoff=1000f32;
loop{
match asset_operation.try_get_asset(&context).await{
match asset_operation.try_get_asset(context).await{
//try again when the operation is not done
Err(rbx_asset::cloud::AssetOperationError::Operation(rbx_asset::cloud::OperationError::NotDone))=>(),
//return all other results
@@ -802,7 +892,7 @@ struct CreateAssetMediasConfig{
}
#[derive(Debug)]
#[allow(dead_code)]
#[expect(dead_code)]
enum CreateAssetMediasError{
NoFileStem(PathBuf),
IO(std::io::Error),
@@ -817,7 +907,7 @@ impl std::fmt::Display for CreateAssetMediasError{
impl std::error::Error for CreateAssetMediasError{}
#[derive(Debug)]
#[allow(dead_code)]
#[expect(dead_code)]
enum PollOperationError{
CreateAssetMedias(CreateAssetMediasError),
AssetOperation(rbx_asset::cloud::AssetOperationError),
@@ -830,7 +920,7 @@ impl std::fmt::Display for PollOperationError{
impl std::error::Error for PollOperationError{}
#[derive(Debug)]
#[allow(dead_code)]
#[expect(dead_code)]
enum DownloadDecalError{
PollOperation(PollOperationError),
ParseInt(std::num::ParseIntError),
@@ -1029,40 +1119,142 @@ async fn download_list(cookie:Cookie,asset_id_file_map:AssetIDFileMap)->AResult<
}))
.buffer_unordered(CONCURRENT_REQUESTS)
.for_each(|b:AResult<_>|async{
match b{
Ok((dest,maybe_gzip))=>if let Err(e)=(async||{tokio::fs::write(dest,maybe_gzip.to_vec()?).await})().await{
eprintln!("fs error: {}",e);
},
Err(e)=>eprintln!("dl error: {}",e),
}
}).await;
match b{
Ok((dest,maybe_gzip))=>if let Err(e)=async{tokio::fs::write(dest,maybe_gzip.to_vec()?).await}.await{
eprintln!("fs error: {}",e);
},
Err(e)=>eprintln!("dl error: {}",e),
}
}).await;
Ok(())
}
async fn get_creations_pages(context:&CookieContext,owner:rbx_asset::cookie::Owner)->AResult<Vec<CreationsItem>>{
let mut config=rbx_asset::cookie::CreationsPageRequest{
owner,
cursor:None,
async fn download_list_locations(api_key:ApiKey,asset_id_file_map:&[u64])->AResult<()>{
let context=CloudContext::new(api_key);
futures::stream::iter(asset_id_file_map)
.map(|&asset_id|
context.get_asset_location(rbx_asset::cloud::GetAssetLatestRequest{asset_id})
)
.buffer_unordered(CONCURRENT_REQUESTS)
.for_each(|result|async{
match result{
Ok(asset_location_info)=>match asset_location_info.location{
Some(location)=>println!("{}",location.location()),
None=>println!("This asset is private!"),
},
Err(e)=>eprintln!("dl error: {}",e),
}
}).await;
Ok(())
}
async fn download_location(api_key:ApiKey,asset_id:AssetID,version:Option<u64>)->AResult<()>{
let context=CloudContext::new(api_key);
let asset_location_info=match version{
Some(version)=>context.get_asset_version_location(rbx_asset::cloud::GetAssetVersionRequest{asset_id,version}).await?,
None=>context.get_asset_location(rbx_asset::cloud::GetAssetLatestRequest{asset_id}).await?,
};
let mut asset_list=Vec::new();
match asset_location_info.location{
Some(location)=>println!("{}",location.location()),
None=>println!("This asset is private!"),
}
Ok(())
}
async fn get_creations_pages(
context:&CookieContext,
asset_list:&mut Vec<rbx_asset::cookie::CreationsItem>,
config:&mut rbx_asset::cookie::CreationsPageRequest,
)->AResult<()>{
loop{
let mut page=context.get_creations_page(&config).await?;
asset_list.append(&mut page.data);
if page.nextPageCursor.is_none(){
config.cursor=page.nextPageCursor;
if config.cursor.is_none(){
break;
}
config.cursor=page.nextPageCursor;
}
Ok(())
}
async fn download_creations_pages_from_checkpoint(context:&CookieContext,owner:rbx_asset::cookie::Owner,output_folder:&Path,continue_from_cursor:bool)->AResult<Vec<CreationsItem>>{
let mut versions_path=output_folder.to_owned();
versions_path.set_file_name("versions.json");
let mut cursor_path=output_folder.to_owned();
cursor_path.set_file_name("cursor");
let (mut asset_list,mut config)=if continue_from_cursor{
// load state from files
let (versions,cursor)=tokio::join!(
tokio::fs::read(versions_path.as_path()),
tokio::fs::read_to_string(cursor_path.as_path()),
);
// allow versions to not exist
let (versions,cursor)=match (versions,cursor){
// continue downloading
(Ok(versions),Ok(cursor))=>(serde_json::from_slice(&versions)?,Some(cursor)),
// already downloaded
(Ok(versions),Err(e)) if matches!(e.kind(),std::io::ErrorKind::NotFound)=>return Ok(serde_json::from_slice(&versions)?),
// not downloaded
(Err(e),result) if matches!(e.kind(),std::io::ErrorKind::NotFound)=>{
match result{
Ok(_)=>{},
Err(e) if matches!(e.kind(),std::io::ErrorKind::NotFound)=>{},
Err(e)=>Err(e)?,
}
(Vec::new(),None)
},
// other errors
(Ok(_),Err(e))=>Err(e)?,
(Err(e),_)=>Err(e)?,
};
(
versions,
rbx_asset::cookie::CreationsPageRequest{
owner,
cursor,
}
)
}else{
// create new state
(
Vec::new(),
rbx_asset::cookie::CreationsPageRequest{
owner,
cursor:None,
}
)
};
get_creations_pages(&context,&mut asset_list,&mut config).await?;
let cursor_fut=async{
if let Some(cursor)=config.cursor{
println!("writing cursor state...");
// there was a problem, write out cursor
tokio::fs::write(cursor_path,cursor).await?;
}else{
// no cursor
if let Err(e)=tokio::fs::remove_file(cursor_path).await{
match e.kind(){
std::io::ErrorKind::NotFound=>println!("Cannot delete cursor: file not found"),
_=>Err(e)?,
}
}
}
Ok(())
};
let versions_fut=tokio::fs::write(versions_path,serde_json::to_string(&asset_list)?);
tokio::try_join!(versions_fut,cursor_fut)?;
Ok(asset_list)
}
async fn download_creations_json(cookie:Cookie,owner:rbx_asset::cookie::Owner,output_folder:PathBuf)->AResult<()>{
async fn download_creations_json(cookie:Cookie,owner:rbx_asset::cookie::Owner,output_folder:PathBuf,continue_from_cursor:bool)->AResult<()>{
let context=CookieContext::new(cookie);
let item_list=get_creations_pages(&context,owner).await?;
let mut path=output_folder.clone();
path.set_file_name("versions.json");
tokio::fs::write(path,serde_json::to_string(&item_list)?).await?;
download_creations_pages_from_checkpoint(&context,owner,output_folder.as_path(),continue_from_cursor).await?;
Ok(())
}
@@ -1073,7 +1265,7 @@ async fn get_user_inventory_pages(
config:&mut rbx_asset::cookie::UserInventoryPageRequest,
)->AResult<()>{
loop{
let page=context.get_user_inventory_page(&config).await?;
let page=context.get_user_inventory_page(config).await?;
asset_list.extend(page.data);
config.cursor=page.nextPageCursor;
if config.cursor.is_none(){
@@ -1087,7 +1279,7 @@ async fn download_user_inventory_json(cookie:Cookie,user_id:u64,output_folder:Pa
let mut versions_path=output_folder.clone();
versions_path.set_file_name("versions.json");
let mut cursor_path=output_folder.clone();
cursor_path.set_file_name("cursor.json");
cursor_path.set_file_name("cursor");
let context=CookieContext::new(cookie);
@@ -1135,18 +1327,163 @@ async fn download_user_inventory_json(cookie:Cookie,user_id:u64,output_folder:Pa
Ok(())
}
/// Download all versions of all assets created by a group or user. The output is written to a folder structure in the output directory.
#[derive(Args)]
struct DownloadCreationsHistorySubcommand{
#[arg(long,group="cookie",required=true)]
cookie_literal:Option<String>,
#[arg(long,group="cookie",required=true)]
cookie_envvar:Option<String>,
#[arg(long,group="cookie",required=true)]
cookie_file:Option<PathBuf>,
#[arg(long,group="api_key",required=true)]
api_key_literal:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_envvar:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_file:Option<PathBuf>,
#[arg(long)]
output_folder:Option<PathBuf>,
#[arg(long,group="owner",required=true)]
group_id:Option<u64>,
#[arg(long,group="owner",required=true)]
user_id:Option<u64>,
#[arg(long)]
r#continue:Option<bool>,
}
impl DownloadCreationsHistorySubcommand{
async fn run(self)->AResult<()>{
download_creations_history(
cookie_from_args(
self.cookie_literal,
self.cookie_envvar,
self.cookie_file,
).await?,
api_key_from_args(
self.api_key_literal,
self.api_key_envvar,
self.api_key_file,
).await?,
owner_from_args(
self.user_id,
self.group_id,
)?,
self.output_folder.unwrap_or_else(||std::env::current_dir().unwrap()),
self.r#continue.unwrap_or(false),
).await
}
}
async fn download_creations_history(cookie:Cookie,api_key:ApiKey,owner:rbx_asset::cookie::Owner,output_folder:PathBuf,r#continue:bool)->AResult<()>{
let cookie_context=CookieContext::new(cookie);
let cloud_context=CloudContext::new(api_key);
// get list of all assets in inventory
let asset_list=download_creations_pages_from_checkpoint(&cookie_context,owner,output_folder.as_path(),r#continue).await?;
// create folder directories
let asset_folders:Vec<PathBuf> ={
futures::stream::iter(asset_list.iter().map(|asset|async{
// create asset folder
let mut asset_folder=output_folder.clone();
asset_folder.push(asset.id.to_string());
tokio::fs::create_dir_all(asset_folder.as_path()).await?;
Ok::<_,anyhow::Error>(asset_folder)
}))
.buffered(CONCURRENT_FS)
.try_collect().await?
};
#[expect(dead_code)]
#[derive(Debug)]
enum Error<'a>{
NoLocations(Job<'a>),
GetVersionLocationError(rbx_asset::cloud::GetError),
GetError(rbx_asset::cloud::GetError),
Io(std::io::Error),
}
#[derive(Clone,Copy,Debug)]
struct Job<'a>{
path:&'a PathBuf,
asset_id:u64,
asset_version:u64,
}
let mut job_list=Vec::new();
// create flattened futures stream to parallel download all asset versions
for (path,asset) in asset_folders.iter().zip(asset_list){
// save versions file
let mut versions_path=path.to_owned();
versions_path.push("versions.json");
let version_history=if r#continue{
let file=tokio::fs::read(versions_path.as_path()).await?;
serde_json::from_slice(&file)?
}else{
println!("Downloading history for {} - {}",asset.id,asset.name);
let version_history=get_version_history(&cookie_context,asset.id).await?;
println!("Found {} versions",version_history.len());
tokio::fs::write(versions_path,serde_json::to_string(&version_history)?).await?;
version_history
};
job_list.extend(version_history.into_iter().map(|asset_version|
Job{
path,
asset_id:asset.id,
asset_version:asset_version.assetVersionNumber,
}
));
}
println!("Completed jobs list. Number of jobs: {}",job_list.len());
futures::stream::iter(job_list).map(async|job|{
let mut dest=job.path.to_owned();
dest.push(format!("{}_v{}.rbxl",job.asset_id,job.asset_version));
//if the file already exists, don't try downloading it again
if tokio::fs::try_exists(dest.as_path()).await.map_err(Error::Io)?{
return Ok(());
}
let location=cloud_context.get_asset_version_location(rbx_asset::cloud::GetAssetVersionRequest{
asset_id:job.asset_id,
version:job.asset_version,
}).await.map_err(Error::GetVersionLocationError)?;
let location=location.location.ok_or(Error::NoLocations(job))?;
let downloaded=cloud_context.get_asset(&location).await.map_err(Error::GetError)?;
tokio::fs::write(dest,downloaded.to_vec().map_err(Error::Io)?).await.map_err(Error::Io)?;
Ok(())
})
.buffer_unordered(CONCURRENT_REQUESTS)
.for_each(async|result|{
match result{
Ok(())=>{},
Err(Error::NoLocations(job))=>println!("Job failed due to no locations: asset_id={} version={}",job.asset_id,job.asset_version),
Err(e)=>println!("Error: {e:?}"),
}
}).await;
println!("All jobs complete.");
Ok(())
}
async fn get_version_history(context:&CookieContext,asset_id:AssetID)->AResult<Vec<AssetVersion>>{
let mut cursor:Option<String>=None;
let mut page_request=rbx_asset::cookie::AssetVersionsPageRequest{
asset_id,
cursor:None,
};
let mut asset_list=Vec::new();
loop{
let mut page=context.get_asset_versions_page(rbx_asset::cookie::AssetVersionsPageRequest{asset_id,cursor}).await?;
let mut page=context.get_asset_versions_page(&page_request).await?;
asset_list.append(&mut page.data);
if page.nextPageCursor.is_none(){
break;
}
cursor=page.nextPageCursor;
page_request.cursor=page.nextPageCursor;
}
asset_list.sort_by(|a,b|a.assetVersionNumber.cmp(&b.assetVersionNumber));
asset_list.sort_by_key(|a|a.assetVersionNumber);
Ok(asset_list)
}
@@ -1202,9 +1539,12 @@ async fn download_history(mut config:DownloadHistoryConfig)->AResult<()>{
let mut join_set=tokio::task::JoinSet::new();
//poll paged list of all asset versions
let mut cursor:Option<String>=None;
let mut page_request=rbx_asset::cookie::AssetVersionsPageRequest{
asset_id:config.asset_id,
cursor:None,
};
loop{
let mut page=context.get_asset_versions_page(rbx_asset::cookie::AssetVersionsPageRequest{asset_id:config.asset_id,cursor}).await?;
let mut page=context.get_asset_versions_page(&page_request).await?;
let context=&context;
let output_folder=config.output_folder.clone();
let data=&page.data;
@@ -1263,10 +1603,10 @@ async fn download_history(mut config:DownloadHistoryConfig)->AResult<()>{
}else{
asset_list.append(&mut page.data);
}
cursor=page.nextPageCursor;
page_request.cursor=page.nextPageCursor;
}
asset_list.sort_by(|a,b|a.assetVersionNumber.cmp(&b.assetVersionNumber));
asset_list.sort_by_key(|a|a.assetVersionNumber);
let mut path=config.output_folder.clone();
path.set_file_name("versions.json");
@@ -1280,7 +1620,7 @@ async fn download_history(mut config:DownloadHistoryConfig)->AResult<()>{
}
#[derive(Debug)]
#[allow(dead_code)]
#[expect(dead_code)]
enum LoadDomError{
IO(std::io::Error),
RbxBinary(rbx_binary::DecodeError),
@@ -1663,3 +2003,43 @@ async fn compile_upload_place(config:CompileUploadPlaceConfig)->AResult<()>{
println!("UploadResponse={:?}",resp);
Ok(())
}
async fn get_luau_result_exp_backoff(
context:&CloudContext,
luau_session:&rbx_asset::cloud::LuauSessionResponse
)->Result<Result<rbx_asset::cloud::LuauResults,rbx_asset::cloud::LuauError>,rbx_asset::cloud::LuauSessionError>{
const BACKOFF_MUL:f32=1.395_612_5;//exp(1/3)
let mut backoff=1000f32;
loop{
match luau_session.try_get_result(context).await{
//try again when the operation is not done
Err(rbx_asset::cloud::LuauSessionError::NotDone)=>(),
//return all other results
other_result=>return other_result,
}
println!("Operation not complete; waiting {:.0}ms...",backoff);
tokio::time::sleep(std::time::Duration::from_millis(backoff as u64)).await;
backoff*=BACKOFF_MUL;
}
}
struct RunLuauConfig{
api_key:ApiKey,
script:String,
request:rbx_asset::cloud::LuauSessionLatestRequest,
}
async fn run_luau(config:RunLuauConfig)->AResult<()>{
let context=CloudContext::new(config.api_key);
let session=rbx_asset::cloud::LuauSessionCreate{
script:&config.script,
user:None,
timeout:None,
binaryInput:None,
enableBinaryOutput:None,
binaryOutputUri:None,
};
let response=context.create_luau_session(&config.request,session).await?;
dbg!(&response);
let result=get_luau_result_exp_backoff(&context,&response).await?;
dbg!(&result);
Ok(())
}