46 Commits

Author SHA1 Message Date
38106f92d5 update deps 2025-04-09 17:47:23 -07:00
df2a5bb9ce rbx_asset: v0.4.3 optional AssetLocation 2025-04-08 16:54:59 -07:00
e40041a894 rbx_asset: change api for asset location again 2025-04-08 16:54:14 -07:00
45c1e52c0f rbx_asset: v0.4.2 2025-04-05 14:53:47 -07:00
0196f47374 rbx_asset: description is optional 2025-04-05 14:53:32 -07:00
a8163014ad rbx_asset: v0.4.1 2025-04-05 14:23:26 -07:00
4d26e7ad19 rbx_asset: discover asset location 2025-04-05 14:21:57 -07:00
99077cf467 rbx_asset: v0.4.0 2025-04-05 13:51:16 -07:00
302603998e rbx_asset: cloud: implement new asset-delivery-api 2025-04-05 13:09:59 -07:00
71cae5c089 rbx_asset: cloud: tweak asset info requests, remove get_asset 2025-04-05 13:09:25 -07:00
fb9dd8660d rbx_asset: deduplicate common code 2025-04-05 12:59:44 -07:00
64e4887b83 rbx_asset: use #[serde(default)] instead of Option<Vec<_>> 2025-04-05 12:59:24 -07:00
d125829a00 rbx_asset: rename CloudContext & CookieContext to Context 2025-04-05 12:23:39 -07:00
5509fd2166 rbx_asset: context fields should be private 2025-04-05 12:23:13 -07:00
e17db96e86 asset-tool v0.4.11 continue feature for DownloadUserInventoryJson 2025-04-04 13:53:19 -07:00
875b059074 asset-tool: tweak get_user_inventory_pages 2025-04-04 13:53:19 -07:00
9b1c709e7c asset-tool: add continue to DownloadUserInventoryJson 2025-04-04 13:36:18 -07:00
ebd48269b8 rbx_asset: v0.3.4 UploadResponse.AssetVersion 2025-04-03 16:14:33 -07:00
5a4ac0e7f2 rbx_asset: include asset version in UploadResponse 2025-04-03 16:13:45 -07:00
68ebbad7a7 rbx_asset: v0.3.3 optional field 2025-04-03 13:56:10 -07:00
c99b752738 rbx_asset: optional field on AssetDetails 2025-04-03 13:56:02 -07:00
4f798e5f07 rbx_asset: v0.3.1 fix missing field 2025-04-03 13:55:56 -07:00
39fa74d44a rbx_asset: fix missing field on GetAssetV2Location 2025-04-03 13:55:56 -07:00
edb5ea7648 rbx_asset: v0.3.1 CreatorType traits 2025-04-03 12:57:17 -07:00
504ff40385 rbx_asset: derive additional traits for CreatorType 2025-04-03 12:56:42 -07:00
89c0ee2cc2 update deps 2025-04-03 12:53:09 -07:00
f7e565bd0b asset-tool: v0.4.10 2025-04-03 12:47:02 -07:00
fe8062d8e0 rbx_asset: v0.3.0 add CreatorType + get_asset_details + get_asset_v2 2025-04-03 12:47:02 -07:00
e6fe04aa73 asset-tool: add AssetDetails 2025-04-03 12:47:02 -07:00
656de62bdc asset-tool: add DownloadVersionV2 2025-04-03 12:47:02 -07:00
4d90a74a82 rbx_asset: get_asset_v2 2025-04-03 12:47:02 -07:00
fb6fb67954 rbx_asset: tweak get_asset 2025-04-03 12:45:18 -07:00
b0f1e964a6 rbx_asset: add get_asset_details 2025-04-03 12:45:18 -07:00
aea777ecd3 rbx_asset: add CreatorType 2025-04-03 12:43:16 -07:00
6eca84a08a DownloadVersion command 2025-02-12 14:55:19 -08:00
b7bab46e04 v0.4.9 roblox error message in body 2025-02-11 13:53:16 -08:00
0413767ef9 v0.2.6 roblox error message in body 2025-02-11 13:50:31 -08:00
b69698fd8e rbx_asset: special error for special roblox 2025-02-11 13:43:11 -08:00
309fc2494d update deps 2025-02-11 13:31:14 -08:00
b352707b99 rbx_asset v0.2.5 better error info 2024-12-13 19:55:45 -08:00
81f411272f rbx_asset keep typed UploadResponse 2024-12-13 19:55:19 -08:00
e45f3c2cf9 cloud: use response_ok helper function 2024-12-13 19:35:31 -08:00
3cd65158a0 update deps 2024-12-13 19:19:04 -08:00
38d78ff2c5 cookie: refactor http errors to include more useful information 2024-12-13 19:18:35 -08:00
c49f9e4dd3 cookie: prepend in library 2024-12-13 19:01:37 -08:00
a99b5a2666 update Roblox API 2024-10-23 10:43:59 -07:00
9 changed files with 1420 additions and 521 deletions

