9 Commits

Author SHA1 Message Date
0bf0b92efb asset location commands 2025-06-13 20:04:18 -07:00
41cd60c459 untab 2025-06-13 20:04:07 -07:00
52a0bf221b rbx_asset: visibility mistake 2025-06-11 23:34:21 -07:00
f2bd298cd1 rbx_asset: try out ref for funsies 2025-06-11 23:34:21 -07:00
89bbe00e3d Set Universe Asset Permissions ()
This implements an endpoint to set universe asset permissions.

Reviewed-on: 
Co-authored-by: Quaternions <krakow20@gmail.com>
Co-committed-by: Quaternions <krakow20@gmail.com>
2025-06-12 02:33:12 +00:00
369f19452c rbx_asset: omit Cursor 2025-05-13 23:44:11 -07:00
9e78be3d09 rbx_asset v0.4.5 fix error type 2025-05-13 23:28:00 -07:00
70414d94ae clippy fixes 2025-05-13 23:26:15 -07:00
819eea1b4a rbx_asset: error type is too damn big 2025-05-13 23:23:58 -07:00
8 changed files with 251 additions and 38 deletions

2
Cargo.lock generated

@ -1339,7 +1339,7 @@ dependencies = [
[[package]]
name = "rbx_asset"
version = "0.4.4"
version = "0.4.6"
dependencies = [
"bytes",
"chrono",

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

44
rbx_asset/src/body.rs Normal 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"
}
}

@ -1,3 +1,4 @@
use crate::body::{ContentType,Json};
use crate::util::response_ok;
use crate::types::{ResponseError,MaybeGzippedBytes};
@ -282,21 +283,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:u64,
}
#[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 +307,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 +409,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
@ -560,4 +636,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(())
}
}

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

@ -1,14 +1,16 @@
#[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 +19,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 +44,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 +57,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())
}
}
}

@ -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})
})
}
}

@ -24,6 +24,8 @@ enum Commands{
DownloadHistory(DownloadHistorySubcommand),
Download(DownloadSubcommand),
DownloadVersion(DownloadVersionSubcommand),
DownloadLocation(DownloadLocationSubcommand),
DownloadVersionLocation(DownloadVersionLocationSubcommand),
DownloadVersionV2(DownloadVersionSubcommand),
DownloadDecompile(DownloadDecompileSubcommand),
DownloadCreationsJson(DownloadCreationsJsonSubcommand),
@ -104,6 +106,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{
@ -489,6 +517,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(
@ -756,10 +805,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
@ -1029,13 +1078,45 @@ 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 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?,
};
match asset_location_info.location{
Some(location)=>println!("{}",location.location()),
None=>println!("This asset is private!"),
}
Ok(())
}
@ -1073,7 +1154,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(){