diff --git a/Cargo.lock b/Cargo.lock index d54686e..fe6a97a 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" @@ -1156,12 +1166,13 @@ dependencies = [ [[package]] name = "rbx_asset" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "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..5164554 100644 --- a/rbx_asset/Cargo.toml +++ b/rbx_asset/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rbx_asset" -version = "0.1.0" +version = "0.2.0" edition = "2021" publish = ["strafesnet"] @@ -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..0d3826b 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,58 +26,86 @@ 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, +} +#[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, } #[derive(Debug)] -pub enum DownloadError{ +pub enum GetError{ ParseError(url::ParseError), Reqwest(reqwest::Error), IO(std::io::Error) } -impl std::fmt::Display for DownloadError{ +impl std::fmt::Display for GetError{ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f,"{self:?}") } } -impl std::error::Error for DownloadError{} +impl std::error::Error for GetError{} -pub struct HistoryPageRequest{ +pub struct AssetVersionsRequest{ pub asset_id:u64, pub cursor:Option, } -#[derive(serde::Deserialize,serde::Serialize)] +#[derive(Debug,serde::Deserialize,serde::Serialize)] #[allow(nonstandard_style,dead_code)] pub struct AssetVersion{ pub Id:u64, @@ -95,36 +117,36 @@ pub struct AssetVersion{ pub created:chrono::DateTime, pub isPublished:bool, } -#[derive(serde::Deserialize)] +#[derive(Debug,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, pub cursor:Option, } -#[derive(serde::Deserialize,serde::Serialize)] +#[derive(Debug,serde::Deserialize,serde::Serialize)] #[allow(nonstandard_style,dead_code)] pub struct InventoryItem{ pub id:u64, pub name:String, } -#[derive(serde::Deserialize,serde::Serialize)] +#[derive(Debug,serde::Deserialize,serde::Serialize)] #[allow(nonstandard_style,dead_code)] pub struct InventoryPageResponse{ pub totalResults:u64,//up to 50 @@ -149,6 +171,16 @@ impl std::fmt::Display for InventoryPageError{ } impl std::error::Error for InventoryPageError{} +#[derive(Debug,serde::Deserialize,serde::Serialize)] +#[allow(nonstandard_style,dead_code)] +pub struct RobloxOperation{ + pub path:Option, + pub metadata:Option, + pub done:Option, + pub error:Option, + pub response:Option, +} + //idk how to do this better enum ReaderType{ GZip(flate2::read::GzDecoder>), @@ -170,97 +202,73 @@ 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>{ - let mut url=reqwest::Url::parse("https://assetdelivery.roblox.com/v1/asset/").map_err(DownloadError::ParseError)?; + 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)? + .error_for_status().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)? + //roblox api documentation is very poor, just give the status code and drop the json + .error_for_status().map_err(UpdateError::Reqwest)?; + + Ok(resp.json::().await.map_err(UpdateError::Reqwest)?) + } + pub async fn get_asset(&self,config:GetAssetRequest)->Result,GetError>{ + let mut url=reqwest::Url::parse("https://assetdelivery.roblox.com/v1/asset/").map_err(GetError::ParseError)?; //url borrow scope { let mut query=url.query_pairs_mut();//borrow here @@ -269,31 +277,22 @@ impl RobloxContext{ query.append_pair("version",version.to_string().as_str()); } } - let resp=self.get(url).await.map_err(DownloadError::Reqwest)?; + let resp=self.get(url).await.map_err(GetError::Reqwest)?; - let body=resp.bytes().await.map_err(DownloadError::Reqwest)?; + let body=resp.bytes().await.map_err(GetError::Reqwest)?; match maybe_gzip_decode(&mut 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(DownloadError::IO) + }.map_err(GetError::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,18 @@ 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 mut url=reqwest::Url::parse(raw_url.as_str()).map_err(UpdateError::ParseError)?; + //url borrow scope + { + let mut query=url.query_pairs_mut();//borrow here + query.append_pair("versionType","Published"); + } + + let resp=self.post(url,body).await.map_err(UpdateError::Reqwest)? + .error_for_status().map_err(UpdateError::Reqwest)?; + + Ok(resp.json::().await.map_err(UpdateError::Reqwest)?) + } +} diff --git a/src/main.rs b/src/main.rs index c32c9db..21b4deb 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)>; @@ -23,10 +23,12 @@ enum Commands{ Download(DownloadSubcommand), DownloadDecompile(DownloadDecompileSubcommand), DownloadGroupInventoryJson(DownloadGroupInventoryJsonSubcommand), - Create(CreateSubcommand), - Upload(UploadSubcommand), + CreateAsset(CreateAssetSubcommand), + UploadAsset(UpdateAssetSubcommand), + UploadPlace(UpdatePlaceSubcommand), Compile(CompileSubcommand), - CompileUpload(CompileUploadSubcommand), + CompileUploadAsset(CompileUploadAssetSubcommand), + CompileUploadPlace(CompileUploadPlaceSubcommand), Decompile(DecompileSubcommand), DecompileHistoryIntoGit(DecompileHistoryIntoGitSubcommand), DownloadAndDecompileHistoryIntoGit(DownloadAndDecompileHistoryIntoGitSubcommand), @@ -36,10 +38,12 @@ enum Commands{ struct DownloadHistorySubcommand{ #[arg(long)] asset_id:AssetID, - #[arg(long)] - cookie_type:CookieType, - #[arg(long)] - cookie:String, + #[arg(long,group="api_key",required=true)] + api_key_literal:Option, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, #[arg(long)] output_folder:Option, #[arg(long)] @@ -51,10 +55,12 @@ struct DownloadHistorySubcommand{ } #[derive(Args)] struct DownloadSubcommand{ - #[arg(long)] - cookie_type:CookieType, - #[arg(long)] - cookie:String, + #[arg(long,group="api_key",required=true)] + api_key_literal:Option, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, #[arg(long)] output_folder:Option, #[arg(required=true)] @@ -62,21 +68,25 @@ struct DownloadSubcommand{ } #[derive(Args)] struct DownloadGroupInventoryJsonSubcommand{ - #[arg(long)] - cookie_type:CookieType, - #[arg(long)] - cookie:String, + #[arg(long,group="api_key",required=true)] + api_key_literal:Option, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, #[arg(long)] output_folder:Option, #[arg(long)] group:u64, } #[derive(Args)] -struct CreateSubcommand{ - #[arg(long)] - cookie_type:CookieType, - #[arg(long)] - cookie:String, +struct CreateAssetSubcommand{ + #[arg(long,group="api_key",required=true)] + api_key_literal:Option, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, #[arg(long)] model_name:String, #[arg(long)] @@ -85,23 +95,34 @@ struct CreateSubcommand{ input_file:PathBuf, #[arg(long)] group:Option, - #[arg(long)] - free_model:Option, - #[arg(long)] - allow_comments:Option, } #[derive(Args)] -struct UploadSubcommand{ +struct UpdateAssetSubcommand{ #[arg(long)] asset_id:AssetID, - #[arg(long)] - cookie_type:CookieType, - #[arg(long)] - cookie:String, + #[arg(long,group="api_key",required=true)] + api_key_literal:Option, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, #[arg(long)] input_file:PathBuf, +} +#[derive(Args)] +struct UpdatePlaceSubcommand{ #[arg(long)] - group:Option, + place_id:u64, + #[arg(long)] + universe_id:u64, + #[arg(long,group="api_key",required=true)] + api_key_literal:Option, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, + #[arg(long)] + input_file:PathBuf, } #[derive(Args)] struct CompileSubcommand{ @@ -115,17 +136,34 @@ struct CompileSubcommand{ template:Option, } #[derive(Args)] -struct CompileUploadSubcommand{ +struct CompileUploadAssetSubcommand{ #[arg(long)] asset_id:AssetID, + #[arg(long,group="api_key",required=true)] + api_key_literal:Option, + #[arg(long,group="api_key",required=true)] + api_key_envvar:Option, + #[arg(long,group="api_key",required=true)] + api_key_file:Option, #[arg(long)] - cookie_type:CookieType, + input_folder:Option, #[arg(long)] - cookie:String, + style:Option