From 7e4f96a19c143b0bd00f39bd654a9363b342d026 Mon Sep 17 00:00:00 2001 From: Quaternions Date: Tue, 2 Jul 2024 14:26:14 -0700 Subject: [PATCH] wip --- Cargo.lock | 27 ++++ rbx_asset/Cargo.toml | 3 +- rbx_asset/src/context.rs | 269 ++++++++++++++++++++------------------- src/main.rs | 43 ++++--- 4 files changed, 190 insertions(+), 152 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d54686e..3eb67fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,6 +890,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -1162,6 +1172,7 @@ dependencies = [ "flate2", "reqwest", "serde", + "serde_json", "url", ] @@ -1294,6 +1305,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -1745,6 +1757,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -1795,6 +1816,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "want" version = "0.3.1" diff --git a/rbx_asset/Cargo.toml b/rbx_asset/Cargo.toml index 64325a2..93b59e4 100644 --- a/rbx_asset/Cargo.toml +++ b/rbx_asset/Cargo.toml @@ -9,6 +9,7 @@ publish = ["strafesnet"] [dependencies] chrono = { version = "0.4.38", features = ["serde"] } flate2 = "1.0.29" -reqwest = { version = "0.12.4", features = ["json"] } +reqwest = { version = "0.12.4", features = ["json","multipart"] } serde = { version = "1.0.199", features = ["derive"] } +serde_json = "1.0.111" url = "2.5.0" diff --git a/rbx_asset/src/context.rs b/rbx_asset/src/context.rs index 134ec3b..3ab9d5b 100644 --- a/rbx_asset/src/context.rs +++ b/rbx_asset/src/context.rs @@ -1,28 +1,22 @@ -#[derive(Debug)] -pub enum PostError{ - Reqwest(reqwest::Error), - CSRF, -} -impl std::fmt::Display for PostError{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f,"{self:?}") - } -} -impl std::error::Error for PostError{} - #[derive(Debug,serde::Deserialize,serde::Serialize)] #[allow(nonstandard_style,dead_code)] -pub struct CreateRequest{ - pub name:String, +pub enum AssetType{ + Audio, + Decal, + Model, +} +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub struct CreateAssetRequest{ + pub assetType:AssetType, + pub creationContext:CreationContext, pub description:String, - pub ispublic:bool, - pub allowComments:bool, - pub groupId:Option, + pub displayName:String, } #[derive(Debug)] pub enum CreateError{ ParseError(url::ParseError), - PostError(PostError), + SerializeError(serde_json::Error), Reqwest(reqwest::Error), } impl std::fmt::Display for CreateError{ @@ -32,37 +26,79 @@ impl std::fmt::Display for CreateError{ } impl std::error::Error for CreateError{} +#[derive(Debug,serde::Deserialize,serde::Serialize)] #[allow(nonstandard_style,dead_code)] -pub struct UploadRequest{ - pub assetid:u64, - pub name:Option, +pub struct UpdateAssetRequest{ + pub assetId:u64, + pub displayName:Option, pub description:Option, - pub ispublic:Option, - pub allowComments:Option, - pub groupId:Option, +} + +//woo nested roblox stuff +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub struct Creator{ + pub userId:u64, + pub groupId:u64, +} +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub struct CreationContext{ + pub creator:Creator, + pub expectedPrice:u64, +} +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub enum ModerationResult{ + MODERATION_STATE_REVIEWING, + MODERATION_STATE_REJECTED, + MODERATION_STATE_APPROVED, +} +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub struct Preview{ + pub asset:String, + pub altText:String, +} +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub struct AssetResponse{ + pub assetId:u64, + pub creationContext:CreationContext, + pub description:String, + pub displayName:String, + pub path:String, + pub revisionId:u64, + pub revisionCreateTime:chrono::DateTime, + pub moderationResult:ModerationResult, + pub icon:String, + pub previews:Vec, +} +#[allow(nonstandard_style,dead_code)] +pub struct UpdatePlaceRequest{ + pub universeId:u64, + pub placeId:u64, +} +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub struct UpdatePlaceResponse{ + pub versionNumber:u64, } #[derive(Debug)] -pub enum UploadError{ +pub enum UpdateError{ ParseError(url::ParseError), - PostError(PostError), + SerializeError(serde_json::Error), Reqwest(reqwest::Error), - AssetIdIsZero, } -impl std::fmt::Display for UploadError{ +impl std::fmt::Display for UpdateError{ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f,"{self:?}") } } -impl std::error::Error for UploadError{} -#[derive(Debug,serde::Deserialize,serde::Serialize)] -#[allow(nonstandard_style,dead_code)] -pub struct UploadResponse{ - pub AssetId:u64, - pub AssetVersionId:u64, -} +impl std::error::Error for UpdateError{} #[allow(nonstandard_style,dead_code)] -pub struct DownloadRequest{ +pub struct GetAssetRequest{ pub asset_id:u64, pub version:Option, } @@ -79,7 +115,7 @@ impl std::fmt::Display for DownloadError{ } impl std::error::Error for DownloadError{} -pub struct HistoryPageRequest{ +pub struct AssetVersionsRequest{ pub asset_id:u64, pub cursor:Option, } @@ -97,22 +133,22 @@ pub struct AssetVersion{ } #[derive(serde::Deserialize)] #[allow(nonstandard_style,dead_code)] -pub struct HistoryPageResponse{ +pub struct AssetVersionsResponse{ pub previousPageCursor:Option, pub nextPageCursor:Option, pub data:Vec, } #[derive(Debug)] -pub enum HistoryPageError{ +pub enum AssetVersionsError{ ParseError(url::ParseError), Reqwest(reqwest::Error), } -impl std::fmt::Display for HistoryPageError{ +impl std::fmt::Display for AssetVersionsError{ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f,"{self:?}") } } -impl std::error::Error for HistoryPageError{} +impl std::error::Error for AssetVersionsError{} pub struct InventoryPageRequest{ pub group:u64, @@ -170,96 +206,68 @@ fn read_readable(mut readable:impl std::io::Read)->std::io::Result>{ #[derive(Clone)] pub struct RobloxContext{ - pub cookie:String, + pub api_key:String, pub client:reqwest::Client, } impl RobloxContext{ - pub fn new(cookie:String)->Self{ + pub fn new(api_key:String)->Self{ Self{ - cookie, + api_key, client:reqwest::Client::new(), } } async fn get(&self,url:impl reqwest::IntoUrl)->Result{ self.client.get(url) - .header("Cookie",self.cookie.as_str()) + .header("x-api-key",self.api_key.as_str()) .send().await } - async fn post(&self,url:url::Url,body:impl Into+Clone)->Result{ - let mut resp=self.client.post(url.clone()) - .header("Cookie",self.cookie.as_str()) - .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.post(url) - .header("X-CSRF-Token",csrf_token) - .header("Cookie",self.cookie.as_str()) - .body(body) - .send().await.map_err(PostError::Reqwest)?; - }else{ - Err(PostError::CSRF)?; - } - } - - Ok(resp) + async fn post(&self,url:url::Url,body:impl Into+Clone)->Result{ + self.client.post(url) + .header("x-api-key",self.api_key.as_str()) + .body(body) + .send().await } - pub async fn create(&self,config:CreateRequest,body:impl Into+Clone)->Result{ - 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 - { - let mut query=url.query_pairs_mut();//borrow here - //archaic roblox api uses 0 for new asset - query.append_pair("assetid","0"); - query.append_pair("name",config.name.as_str()); - query.append_pair("description",config.description.as_str()); - query.append_pair("ispublic",if config.ispublic{"True"}else{"False"}); - query.append_pair("allowComments",if config.allowComments{"True"}else{"False"}); - match config.groupId{ - Some(group_id)=>{query.append_pair("groupId",group_id.to_string().as_str());}, - None=>(), - } - } - - let resp=self.post(url,body).await.map_err(CreateError::PostError)?; - - Ok(resp.json::().await.map_err(CreateError::Reqwest)?) + async fn patch_form(&self,url:url::Url,form:reqwest::multipart::Form)->Result{ + self.client.patch(url) + .header("x-api-key",self.api_key.as_str()) + .multipart(form) + .send().await } - pub async fn upload(&self,config:UploadRequest,body:impl Into+Clone)->Result{ - let mut url=reqwest::Url::parse("https://data.roblox.com/Data/Upload.ashx?json=1&type=Model&genreTypeId=1").map_err(UploadError::ParseError)?; - //url borrow scope - { - let mut query=url.query_pairs_mut();//borrow here - //archaic roblox api uses 0 for new asset - match config.assetid{ - 0=>return Err(UploadError::AssetIdIsZero), - assetid=>{query.append_pair("assetid",assetid.to_string().as_str());}, - } - if let Some(name)=config.name.as_deref(){ - query.append_pair("name",name); - } - if let Some(description)=config.description.as_deref(){ - query.append_pair("description",description); - } - if let Some(ispublic)=config.ispublic{ - query.append_pair("ispublic",if ispublic{"True"}else{"False"}); - } - if let Some(allow_comments)=config.allowComments{ - query.append_pair("allowComments",if allow_comments{"True"}else{"False"}); - } - if let Some(group_id)=config.groupId{ - query.append_pair("groupId",group_id.to_string().as_str()); - } - } - - let resp=self.post(url,body).await.map_err(UploadError::PostError)?; - - Ok(resp.json::().await.map_err(UploadError::Reqwest)?) + async fn post_form(&self,url:url::Url,form:reqwest::multipart::Form)->Result{ + self.client.post(url) + .header("x-api-key",self.api_key.as_str()) + .multipart(form) + .send().await } - pub async fn download(&self,config:DownloadRequest)->Result,DownloadError>{ + pub async fn create_asset(&self,config:CreateAssetRequest,body:impl Into>)->Result{ + let url=reqwest::Url::parse("https://apis.roblox.com/assets/v1/assets").map_err(CreateError::ParseError)?; + + let request_config=serde_json::to_string(&config).map_err(CreateError::SerializeError)?; + + let form=reqwest::multipart::Form::new() + .text("request",request_config) + .part("fileContent",reqwest::multipart::Part::bytes(body)); + + let resp=self.post_form(url,form).await.map_err(CreateError::Reqwest)?; + + Ok(resp.json::().await.map_err(CreateError::Reqwest)?) + } + pub async fn update_asset(&self,config:UpdateAssetRequest,body:impl Into>)->Result{ + let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}",config.assetId); + let url=reqwest::Url::parse(raw_url.as_str()).map_err(UpdateError::ParseError)?; + + let request_config=serde_json::to_string(&config).map_err(UpdateError::SerializeError)?; + + let form=reqwest::multipart::Form::new() + .text("request",request_config) + .part("fileContent",reqwest::multipart::Part::bytes(body)); + + let resp=self.patch_form(url,form).await.map_err(UpdateError::Reqwest)?; + + Ok(resp.json::().await.map_err(UpdateError::Reqwest)?) + } + pub async fn get_asset(&self,config:GetAssetRequest)->Result,DownloadError>{ let mut url=reqwest::Url::parse("https://assetdelivery.roblox.com/v1/asset/").map_err(DownloadError::ParseError)?; //url borrow scope { @@ -279,21 +287,12 @@ impl RobloxContext{ Err(e)=>Err(e), }.map_err(DownloadError::IO) } - pub async fn history_page(&self,config:HistoryPageRequest)->Result{ - let mut url=reqwest::Url::parse(format!("https://develop.roblox.com/v1/assets/{}/saved-versions",config.asset_id).as_str()).map_err(HistoryPageError::ParseError)?; - //url borrow scope - { - let mut query=url.query_pairs_mut();//borrow here - //query.append_pair("sortOrder","Asc"); - //query.append_pair("limit","100"); - //query.append_pair("count","100"); - if let Some(cursor)=config.cursor.as_deref(){ - query.append_pair("cursor",cursor); - } - } + pub async fn get_asset_versions(&self,config:AssetVersionsRequest)->Result{ + 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)?; - Ok(self.get(url).await.map_err(HistoryPageError::Reqwest)? - .json::().await.map_err(HistoryPageError::Reqwest)?) + Ok(self.get(url).await.map_err(AssetVersionsError::Reqwest)? + .json::().await.map_err(AssetVersionsError::Reqwest)?) } pub async fn inventory_page(&self,config:InventoryPageRequest)->Result{ let mut url=reqwest::Url::parse(format!("https://apis.roblox.com/toolbox-service/v1/creations/group/{}/10?limit=50",config.group).as_str()).map_err(InventoryPageError::ParseError)?; @@ -308,4 +307,12 @@ impl RobloxContext{ Ok(self.get(url).await.map_err(InventoryPageError::Reqwest)? .json::().await.map_err(InventoryPageError::Reqwest)?) } -} \ No newline at end of file + pub async fn update_place(&self,config:UpdatePlaceRequest,body:impl Into+Clone)->Result{ + let raw_url=format!("https://apis.roblox.com/universes/v1/{}/places/{}/versions",config.universeId,config.placeId); + let url=reqwest::Url::parse(raw_url.as_str()).map_err(UpdateError::ParseError)?; + + let resp=self.post(url,body).await.map_err(UpdateError::Reqwest)?; + + Ok(resp.json::().await.map_err(UpdateError::Reqwest)?) + } +} diff --git a/src/main.rs b/src/main.rs index c32c9db..999580e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::{io::Read,path::PathBuf}; use clap::{Args,Parser,Subcommand}; use anyhow::Result as AResult; use futures::StreamExt; -use rbx_asset::context::{RobloxContext,InventoryItem,AssetVersion}; +use rbx_asset::context::{AssetVersion,InventoryItem,RobloxContext}; type AssetID=u64; type AssetIDFileMap=Vec<(AssetID,PathBuf)>; @@ -353,33 +353,36 @@ struct CreateConfig{ allow_comments:bool, } +///This is hardcoded to create models atm async fn create(config:CreateConfig)->AResult<()>{ let resp=RobloxContext::new(config.cookie) - .create(rbx_asset::context::CreateRequest{ - name:config.model_name, + .create_asset(rbx_asset::context::CreateAssetRequest{ + assetType:rbx_asset::context::AssetType::Model, + displayName:config.model_name, description:config.description, - ispublic:config.free_model, - allowComments:config.allow_comments, - groupId:config.group, + creationContext:rbx_asset::context::CreationContext{ + creator:rbx_asset::context::Creator{ + userId:0,//ever needed? roblox should implicitly know this + groupId:config.group.unwrap_or(0), + }, + expectedPrice:0, + } },tokio::fs::read(config.input_file).await?).await?; println!("UploadResponse={:?}",resp); Ok(()) } -async fn upload_list(cookie:String,group:Option,asset_id_file_map:AssetIDFileMap)->AResult<()>{ +async fn upload_list(cookie:String,asset_id_file_map:AssetIDFileMap)->AResult<()>{ let context=RobloxContext::new(cookie); //this is calling map on the vec because the closure produces an iterator of futures futures::stream::iter(asset_id_file_map.into_iter() .map(|(asset_id,file)|{ let context=&context; async move{ - Ok((asset_id,context.upload(rbx_asset::context::UploadRequest{ - assetid:asset_id, - name:None, + Ok((asset_id,context.update_asset(rbx_asset::context::UpdateAssetRequest{ + assetId:asset_id, + displayName:None, description:None, - ispublic:None, - allowComments:None, - groupId:group, },tokio::fs::read(file).await?).await?)) } })) @@ -401,7 +404,7 @@ async fn download_list(cookie:String,asset_id_file_map:AssetIDFileMap)->AResult< .map(|(asset_id,file)|{ let context=&context; async move{ - Ok((file,context.download(rbx_asset::context::DownloadRequest{asset_id,version:None}).await?)) + Ok((file,context.download(rbx_asset::context::GetAssetRequest{asset_id,version:None}).await?)) } })) .buffer_unordered(CONCURRENT_REQUESTS) @@ -448,7 +451,7 @@ async fn get_version_history(context:&RobloxContext,asset_id:AssetID)->AResult=None; let mut asset_list=Vec::new(); loop{ - let mut page=context.history_page(rbx_asset::context::HistoryPageRequest{asset_id,cursor}).await?; + let mut page=context.history_page(rbx_asset::context::AssetVersionsRequest{asset_id,cursor}).await?; asset_list.append(&mut page.data); if page.nextPageCursor.is_none(){ break; @@ -513,7 +516,7 @@ async fn download_history(mut config:DownloadHistoryConfig)->AResult<()>{ //poll paged list of all asset versions let mut cursor:Option=None; loop{ - let mut page=context.history_page(rbx_asset::context::HistoryPageRequest{asset_id:config.asset_id,cursor}).await?; + let mut page=context.history_page(rbx_asset::context::AssetVersionsRequest{asset_id:config.asset_id,cursor}).await?; let context=&context; let output_folder=config.output_folder.clone(); let data=&page.data; @@ -543,7 +546,7 @@ async fn download_history(mut config:DownloadHistoryConfig)->AResult<()>{ let mut path=output_folder.clone(); path.push(format!("{}_v{}.rbxl",config.asset_id,version_number)); join_set.spawn(async move{ - let file=context.download(rbx_asset::context::DownloadRequest{asset_id:config.asset_id,version:Some(version_number)}).await?; + let file=context.download(rbx_asset::context::GetAssetRequest{asset_id:config.asset_id,version:Some(version_number)}).await?; tokio::fs::write(path,file).await?; @@ -649,7 +652,7 @@ struct DownloadDecompileConfig{ async fn download_decompile(config:DownloadDecompileConfig)->AResult<()>{ let context=RobloxContext::new(config.cookie); - let file=context.download(rbx_asset::context::DownloadRequest{asset_id:config.asset_id,version:None}).await?; + let file=context.download(rbx_asset::context::GetAssetRequest{asset_id:config.asset_id,version:None}).await?; let dom=load_dom(std::io::Cursor::new(file))?; let context=rox_compiler::DecompiledContext::from_dom(dom); @@ -831,7 +834,7 @@ async fn download_and_decompile_history_into_git(config:DownloadAndDecompileHist .map(|asset_version|{ let context=context.clone(); tokio::task::spawn(async move{ - let file=context.download(rbx_asset::context::DownloadRequest{asset_id,version:Some(asset_version.assetVersionNumber)}).await?; + let file=context.download(rbx_asset::context::GetAssetRequest{asset_id,version:Some(asset_version.assetVersionNumber)}).await?; let dom=load_dom(std::io::Cursor::new(file))?; Ok::<_,anyhow::Error>((asset_version,rox_compiler::DecompiledContext::from_dom(dom))) }) @@ -911,7 +914,7 @@ async fn compile_upload(config:CompileUploadConfig)->AResult<()>{ //upload it let context=RobloxContext::new(config.cookie); - context.upload(rbx_asset::context::UploadRequest{ + context.upload(rbx_asset::context::UpdateAssetRequest{ assetid:config.asset_id, name:None, description:None,