Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
0bf0b92efb
|
|||
41cd60c459
|
|||
52a0bf221b
|
|||
f2bd298cd1
|
|||
89bbe00e3d | |||
369f19452c
|
|||
9e78be3d09
|
|||
70414d94ae
|
|||
819eea1b4a
|
2
Cargo.lock
generated
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
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})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
101
src/main.rs
101
src/main.rs
@ -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(){
|
||||
|
Reference in New Issue
Block a user