1221
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,7 +1,7 @@
workspace = { members = ["rbx_asset", "rox_compiler"] }
[package]
name = "asset-tool"
version = "0.4.7"
version = "0.4.11"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -10,14 +10,13 @@ edition = "2021"
anyhow = "1.0.75"
clap = { version = "4.4.2", features = ["derive"] }
futures = "0.3.30"
git2 = "0.18.1"
git2 = "0.20.0"
lazy-regex = "3.1.0"
pollster = "0.3.0"
rbx_asset = { path = "rbx_asset" }
rbx_binary = "0.7.4"
rbx_dom_weak = "2.7.0"
rbx_reflection_database = "0.2.10"
rbx_xml = "0.13.3"
rbx_binary = "1.0.0"
rbx_dom_weak = "3.0.0"
rbx_reflection_database = "1.0.1"
rbx_xml = "1.0.0"
rox_compiler = { path = "rox_compiler" }
serde_json = "1.0.111"
tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread", "fs"] }

@ -1,6 +1,6 @@
[package]
name = "rbx_asset"
version = "0.2.3"
version = "0.4.3"
edition = "2021"
publish = ["strafesnet"]
repository = "https://git.itzana.me/StrafesNET/asset-tool"

@ -1,3 +1,5 @@
use crate::{ResponseError,ReaderType,maybe_gzip_decode,read_readable};
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub enum AssetType{
@ -29,7 +31,7 @@ pub struct AssetOperation{
operation:RobloxOperation,
}
impl AssetOperation{
pub async fn try_get_asset(&self,context:&CloudContext)->Result<AssetResponse,AssetOperationError>{
pub async fn try_get_asset(&self,context:&Context)->Result<AssetResponse,AssetOperationError>{
serde_json::from_value(
self.operation
.try_get_reponse(context).await
@ -40,6 +42,7 @@ impl AssetOperation{
#[derive(Debug)]
pub enum CreateError{
Parse(url::ParseError),
Response(ResponseError),
Serialize(serde_json::Error),
Reqwest(reqwest::Error),
}
@ -102,6 +105,7 @@ pub struct UpdatePlaceResponse{
#[derive(Debug)]
pub enum UpdateError{
ParseError(url::ParseError),
Response(ResponseError),
SerializeError(serde_json::Error),
Reqwest(reqwest::Error),
}
@ -115,7 +119,7 @@ impl std::error::Error for UpdateError{}
struct GetAssetOperationRequest{
operation_id:String,
}
pub struct GetAssetInfoRequest{
pub struct GetAssetLatestRequest{
pub asset_id:u64,
}
/*
@ -145,28 +149,25 @@ pub struct AssetResponse{
pub assetId:String,//u64 wrapped in quotes wohoo!!
pub assetType:AssetType,
pub creationContext:CreationContext,
pub description:String,
pub description:Option<String>,
pub displayName:String,
pub path:String,
pub revisionCreateTime:chrono::DateTime<chrono::Utc>,
pub revisionId:String,//u64
pub moderationResult:ModerationResult,
pub icon:Option<String>,
pub previews:Option<Vec<Preview>>,
#[serde(default)]
pub previews:Vec<Preview>,
}
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetVersionRequest{
pub asset_id:u64,
pub version:u64,
}
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetRequest{
pub asset_id:u64,
pub version:Option<u64>,
}
#[derive(Debug)]
pub enum GetError{
ParseError(url::ParseError),
Response(ResponseError),
Reqwest(reqwest::Error),
IO(std::io::Error)
}
@ -177,6 +178,29 @@ impl std::fmt::Display for GetError{
}
impl std::error::Error for GetError{}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
pub struct AssetLocation(
// the location is private so users cannot mutate it
String
);
impl AssetLocation{
pub fn location(&self)->&str{
let Self(location)=self;
location
}
}
#[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,
}
pub struct AssetVersionsRequest{
pub asset_id:u64,
pub cursor:Option<String>,
@ -203,6 +227,7 @@ pub struct AssetVersionsResponse{
#[derive(Debug)]
pub enum AssetVersionsError{
ParseError(url::ParseError),
Response(ResponseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for AssetVersionsError{
@ -238,6 +263,7 @@ pub struct InventoryPageResponse{
#[derive(Debug)]
pub enum InventoryPageError{
ParseError(url::ParseError),
Response(ResponseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for InventoryPageError{
@ -279,7 +305,7 @@ impl RobloxOperation{
None=>self.path.as_deref()?.get(11..),
}
}
pub async fn try_get_reponse(&self,context:&CloudContext)->Result<serde_json::Value,OperationError>{
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)?
@ -289,25 +315,6 @@ impl RobloxOperation{
}
}
//idk how to do this better
enum ReaderType<R:std::io::Read>{
GZip(flate2::read::GzDecoder<std::io::BufReader<R>>),
Raw(std::io::BufReader<R>),
}
fn maybe_gzip_decode<R:std::io::Read>(input:R)->std::io::Result<ReaderType<R>>{
let mut buf=std::io::BufReader::new(input);
let peek=std::io::BufRead::fill_buf(&mut buf)?;
match &peek[0..2]{
b"\x1f\x8b"=>Ok(ReaderType::GZip(flate2::read::GzDecoder::new(buf))),
_=>Ok(ReaderType::Raw(buf)),
}
}
fn read_readable(mut readable:impl std::io::Read)->std::io::Result<Vec<u8>>{
let mut contents=Vec::new();
readable.read_to_end(&mut contents)?;
Ok(contents)
}
#[derive(Clone)]
pub struct ApiKey(String);
impl ApiKey{
@ -320,12 +327,12 @@ impl ApiKey{
}
#[derive(Clone)]
pub struct CloudContext{
pub api_key:String,
pub client:reqwest::Client,
pub struct Context{
api_key:String,
client:reqwest::Client,
}
impl CloudContext{
impl Context{
pub fn new(api_key:ApiKey)->Self{
Self{
api_key:api_key.get(),
@ -368,9 +375,9 @@ impl CloudContext{
.text("request",request_config)
.part("fileContent",part);
let operation=self.post_form(url,form).await
.map_err(CreateError::Reqwest)?
.error_for_status().map_err(CreateError::Reqwest)?
let operation=crate::response_ok(
self.post_form(url,form).await.map_err(CreateError::Reqwest)?
).await.map_err(CreateError::Response)?
.json::<RobloxOperation>().await.map_err(CreateError::Reqwest)?;
Ok(AssetOperation{
@ -387,10 +394,9 @@ impl CloudContext{
.text("request",request_config)
.part("fileContent",reqwest::multipart::Part::bytes(body));
let operation=self.patch_form(url,form).await
.map_err(UpdateError::Reqwest)?
//roblox api documentation is very poor, just give the status code and drop the json
.error_for_status().map_err(UpdateError::Reqwest)?
let operation=crate::response_ok(
self.patch_form(url,form).await.map_err(UpdateError::Reqwest)?
).await.map_err(UpdateError::Response)?
.json::<RobloxOperation>().await.map_err(UpdateError::Reqwest)?;
Ok(AssetOperation{
@ -401,48 +407,68 @@ impl CloudContext{
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)?;
self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
crate::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 get_asset_info(&self,config:GetAssetInfoRequest)->Result<AssetResponse,GetError>{
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)?;
self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json::<AssetResponse>().await.map_err(GetError::Reqwest)
}
pub async fn get_asset_version(&self,config:GetAssetVersionRequest)->Result<Vec<u8>,GetError>{
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 body=self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json::<AssetResponse>().await.map_err(GetError::Reqwest)
}
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)?;
crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json().await.map_err(GetError::Reqwest)
}
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)?;
crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json().await.map_err(GetError::Reqwest)
}
pub async fn get_asset(&self,config:&AssetLocation)->Result<Vec<u8>,GetError>{
let url=reqwest::Url::parse(config.location()).map_err(GetError::ParseError)?;
let body=crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.bytes().await.map_err(GetError::Reqwest)?;
match maybe_gzip_decode(&mut std::io::Cursor::new(body)){
match maybe_gzip_decode(std::io::Cursor::new(body)){
Ok(ReaderType::GZip(readable))=>read_readable(readable),
Ok(ReaderType::Raw(readable))=>read_readable(readable),
Err(e)=>Err(e),
}.map_err(GetError::IO)
}
pub async fn get_asset(&self,config:GetAssetRequest)->Result<Vec<u8>,GetError>{
let version=match config.version{
Some(version)=>version,
None=>self.get_asset_info(GetAssetInfoRequest{asset_id:config.asset_id}).await?.revisionId.parse().unwrap(),
};
self.get_asset_version(GetAssetVersionRequest{
asset_id:config.asset_id,
version,
}).await
}
pub async fn get_asset_versions(&self,config:AssetVersionsRequest)->Result<AssetVersionsResponse,AssetVersionsError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}/versions",config.asset_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(AssetVersionsError::ParseError)?;
self.get(url).await.map_err(AssetVersionsError::Reqwest)?
.error_for_status().map_err(AssetVersionsError::Reqwest)?
crate::response_ok(
self.get(url).await.map_err(AssetVersionsError::Reqwest)?
).await.map_err(AssetVersionsError::Response)?
.json::<AssetVersionsResponse>().await.map_err(AssetVersionsError::Reqwest)
}
pub async fn inventory_page(&self,config:InventoryPageRequest)->Result<InventoryPageResponse,InventoryPageError>{
@ -455,8 +481,9 @@ impl CloudContext{
}
}
self.get(url).await.map_err(InventoryPageError::Reqwest)?
.error_for_status().map_err(InventoryPageError::Reqwest)?
crate::response_ok(
self.get(url).await.map_err(InventoryPageError::Reqwest)?
).await.map_err(InventoryPageError::Response)?
.json::<InventoryPageResponse>().await.map_err(InventoryPageError::Reqwest)
}
pub async fn update_place(&self,config:UpdatePlaceRequest,body:impl Into<reqwest::Body>+Clone)->Result<UpdatePlaceResponse,UpdateError>{
@ -468,8 +495,9 @@ impl CloudContext{
query.append_pair("versionType","Published");
}
self.post(url,body).await.map_err(UpdateError::Reqwest)?
.error_for_status().map_err(UpdateError::Reqwest)?
crate::response_ok(
self.post(url,body).await.map_err(UpdateError::Reqwest)?
).await.map_err(UpdateError::Response)?
.json::<UpdatePlaceResponse>().await.map_err(UpdateError::Reqwest)
}
}

@ -1,3 +1,5 @@
use crate::{ResponseError,ReaderType,maybe_gzip_decode,read_readable};
#[derive(Debug)]
pub enum PostError{
Reqwest(reqwest::Error),
@ -23,7 +25,14 @@ pub struct CreateRequest{
pub enum CreateError{
ParseError(url::ParseError),
PostError(PostError),
Response(ResponseError),
Reqwest(reqwest::Error),
ParseInt{
response:String,
err:std::num::ParseIntError,
},
VersionHeaderMissing,
ToStr(reqwest::header::ToStrError),
}
impl std::fmt::Display for CreateError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@ -46,7 +55,14 @@ pub enum UploadError{
ParseError(url::ParseError),
PostError(PostError),
Reqwest(reqwest::Error),
Response(ResponseError),
AssetIdIsZero,
ParseInt{
response:String,
err:std::num::ParseIntError,
},
VersionHeaderMissing,
ToStr(reqwest::header::ToStrError),
}
impl std::fmt::Display for UploadError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@ -58,9 +74,13 @@ impl std::error::Error for UploadError{}
#[allow(nonstandard_style,dead_code)]
pub struct UploadResponse{
pub AssetId:u64,
pub AssetVersionId:u64,
pub AssetVersion:u64,
}
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetDetailsRequest{
pub asset_id:u64,
}
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetRequest{
pub asset_id:u64,
@ -69,6 +89,7 @@ pub struct GetAssetRequest{
#[derive(Debug)]
pub enum GetError{
ParseError(url::ParseError),
Response(ResponseError),
Reqwest(reqwest::Error),
IO(std::io::Error)
}
@ -79,6 +100,108 @@ impl std::fmt::Display for GetError{
}
impl std::error::Error for GetError{}
#[derive(Debug)]
pub enum GetAssetV2Error{
ParseError(url::ParseError),
Response(ResponseError),
VersionHeaderMissing,
ToStr(reqwest::header::ToStrError),
ParseInt(std::num::ParseIntError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for GetAssetV2Error{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for GetAssetV2Error{}
#[derive(serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetV2AssetMetadata{
pub metadataType:u32,
pub value:String,
}
#[derive(serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetV2Location{
pub assetFormat:String,// "source"
location:String,// this value is private so users cannot mutate it
#[serde(default)]
pub assetMetadatas:Vec<GetAssetV2AssetMetadata>,
}
impl GetAssetV2Location{
pub fn location(&self)->&str{
&self.location
}
}
#[derive(serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetV2Info{
pub locations:Vec<GetAssetV2Location>,
pub requestId:String,
pub IsHashDynamic:bool,
pub IsCopyrightProtected:bool,
pub isArchived:bool,
pub assetTypeId:u32,
}
pub struct GetAssetV2{
pub version:u64,
pub info:GetAssetV2Info,
}
#[derive(Clone,Copy,Debug,Eq,PartialEq,Hash)]
#[derive(serde::Deserialize,serde::Serialize)]
pub enum CreatorType{
User,
Group,
}
#[derive(Debug)]
#[derive(serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct Creator{
pub Id:u64,
pub Name:String,
pub CreatorType:CreatorType,
pub CreatorTargetId:u64,
pub HasVerifiedBadge:bool,
}
#[derive(Debug)]
#[derive(serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetDetails{
pub TargetId:u64,
pub ProductType:Option<String>,
pub AssetId:u64,
pub ProductId:u64,
pub Name:String,
pub Description:String,
pub AssetTypeId:u32,
pub Creator:Creator,
pub IconImageAssetId:u64,
pub Created:chrono::DateTime<chrono::Utc>,
pub Updated:chrono::DateTime<chrono::Utc>,
pub PriceInRobux:Option<u32>,
pub PriceInTickets:Option<u32>,
pub Sales:u32,
pub IsNew:bool,
pub IsForSale:bool,
pub IsPublicDomain:bool,
pub IsLimited:bool,
pub IsLimitedUnique:bool,
pub Remaining:Option<u32>,
pub MinimumMembershipLevel:u32,
pub ContentRatingTypeId:u32,
pub SaleAvailabilityLocations:Option<String>,
pub SaleLocation:Option<String>,
pub CollectibleItemId:Option<u64>,
pub CollectibleProductId:Option<u64>,
pub CollectiblesItemDetails:Option<String>,
}
pub struct AssetVersionsPageRequest{
pub asset_id:u64,
pub cursor:Option<String>,
@ -89,7 +212,7 @@ pub struct AssetVersion{
pub Id:u64,
pub assetId:u64,
pub assetVersionNumber:u64,
pub creatorType:String,
pub creatorType:CreatorType,
pub creatorTargetId:u64,
pub creatingUniverseId:Option<u64>,
pub created:chrono::DateTime<chrono::Utc>,
@ -105,6 +228,7 @@ pub struct AssetVersionsPageResponse{
#[derive(Debug)]
pub enum PageError{
ParseError(url::ParseError),
Response(ResponseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for PageError{
@ -182,42 +306,24 @@ pub struct UserInventoryPageResponse{
pub data:Vec<UserInventoryItem>,
}
//idk how to do this better
enum ReaderType<R:std::io::Read>{
GZip(flate2::read::GzDecoder<std::io::BufReader<R>>),
Raw(std::io::BufReader<R>),
}
fn maybe_gzip_decode<R:std::io::Read>(input:R)->std::io::Result<ReaderType<R>>{
let mut buf=std::io::BufReader::new(input);
let peek=std::io::BufRead::fill_buf(&mut buf)?;
match &peek[0..2]{
b"\x1f\x8b"=>Ok(ReaderType::GZip(flate2::read::GzDecoder::new(buf))),
_=>Ok(ReaderType::Raw(buf)),
}
}
fn read_readable(mut readable:impl std::io::Read)->std::io::Result<Vec<u8>>{
let mut contents=Vec::new();
readable.read_to_end(&mut contents)?;
Ok(contents)
}
#[derive(Clone)]
pub struct Cookie(String);
impl Cookie{
/// cookie is prepended with ".ROBLOSECURITY=" by this function
pub fn new(cookie:String)->Self{
Self(cookie)
Self(format!(".ROBLOSECURITY={cookie}"))
}
pub fn get(self)->String{
self.0
}
}
#[derive(Clone)]
pub struct CookieContext{
pub cookie:String,
pub client:reqwest::Client,
pub struct Context{
cookie:String,
client:reqwest::Client,
}
impl CookieContext{
impl Context{
pub fn new(cookie:Cookie)->Self{
Self{
cookie:cookie.get(),
@ -265,10 +371,31 @@ impl CookieContext{
query.append_pair("groupId",group_id.to_string().as_str());
}
}
let response=crate::response_ok(
self.post(url,body).await.map_err(CreateError::PostError)?
).await.map_err(CreateError::Response)?;
self.post(url,body).await.map_err(CreateError::PostError)?
.error_for_status().map_err(CreateError::Reqwest)?
.json::<UploadResponse>().await.map_err(CreateError::Reqwest)
let version_str=response
.headers()
.get("roblox-assetversionnumber")
.ok_or(CreateError::VersionHeaderMissing)?
.to_str()
.map_err(CreateError::ToStr)?;
let version=version_str.parse()
.map_err(|err|CreateError::ParseInt{err,response:version_str.to_owned()})?;
let response=response.text().await.map_err(CreateError::Reqwest)?;
match response.parse(){
Ok(asset_id)=>Ok(UploadResponse{
AssetId:asset_id,
AssetVersion:version,
}),
Err(err)=>Err(CreateError::ParseInt{
response,
err,
})
}
}
pub async fn upload(&self,config:UploadRequest,body:impl Into<reqwest::Body>+Clone)->Result<UploadResponse,UploadError>{
let mut url=reqwest::Url::parse("https://data.roblox.com/Data/Upload.ashx?json=1&type=Model&genreTypeId=1").map_err(UploadError::ParseError)?;
@ -296,10 +423,31 @@ impl CookieContext{
query.append_pair("groupId",group_id.to_string().as_str());
}
}
let response=crate::response_ok(
self.post(url,body).await.map_err(UploadError::PostError)?
).await.map_err(UploadError::Response)?;
self.post(url,body).await.map_err(UploadError::PostError)?
.error_for_status().map_err(UploadError::Reqwest)?
.json::<UploadResponse>().await.map_err(UploadError::Reqwest)
let version_str=response
.headers()
.get("roblox-assetversionnumber")
.ok_or(UploadError::VersionHeaderMissing)?
.to_str()
.map_err(UploadError::ToStr)?;
let version=version_str.parse()
.map_err(|err|UploadError::ParseInt{err,response:version_str.to_owned()})?;
let response=response.text().await.map_err(UploadError::Reqwest)?;
match response.parse(){
Ok(asset_id)=>Ok(UploadResponse{
AssetId:asset_id,
AssetVersion:version,
}),
Err(err)=>Err(UploadError::ParseInt{
response,
err,
})
}
}
pub async fn get_asset(&self,config:GetAssetRequest)->Result<Vec<u8>,GetError>{
let mut url=reqwest::Url::parse("https://assetdelivery.roblox.com/v1/asset/").map_err(GetError::ParseError)?;
@ -311,16 +459,68 @@ impl CookieContext{
query.append_pair("version",version.to_string().as_str());
}
}
let body=self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
let body=crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.bytes().await.map_err(GetError::Reqwest)?;
match maybe_gzip_decode(&mut std::io::Cursor::new(body)){
match maybe_gzip_decode(std::io::Cursor::new(body)){
Ok(ReaderType::GZip(readable))=>read_readable(readable),
Ok(ReaderType::Raw(readable))=>read_readable(readable),
Err(e)=>Err(e),
}.map_err(GetError::IO)
}
pub async fn get_asset_v2(&self,config:GetAssetRequest)->Result<GetAssetV2,GetAssetV2Error>{
let mut url=reqwest::Url::parse("https://assetdelivery.roblox.com/v2/asset").map_err(GetAssetV2Error::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
query.append_pair("ID",config.asset_id.to_string().as_str());
if let Some(version)=config.version{
query.append_pair("version",version.to_string().as_str());
}
}
let response=crate::response_ok(
self.get(url).await.map_err(GetAssetV2Error::Reqwest)?
).await.map_err(GetAssetV2Error::Response)?;
let version=response
.headers()
.get("roblox-assetversionnumber")
.ok_or(GetAssetV2Error::VersionHeaderMissing)?
.to_str()
.map_err(GetAssetV2Error::ToStr)?
.parse()
.map_err(GetAssetV2Error::ParseInt)?;
let info=response.json().await.map_err(GetAssetV2Error::Reqwest)?;
Ok(GetAssetV2{
version,
info,
})
}
pub async fn get_asset_v2_download(&self,config:&GetAssetV2Location)->Result<Vec<u8>,GetError>{
let url=reqwest::Url::parse(config.location.as_str()).map_err(GetError::ParseError)?;
let body=crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.bytes().await.map_err(GetError::Reqwest)?;
match maybe_gzip_decode(std::io::Cursor::new(body)){
Ok(ReaderType::GZip(readable))=>read_readable(readable),
Ok(ReaderType::Raw(readable))=>read_readable(readable),
Err(e)=>Err(e),
}.map_err(GetError::IO)
}
pub async fn get_asset_details(&self,config:GetAssetDetailsRequest)->Result<AssetDetails,GetError>{
let url=reqwest::Url::parse(format!("https://economy.roblox.com/v2/assets/{}/details",config.asset_id).as_str()).map_err(GetError::ParseError)?;
crate::response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json().await.map_err(GetError::Reqwest)
}
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
@ -333,9 +533,9 @@ impl CookieContext{
query.append_pair("cursor",cursor);
}
}
self.get(url).await.map_err(PageError::Reqwest)?
.error_for_status().map_err(PageError::Reqwest)?
crate::response_ok(
self.get(url).await.map_err(PageError::Reqwest)?
).await.map_err(PageError::Response)?
.json::<AssetVersionsPageResponse>().await.map_err(PageError::Reqwest)
}
pub async fn get_creations_page(&self,config:&CreationsPageRequest)->Result<CreationsPageResponse,PageError>{
@ -348,9 +548,9 @@ impl CookieContext{
query.append_pair("cursor",cursor);
}
}
self.get(url).await.map_err(PageError::Reqwest)?
.error_for_status().map_err(PageError::Reqwest)?
crate::response_ok(
self.get(url).await.map_err(PageError::Reqwest)?
).await.map_err(PageError::Response)?
.json::<CreationsPageResponse>().await.map_err(PageError::Reqwest)
}
pub async fn get_user_inventory_page(&self,config:&UserInventoryPageRequest)->Result<UserInventoryPageResponse,PageError>{
@ -362,9 +562,9 @@ impl CookieContext{
query.append_pair("cursor",cursor);
}
}
self.get(url).await.map_err(PageError::Reqwest)?
.error_for_status().map_err(PageError::Reqwest)?
crate::response_ok(
self.get(url).await.map_err(PageError::Reqwest)?
).await.map_err(PageError::Response)?
.json::<UserInventoryPageResponse>().await.map_err(PageError::Reqwest)
}
}

@ -1,2 +1,56 @@
pub mod cloud;
pub mod cookie;
#[allow(dead_code)]
#[derive(Debug)]
pub struct StatusCodeWithUrlAndBody{
pub status_code:reqwest::StatusCode,
pub url:url::Url,
pub body:String,
}
#[derive(Debug)]
pub enum ResponseError{
Reqwest(reqwest::Error),
StatusCodeWithUrlAndBody(StatusCodeWithUrlAndBody),
}
impl std::fmt::Display for ResponseError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for ResponseError{}
// 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>{
let status_code=response.status();
if status_code.is_success(){
Ok(response)
}else{
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{
status_code,
url,
body,
}))
}
}
//idk how to do this better
pub(crate) enum ReaderType<R:std::io::Read>{
GZip(flate2::read::GzDecoder<std::io::BufReader<R>>),
Raw(std::io::BufReader<R>),
}
pub(crate) fn maybe_gzip_decode<R:std::io::Read>(input:R)->std::io::Result<ReaderType<R>>{
let mut buf=std::io::BufReader::new(input);
let peek=std::io::BufRead::fill_buf(&mut buf)?;
match &peek[0..2]{
b"\x1f\x8b"=>Ok(ReaderType::GZip(flate2::read::GzDecoder::new(buf))),
_=>Ok(ReaderType::Raw(buf)),
}
}
pub(crate) fn read_readable(mut readable:impl std::io::Read)->std::io::Result<Vec<u8>>{
let mut contents=Vec::new();
readable.read_to_end(&mut contents)?;
Ok(contents)
}

@ -12,6 +12,6 @@ authors = ["Rhys Lloyd <krakow20@gmail.com>"]
futures = "0.3.30"
lazy-regex = "3.1.0"
rayon = "1.8.0"
rbx_dom_weak = "2.7.0"
rbx_xml = "0.13.3"
rbx_dom_weak = "3.0.0"
rbx_xml = "1.0.0"
tokio = { version = "1.35.1", features = ["fs"] }

@ -90,7 +90,7 @@ fn write_item(dom:&rbx_dom_weak::WeakDom,mut file:PathBuf,node:&TreeNode,node_na
if let Some(item)=dom.get_by_ref(node.referent){
//TODO: delete disabled scripts
if let Some(rbx_dom_weak::types::Variant::String(source))=item.properties.get("Source"){
if let Some(rbx_dom_weak::types::Variant::String(source))=item.properties.get(&rbx_dom_weak::ustr("Source")){
if properties.is_some(){
//rox style
let source=properties.to_string()+source.as_str();

@ -2,8 +2,8 @@ use std::{io::Read,path::PathBuf};
use clap::{Args,Parser,Subcommand};
use anyhow::{anyhow,Result as AResult};
use futures::StreamExt;
use rbx_asset::cloud::{ApiKey,CloudContext};
use rbx_asset::cookie::{Cookie,CookieContext,AssetVersion,CreationsItem};
use rbx_asset::cloud::{ApiKey,Context as CloudContext};
use rbx_asset::cookie::{Cookie,Context as CookieContext,AssetVersion,CreationsItem};
type AssetID=u64;
type AssetIDFileMap=Vec<(AssetID,PathBuf)>;
@ -20,8 +20,11 @@ struct Cli{
#[derive(Subcommand)]
enum Commands{
AssetDetails(AssetDetailsSubcommand),
DownloadHistory(DownloadHistorySubcommand),
Download(DownloadSubcommand),
DownloadVersion(DownloadVersionSubcommand),
DownloadVersionV2(DownloadVersionSubcommand),
DownloadDecompile(DownloadDecompileSubcommand),
DownloadCreationsJson(DownloadCreationsJsonSubcommand),
DownloadUserInventoryJson(DownloadUserInventoryJsonSubcommand),
@ -59,7 +62,19 @@ struct DownloadHistorySubcommand{
#[arg(long)]
end_version:Option<u64>,
}
/// Download a single asset by id.
/// Print the details for an asset
#[derive(Args)]
struct AssetDetailsSubcommand{
#[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(required=true)]
asset_id:AssetID,
}
/// Download a list of assets by id.
#[derive(Args)]
struct DownloadSubcommand{
#[arg(long,group="cookie",required=true)]
@ -73,6 +88,22 @@ struct DownloadSubcommand{
#[arg(required=true)]
asset_ids:Vec<AssetID>,
}
/// Download a single asset by id, optionally specifying the version to download.
#[derive(Args)]
struct DownloadVersionSubcommand{
#[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)]
output_folder: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{
@ -102,6 +133,8 @@ struct DownloadUserInventoryJsonSubcommand{
output_folder:Option<PathBuf>,
#[arg(long)]
user_id:u64,
#[arg(long)]
continue_from_cursor:Option<bool>,
}
/// Upload a (.rbxm, .rbxmx) model file, creating a new asset. Can be any type of model, including modulescripts.
#[derive(Args)]
@ -137,7 +170,7 @@ struct CreateAssetMediaSubcommand{
#[arg(long)]
model_name:String,
#[arg(long)]
description:Option<String>,
description:String,
#[arg(long)]
input_file:PathBuf,
#[arg(long)]
@ -402,6 +435,16 @@ impl AssetType{
async fn main()->AResult<()>{
let cli=Cli::parse();
match cli.command{
Commands::AssetDetails(subcommand)=>{
asset_details(
cookie_from_args(
subcommand.cookie_literal,
subcommand.cookie_envvar,
subcommand.cookie_file,
).await?,
subcommand.asset_id
).await
},
Commands::DownloadHistory(subcommand)=>download_history(DownloadHistoryConfig{
continue_from_versions:subcommand.continue_from_versions.unwrap_or(false),
end_version:subcommand.end_version,
@ -429,6 +472,40 @@ async fn main()->AResult<()>{
}).collect()
).await
},
Commands::DownloadVersion(subcommand)=>{
let output_folder=subcommand.output_folder.unwrap_or_else(||std::env::current_dir().unwrap());
download_version(
cookie_from_args(
subcommand.cookie_literal,
subcommand.cookie_envvar,
subcommand.cookie_file,
).await?,
subcommand.asset_id,
subcommand.asset_version,
{
let mut path=output_folder.clone();
path.push(subcommand.asset_id.to_string());
path
},
).await
},
Commands::DownloadVersionV2(subcommand)=>{
let output_folder=subcommand.output_folder.unwrap_or_else(||std::env::current_dir().unwrap());
download_version_v2(
cookie_from_args(
subcommand.cookie_literal,
subcommand.cookie_envvar,
subcommand.cookie_file,
).await?,
subcommand.asset_id,
subcommand.asset_version,
{
let mut path=output_folder.clone();
path.push(subcommand.asset_id.to_string());
path
},
).await
},
Commands::DownloadDecompile(subcommand)=>{
download_decompile(DownloadDecompileConfig{
cookie:cookie_from_args(
@ -464,6 +541,7 @@ async fn main()->AResult<()>{
).await?,
subcommand.user_id,
subcommand.output_folder.unwrap_or_else(||std::env::current_dir().unwrap()),
subcommand.continue_from_cursor.unwrap_or(false),
).await,
Commands::CreateAsset(subcommand)=>create_asset(CreateAssetConfig{
cookie:cookie_from_args(
@ -492,7 +570,7 @@ async fn main()->AResult<()>{
input_file:subcommand.input_file,
asset_type:subcommand.asset_type.cloud(),
model_name:subcommand.model_name,
description:subcommand.description.unwrap_or_else(||String::with_capacity(0)),
description:subcommand.description,
expected_price:subcommand.expected_price,
}).await,
Commands::CreateAssetMedias(subcommand)=>create_asset_medias(CreateAssetMediasConfig{
@ -621,7 +699,7 @@ async fn cookie_from_args(literal:Option<String>,environment:Option<String>,file
(None,None,Some(cookie_file))=>tokio::fs::read_to_string(cookie_file).await?,
_=>Err(anyhow::Error::msg("Illegal cookie argument triple"))?,
};
Ok(Cookie::new(format!(".ROBLOSECURITY={cookie}")))
Ok(Cookie::new(cookie))
}
async fn api_key_from_args(literal:Option<String>,environment:Option<String>,file:Option<PathBuf>)->AResult<ApiKey>{
let api_key=match (literal,environment,file){
@ -833,9 +911,12 @@ async fn create_asset_medias(config:CreateAssetMediasConfig)->AResult<()>{
let instance=dom.get_by_ref(
*dom.root().children().first().ok_or(DownloadDecalError::NoFirstInstance)?
).ok_or(DownloadDecalError::NoFirstInstance)?;
let texture=instance.properties.get("Texture").ok_or(DownloadDecalError::NoTextureProperty)?;
let texture=instance.properties.get(&rbx_dom_weak::ustr("Texture")).ok_or(DownloadDecalError::NoTextureProperty)?;
let asset_url=match texture{
rbx_dom_weak::types::Variant::Content(url)=>url.clone().into_string(),
rbx_dom_weak::types::Variant::Content(content)=>match content.value(){
rbx_dom_weak::types::ContentType::Uri(url)=>url.as_str().to_owned(),
_=>Err(DownloadDecalError::TexturePropertyInvalid)?,
}
_=>Err(DownloadDecalError::TexturePropertyInvalid)?,
};
Ok::<_,DownloadDecalError>((asset_response.displayName,asset_url))
@ -906,6 +987,34 @@ async fn upload_place(config:UploadPlaceConfig)->AResult<()>{
Ok(())
}
async fn asset_details(cookie:Cookie,asset_id:AssetID)->AResult<()>{
let context=CookieContext::new(cookie);
let details=context.get_asset_details(rbx_asset::cookie::GetAssetDetailsRequest{asset_id}).await?;
println!("details:{details:?}");
Ok(())
}
async fn download_version(cookie:Cookie,asset_id:AssetID,version:Option<u64>,dest:PathBuf)->AResult<()>{
let context=CookieContext::new(cookie);
let data=context.get_asset(rbx_asset::cookie::GetAssetRequest{asset_id,version}).await?;
tokio::fs::write(dest,data).await?;
Ok(())
}
async fn download_version_v2(cookie:Cookie,asset_id:AssetID,version:Option<u64>,dest:PathBuf)->AResult<()>{
let context=CookieContext::new(cookie);
// v2 has two steps
let info=context.get_asset_v2(rbx_asset::cookie::GetAssetRequest{asset_id,version}).await?;
println!("version:{}",info.version);
let location=info.info.locations.first().ok_or(anyhow::Error::msg("No locations"))?;
let data=context.get_asset_v2_download(location).await?;
tokio::fs::write(dest,data).await?;
Ok(())
}
async fn download_list(cookie:Cookie,asset_id_file_map:AssetIDFileMap)->AResult<()>{
let context=CookieContext::new(cookie);
futures::stream::iter(asset_id_file_map.into_iter()
@ -955,30 +1064,70 @@ async fn download_creations_json(cookie:Cookie,owner:rbx_asset::cookie::Owner,ou
Ok(())
}
async fn get_user_inventory_pages(context:&CookieContext,user_id:u64)->AResult<Vec<rbx_asset::cookie::UserInventoryItem>>{
let mut config=rbx_asset::cookie::UserInventoryPageRequest{
user_id,
cursor:None,
};
let mut asset_list=Vec::new();
async fn get_user_inventory_pages(
context:&CookieContext,
asset_list:&mut Vec<rbx_asset::cookie::UserInventoryItem>,
config:&mut rbx_asset::cookie::UserInventoryPageRequest,
)->AResult<()>{
loop{
let mut page=context.get_user_inventory_page(&config).await?;
asset_list.append(&mut page.data);
if page.nextPageCursor.is_none(){
let page=context.get_user_inventory_page(&config).await?;
asset_list.extend(page.data);
config.cursor=page.nextPageCursor;
if config.cursor.is_none(){
break;
}
config.cursor=page.nextPageCursor;
}
Ok(asset_list)
Ok(())
}
async fn download_user_inventory_json(cookie:Cookie,user_id:u64,output_folder:PathBuf)->AResult<()>{
let context=CookieContext::new(cookie);
let item_list=get_user_inventory_pages(&context,user_id).await?;
async fn download_user_inventory_json(cookie:Cookie,user_id:u64,output_folder:PathBuf,continue_from_cursor:bool)->AResult<()>{
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");
let mut path=output_folder.clone();
path.set_file_name("versions.json");
tokio::fs::write(path,serde_json::to_string(&item_list)?).await?;
let context=CookieContext::new(cookie);
let (mut asset_list,mut config)=if continue_from_cursor{
// load state from files
let (versions,cursor)=tokio::try_join!(
tokio::fs::read(versions_path.as_path()),
tokio::fs::read_to_string(cursor_path.as_path()),
)?;
(
serde_json::from_slice(&versions)?,
rbx_asset::cookie::UserInventoryPageRequest{
user_id,
cursor:Some(cursor),
}
)
}else{
// create new state
(
Vec::new(),
rbx_asset::cookie::UserInventoryPageRequest{
user_id,
cursor:None,
}
)
};
match get_user_inventory_pages(&context,&mut asset_list,&mut config).await{
Ok(())=>println!("Pages polling complete"),
Err(e)=>println!("Error: {e}"),
}
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?;
}
Ok(())
};
let versions_fut=tokio::fs::write(versions_path,serde_json::to_string(&asset_list)?);
tokio::try_join!(versions_fut,cursor_fut)?;
Ok(())
}