From b94850bf73537e8f861fe697cc659e716ff6430b Mon Sep 17 00:00:00 2001 From: Blake Mealey Date: Sun, 20 Oct 2024 17:37:16 -0500 Subject: [PATCH] feat(rbx_auth): check csrf token per request --- mantle/Cargo.lock | 11 +- mantle/mantle/src/commands/import.rs | 11 +- mantle/project-fixtures/light/mantle.yml | 8 + mantle/rbx_api/src/asset_aliases/mod.rs | 100 +++++++----- mantle/rbx_api/src/asset_permissions/mod.rs | 23 +-- .../rbx_api/src/asset_permissions/models.rs | 8 +- mantle/rbx_api/src/assets/mod.rs | 143 +++++++++------- mantle/rbx_api/src/badges/mod.rs | 126 +++++++++------ mantle/rbx_api/src/developer_products/mod.rs | 98 ++++++----- mantle/rbx_api/src/errors.rs | 4 + mantle/rbx_api/src/experiences/mod.rs | 109 ++++++++----- mantle/rbx_api/src/game_passes/mod.rs | 122 ++++++++------ mantle/rbx_api/src/groups/mod.rs | 40 +++-- mantle/rbx_api/src/helpers.rs | 26 +-- mantle/rbx_api/src/lib.rs | 26 ++- mantle/rbx_api/src/notifications/mod.rs | 49 +++--- mantle/rbx_api/src/places/mod.rs | 152 +++++++++++------- mantle/rbx_api/src/social_links/mod.rs | 88 ++++++---- mantle/rbx_api/src/spatial_voice/mod.rs | 36 +++-- mantle/rbx_api/src/thumbnails/mod.rs | 129 +++++++++------ mantle/rbx_auth/Cargo.toml | 3 + mantle/rbx_auth/examples/check_auth.rs | 14 -- mantle/rbx_auth/src/lib.rs | 126 ++++++++++----- mantle/rbx_auth/src/main.rs | 16 +- .../rbx_mantle/src/roblox_resource_manager.rs | 8 +- 25 files changed, 905 insertions(+), 571 deletions(-) delete mode 100644 mantle/rbx_auth/examples/check_auth.rs diff --git a/mantle/Cargo.lock b/mantle/Cargo.lock index 951a68a..8399b1f 100644 --- a/mantle/Cargo.lock +++ b/mantle/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "37bf3594c4c988a53154954629820791dde498571819ae4ca50ca811e060cc95" [[package]] name = "approx" @@ -1992,9 +1992,9 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -2460,9 +2460,12 @@ dependencies = [ name = "rbx_auth" version = "0.2.3-prerelease" dependencies = [ + "anyhow", + "async-trait", "clap 2.34.0", "env_logger", "log", + "parking_lot", "rbx_cookie", "reqwest 0.11.14", "serde_json", diff --git a/mantle/mantle/src/commands/import.rs b/mantle/mantle/src/commands/import.rs index 676df14..ab1625d 100644 --- a/mantle/mantle/src/commands/import.rs +++ b/mantle/mantle/src/commands/import.rs @@ -1,5 +1,7 @@ +use std::sync::Arc; + use rbx_api::{models::AssetId, RobloxApi}; -use rbx_auth::RobloxAuth; +use rbx_auth::{RobloxCookieStore, RobloxCsrfTokenStore}; use yansi::Paint; use rbx_mantle::{ @@ -54,14 +56,15 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, target_id: &s }; logger::start_action("Import target:"); - let roblox_auth = match RobloxAuth::new().await { - Ok(v) => v, + let cookie_store = match RobloxCookieStore::new() { + Ok(v) => Arc::new(v), Err(e) => { logger::end_action(Paint::red(e)); return 1; } }; - let roblox_api = match RobloxApi::new(roblox_auth) { + let csrf_token_store = RobloxCsrfTokenStore::new(); + let roblox_api = match RobloxApi::new(cookie_store, csrf_token_store) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); diff --git a/mantle/project-fixtures/light/mantle.yml b/mantle/project-fixtures/light/mantle.yml index 86d5cf9..8714e4b 100644 --- a/mantle/project-fixtures/light/mantle.yml +++ b/mantle/project-fixtures/light/mantle.yml @@ -13,6 +13,14 @@ target: name: Lightweight Mantle Test serverFill: reservedSlots: 4 + badges: + myBadge: + name: Badge + icon: assets/badge-2.png + assets: + - assets/* + thumbnails: + - assets/badge-2.png state: remote: diff --git a/mantle/rbx_api/src/asset_aliases/mod.rs b/mantle/rbx_api/src/asset_aliases/mod.rs index 83957c8..0723604 100644 --- a/mantle/rbx_api/src/asset_aliases/mod.rs +++ b/mantle/rbx_api/src/asset_aliases/mod.rs @@ -18,18 +18,23 @@ impl RobloxApi { asset_id: AssetId, name: String, ) -> RobloxApiResult<()> { - let req = self - .client - .post("https://apis.roblox.com/content-aliases-api/v1/universes/create-alias") - .header(header::CONTENT_LENGTH, 0) - .query(&[ - ("universeId", experience_id.to_string().as_str()), - ("name", name.as_str()), - ("type", "1"), - ("targetId", asset_id.to_string().as_str()), - ]); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post("https://apis.roblox.com/content-aliases-api/v1/universes/create-alias") + .header(header::CONTENT_LENGTH, 0) + .query(&[ + ("universeId", experience_id.to_string().as_str()), + ("name", name.as_str()), + ("type", "1"), + ("targetId", asset_id.to_string().as_str()), + ])) + }) + .await; + + handle(res).await?; Ok(()) } @@ -41,18 +46,23 @@ impl RobloxApi { previous_name: String, name: String, ) -> RobloxApiResult<()> { - let req = self - .client - .post("https://apis.roblox.com/content-aliases-api/v1/universes/update-alias") - .query(&[ - ("universeId", experience_id.to_string().as_str()), - ("oldName", previous_name.as_str()), - ("name", name.as_str()), - ("type", "1"), - ("targetId", asset_id.to_string().as_str()), - ]); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post("https://apis.roblox.com/content-aliases-api/v1/universes/update-alias") + .query(&[ + ("universeId", experience_id.to_string().as_str()), + ("oldName", previous_name.as_str()), + ("name", name.as_str()), + ("type", "1"), + ("targetId", asset_id.to_string().as_str()), + ])) + }) + .await; + + handle(res).await?; Ok(()) } @@ -62,13 +72,18 @@ impl RobloxApi { experience_id: AssetId, name: String, ) -> RobloxApiResult<()> { - let req = self - .client - .post("https://apis.roblox.com/content-aliases-api/v1/universes/delete-alias") - .header(header::CONTENT_LENGTH, 0) - .query(&[("universeId", &experience_id.to_string()), ("name", &name)]); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post("https://apis.roblox.com/content-aliases-api/v1/universes/delete-alias") + .header(header::CONTENT_LENGTH, 0) + .query(&[("universeId", &experience_id.to_string()), ("name", &name)])) + }) + .await; + + handle(res).await?; Ok(()) } @@ -78,15 +93,20 @@ impl RobloxApi { experience_id: AssetId, page: u32, ) -> RobloxApiResult { - let req = self - .client - .get("https://apis.roblox.com/content-aliases-api/v1/universes/get-aliases") - .query(&[ - ("universeId", &experience_id.to_string()), - ("page", &page.to_string()), - ]); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .get("https://apis.roblox.com/content-aliases-api/v1/universes/get-aliases") + .query(&[ + ("universeId", &experience_id.to_string()), + ("page", &page.to_string()), + ])) + }) + .await; + + handle_as_json(res).await } pub async fn get_all_asset_aliases( diff --git a/mantle/rbx_api/src/asset_permissions/mod.rs b/mantle/rbx_api/src/asset_permissions/mod.rs index 6b0e4bd..7bd8a4c 100644 --- a/mantle/rbx_api/src/asset_permissions/mod.rs +++ b/mantle/rbx_api/src/asset_permissions/mod.rs @@ -11,17 +11,22 @@ impl RobloxApi { request: R, ) -> RobloxApiResult<()> where - R: Into, + R: Into + Clone, { - let req = self - .client - .patch(format!( - "https://apis.roblox.com/asset-permissions-api/v1/assets/{}/permissions", - asset_id - )) - .json(&request.into()); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .patch(format!( + "https://apis.roblox.com/asset-permissions-api/v1/assets/{}/permissions", + asset_id + )) + .json(&request.clone().into())) + }) + .await; - handle(req).await?; + handle(res).await?; Ok(()) } diff --git a/mantle/rbx_api/src/asset_permissions/models.rs b/mantle/rbx_api/src/asset_permissions/models.rs index 9cace68..c65fe90 100644 --- a/mantle/rbx_api/src/asset_permissions/models.rs +++ b/mantle/rbx_api/src/asset_permissions/models.rs @@ -2,13 +2,13 @@ use serde::Serialize; use crate::models::AssetId; -#[derive(Serialize)] +#[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct GrantAssetPermissionsRequest { pub requests: Vec, } -#[derive(Serialize)] +#[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct GrantAssetPermissionsRequestRequest { pub subject_type: GrantAssetPermissionRequestSubjectType, @@ -16,12 +16,12 @@ pub struct GrantAssetPermissionsRequestRequest { pub action: GrantAssetPermissionRequestAction, } -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub enum GrantAssetPermissionRequestSubjectType { Universe, } -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub enum GrantAssetPermissionRequestAction { Use, } diff --git a/mantle/rbx_api/src/assets/mod.rs b/mantle/rbx_api/src/assets/mod.rs index 80197d9..d280214 100644 --- a/mantle/rbx_api/src/assets/mod.rs +++ b/mantle/rbx_api/src/assets/mod.rs @@ -2,8 +2,10 @@ pub mod models; use std::{ffi::OsStr, fs, path::PathBuf}; -use reqwest::header; +use reqwest::{header, Body}; use serde_json::json; +use tokio::fs::File; +use tokio_util::codec::{BytesCodec, FramedRead}; use crate::{ errors::{RobloxApiError, RobloxApiResult}, @@ -22,45 +24,58 @@ impl RobloxApi { file_path: PathBuf, group_id: Option, ) -> RobloxApiResult { - let data = fs::read(&file_path)?; - - let file_name = format!( - "Images/{}", - file_path.file_stem().and_then(OsStr::to_str).unwrap() - ); - - let mut req = self - .client - .post("https://data.roblox.com/data/upload/json") - .header(reqwest::header::CONTENT_TYPE, "*/*") - .body(data) - .query(&[ - ("assetTypeId", &AssetTypeId::Decal.to_string()), - ("name", &file_name), - ("description", &"madewithmantle".to_owned()), - ]); - if let Some(group_id) = group_id { - req = req.query(&[("groupId", &group_id.to_string())]); - } - - handle_as_json_with_status(req).await + let res = self + .csrf_token_store + .send_request(|| async { + let file_name = format!( + "Images/{}", + file_path.file_stem().and_then(OsStr::to_str).unwrap() + ); + + let file = File::open(&file_path).await?; + let reader = Body::wrap_stream(FramedRead::new(file, BytesCodec::new())); + + let mut req = self + .client + .post("https://data.roblox.com/data/upload/json") + .header(reqwest::header::CONTENT_TYPE, "*/*") + .body(reader) + .query(&[ + ("assetTypeId", &AssetTypeId::Decal.to_string()), + ("name", &file_name), + ("description", &"madewithmantle".to_owned()), + ]); + if let Some(group_id) = &group_id { + req = req.query(&[("groupId", group_id.to_string())]); + } + + Ok(req) + }) + .await; + + handle_as_json_with_status(res).await } pub async fn get_create_asset_quota( &self, asset_type: AssetTypeId, ) -> RobloxApiResult { - let req = self - .client - .get("https://publish.roblox.com/v1/asset-quotas") - .query(&[ - // TODO: Understand what this parameter does - ("resourceType", "1"), - ("assetType", &asset_type.to_string()), - ]); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .get("https://publish.roblox.com/v1/asset-quotas") + .query(&[ + // TODO: Understand what this parameter does + ("resourceType", "1"), + ("assetType", &asset_type.to_string()), + ])) + }) + .await; // TODO: Understand how to interpret multiple quota objects (rather than just using the first one) - (handle_as_json::(req).await?) + (handle_as_json::(res).await?) .quotas .first() .cloned() @@ -73,36 +88,46 @@ impl RobloxApi { group_id: Option, payment_source: CreatorType, ) -> RobloxApiResult { - let data = fs::read(&file_path)?; - - let file_name = format!( - "Audio/{}", - file_path.file_stem().and_then(OsStr::to_str).unwrap() - ); - - let req = self - .client - .post("https://publish.roblox.com/v1/audio") - .json(&json!({ - "name": file_name, - "file": base64::encode(data), - "groupId": group_id, - "paymentSource": payment_source - })); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + let data = fs::read(&file_path)?; + + let file_name = format!( + "Audio/{}", + file_path.file_stem().and_then(OsStr::to_str).unwrap() + ); + + Ok(self + .client + .post("https://publish.roblox.com/v1/audio") + .json(&json!({ + "name": file_name, + "file": base64::encode(data), + "groupId": group_id, + "paymentSource": payment_source + }))) + }) + .await; + + handle_as_json(res).await } pub async fn archive_asset(&self, asset_id: AssetId) -> RobloxApiResult<()> { - let req = self - .client - .post(format!( - "https://develop.roblox.com/v1/assets/{}/archive", - asset_id - )) - .header(header::CONTENT_LENGTH, 0); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( + "https://develop.roblox.com/v1/assets/{}/archive", + asset_id + )) + .header(header::CONTENT_LENGTH, 0)) + }) + .await; + + handle(res).await?; Ok(()) } diff --git a/mantle/rbx_api/src/badges/mod.rs b/mantle/rbx_api/src/badges/mod.rs index 5e11a57..fda867f 100644 --- a/mantle/rbx_api/src/badges/mod.rs +++ b/mantle/rbx_api/src/badges/mod.rs @@ -24,22 +24,27 @@ impl RobloxApi { payment_source: CreatorType, expected_cost: u32, ) -> RobloxApiResult { - let req = self - .client - .post(&format!( - "https://badges.roblox.com/v1/universes/{}/badges", - experience_id - )) - .multipart( - Form::new() - .part("request.files", get_file_part(icon_file_path).await?) - .text("request.name", name) - .text("request.description", description) - .text("request.paymentSourceType", payment_source.to_string()) - .text("request.expectedCost", expected_cost.to_string()), - ); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(&format!( + "https://badges.roblox.com/v1/universes/{}/badges", + experience_id + )) + .multipart( + Form::new() + .part("request.files", get_file_part(&icon_file_path).await?) + .text("request.name", name.clone()) + .text("request.description", description.clone()) + .text("request.paymentSourceType", payment_source.to_string()) + .text("request.expectedCost", expected_cost.to_string()), + )) + }) + .await; + + handle_as_json(res).await } pub async fn update_badge( @@ -49,16 +54,21 @@ impl RobloxApi { description: String, enabled: bool, ) -> RobloxApiResult<()> { - let req = self - .client - .patch(format!("https://badges.roblox.com/v1/badges/{}", badge_id)) - .json(&json!({ - "name": name, - "description": description, - "enabled": enabled, - })); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .patch(format!("https://badges.roblox.com/v1/badges/{}", badge_id)) + .json(&json!({ + "name": name, + "description": description, + "enabled": enabled, + }))) + }) + .await; + + handle(res).await?; Ok(()) } @@ -67,12 +77,17 @@ impl RobloxApi { &self, experience_id: AssetId, ) -> RobloxApiResult { - let req = self.client.get(format!( - "https://badges.roblox.com/v1/universes/{}/free-badges-quota", - experience_id - )); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://badges.roblox.com/v1/universes/{}/free-badges-quota", + experience_id + ))) + }) + .await; + + handle_as_json(res).await } pub async fn list_badges( @@ -80,15 +95,21 @@ impl RobloxApi { experience_id: AssetId, page_cursor: Option, ) -> RobloxApiResult { - let mut req = self.client.get(format!( - "https://badges.roblox.com/v1/universes/{}/badges", - experience_id - )); - if let Some(page_cursor) = page_cursor { - req = req.query(&[("cursor", &page_cursor)]); - } - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + let mut req = self.client.get(format!( + "https://badges.roblox.com/v1/universes/{}/badges", + experience_id + )); + if let Some(page_cursor) = &page_cursor { + req = req.query(&[("cursor", page_cursor)]); + } + Ok(req) + }) + .await; + + handle_as_json(res).await } pub async fn get_all_badges( @@ -117,14 +138,19 @@ impl RobloxApi { badge_id: AssetId, icon_file: PathBuf, ) -> RobloxApiResult { - let req = self - .client - .post(&format!( - "https://publish.roblox.com/v1/badges/{}/icon", - badge_id - )) - .multipart(Form::new().part("request.files", get_file_part(icon_file).await?)); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(&format!( + "https://publish.roblox.com/v1/badges/{}/icon", + badge_id + )) + .multipart(Form::new().part("request.files", get_file_part(&icon_file).await?))) + }) + .await; + + handle_as_json(res).await } } diff --git a/mantle/rbx_api/src/developer_products/mod.rs b/mantle/rbx_api/src/developer_products/mod.rs index bbffb06..e53e60a 100644 --- a/mantle/rbx_api/src/developer_products/mod.rs +++ b/mantle/rbx_api/src/developer_products/mod.rs @@ -23,15 +23,20 @@ impl RobloxApi { developer_product_id: AssetId, icon_file: PathBuf, ) -> RobloxApiResult { - let req = self - .client - .post(format!( - "https://apis.roblox.com/developer-products/v1/developer-products/{}/image", - developer_product_id - )) - .multipart(Form::new().part("imageFile", get_file_part(icon_file).await?)); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( + "https://apis.roblox.com/developer-products/v1/developer-products/{}/image", + developer_product_id + )) + .multipart(Form::new().part("imageFile", get_file_part(&icon_file).await?))) + }) + .await; + + handle_as_json(res).await } pub async fn create_developer_product( @@ -41,20 +46,25 @@ impl RobloxApi { price: u32, description: String, ) -> RobloxApiResult { - let req = self - .client - .post(format!( + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( "https://apis.roblox.com/developer-products/v1/universes/{}/developerproducts", experience_id )) - .header(header::CONTENT_LENGTH, 0) - .query(&[ - ("name", &name), - ("priceInRobux", &price.to_string()), - ("description", &description), - ]); - - handle_as_json(req).await + .header(header::CONTENT_LENGTH, 0) + .query(&[ + ("name", &name), + ("priceInRobux", &price.to_string()), + ("description", &description), + ])) + }) + .await; + + handle_as_json(res).await } pub async fn list_developer_products( @@ -62,15 +72,20 @@ impl RobloxApi { experience_id: AssetId, page: u32, ) -> RobloxApiResult { - let req = self - .client - .get("https://apis.roblox.com/developer-products/v1/developer-products/list") - .query(&[ - ("universeId", &experience_id.to_string()), - ("page", &page.to_string()), - ]); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .get("https://apis.roblox.com/developer-products/v1/developer-products/list") + .query(&[ + ("universeId", &experience_id.to_string()), + ("page", &page.to_string()), + ])) + }) + .await; + + handle_as_json(res).await } pub async fn get_all_developer_products( @@ -98,12 +113,17 @@ impl RobloxApi { &self, developer_product_id: AssetId, ) -> RobloxApiResult { - let req = self.client.get(format!( - "https://apis.roblox.com/developer-products/v1/developer-products/{}", - developer_product_id - )); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://apis.roblox.com/developer-products/v1/developer-products/{}", + developer_product_id + ))) + }) + .await; + + handle_as_json(res).await } pub async fn update_developer_product( @@ -114,7 +134,8 @@ impl RobloxApi { price: u32, description: String, ) -> RobloxApiResult<()> { - let req = self + let res = self.csrf_token_store.send_request(||async { +Ok(self .client .post(format!( "https://apis.roblox.com/developer-products/v1/universes/{}/developerproducts/{}/update", @@ -124,9 +145,10 @@ impl RobloxApi { "Name": name, "PriceInRobux": price, "Description": description, - })); + }))) + }).await; - handle(req).await?; + handle(res).await?; Ok(()) } diff --git a/mantle/rbx_api/src/errors.rs b/mantle/rbx_api/src/errors.rs index 77ace00..298fc4c 100644 --- a/mantle/rbx_api/src/errors.rs +++ b/mantle/rbx_api/src/errors.rs @@ -1,3 +1,4 @@ +use rbx_auth::CsrfTokenRequestError; use reqwest::StatusCode; use serde::Deserialize; use thiserror::Error; @@ -10,6 +11,9 @@ pub enum RobloxApiError { #[error("HTTP client error: {0}")] HttpClient(#[from] reqwest::Error), + #[error(transparent)] + RequestFactoryError(#[from] CsrfTokenRequestError), + #[error("Authorization has been denied for this request. Check your ROBLOSECURITY cookie.")] Authorization, diff --git a/mantle/rbx_api/src/experiences/mod.rs b/mantle/rbx_api/src/experiences/mod.rs index 4b6f49b..06fb4a2 100644 --- a/mantle/rbx_api/src/experiences/mod.rs +++ b/mantle/rbx_api/src/experiences/mod.rs @@ -17,42 +17,57 @@ impl RobloxApi { &self, group_id: Option, ) -> RobloxApiResult { - let mut req = self - .client - .post("https://apis.roblox.com/universes/v1/universes/create") - .json(&json!({ - "templatePlaceId": 95206881, - })); - - if let Some(group_id) = group_id { - req = req.query(&[("groupId", group_id.to_string())]); - } - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + let mut req = self + .client + .post("https://apis.roblox.com/universes/v1/universes/create") + .json(&json!({ + "templatePlaceId": 95206881, + })); + if let Some(group_id) = group_id { + req = req.query(&[("groupId", group_id.to_string())]); + } + Ok(req) + }) + .await; + + handle_as_json(res).await } pub async fn get_experience( &self, experience_id: AssetId, ) -> RobloxApiResult { - let req = self.client.get(format!( - "https://develop.roblox.com/v1/universes/{}", - experience_id - )); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://develop.roblox.com/v1/universes/{}", + experience_id + ))) + }) + .await; + + handle_as_json(res).await } pub async fn get_experience_configuration( &self, experience_id: AssetId, ) -> RobloxApiResult { - let req = self.client.get(format!( - "https://develop.roblox.com/v1/universes/{}/configuration", - experience_id - )); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://develop.roblox.com/v1/universes/{}/configuration", + experience_id + ))) + }) + .await; + + handle_as_json(res).await } pub async fn configure_experience( @@ -60,15 +75,20 @@ impl RobloxApi { experience_id: AssetId, experience_configuration: &ExperienceConfigurationModel, ) -> RobloxApiResult<()> { - let req = self - .client - .patch(format!( - "https://develop.roblox.com/v2/universes/{}/configuration", - experience_id - )) - .json(experience_configuration); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .patch(format!( + "https://develop.roblox.com/v2/universes/{}/configuration", + experience_id + )) + .json(experience_configuration)) + }) + .await; + + handle(res).await?; Ok(()) } @@ -79,15 +99,20 @@ impl RobloxApi { active: bool, ) -> RobloxApiResult<()> { let endpoint = if active { "activate" } else { "deactivate" }; - let req = self - .client - .post(format!( - "https://develop.roblox.com/v1/universes/{}/{}", - experience_id, endpoint - )) - .header(header::CONTENT_LENGTH, 0); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( + "https://develop.roblox.com/v1/universes/{}/{}", + experience_id, endpoint + )) + .header(header::CONTENT_LENGTH, 0)) + }) + .await; + + handle(res).await?; Ok(()) } diff --git a/mantle/rbx_api/src/game_passes/mod.rs b/mantle/rbx_api/src/game_passes/mod.rs index 7ddff0e..815de1c 100644 --- a/mantle/rbx_api/src/game_passes/mod.rs +++ b/mantle/rbx_api/src/game_passes/mod.rs @@ -21,30 +21,42 @@ impl RobloxApi { experience_id: AssetId, page_cursor: Option, ) -> RobloxApiResult { - let mut req = self - .client - .get(format!( - "https://games.roblox.com/v1/games/{}/game-passes", - experience_id - )) - .query(&[("limit", 100.to_string())]); - if let Some(page_cursor) = page_cursor { - req = req.query(&[("cursor", &page_cursor)]); - } - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + let mut req = self + .client + .get(format!( + "https://games.roblox.com/v1/games/{}/game-passes", + experience_id + )) + .query(&[("limit", 100.to_string())]); + if let Some(page_cursor) = &page_cursor { + req = req.query(&[("cursor", page_cursor)]); + } + Ok(req) + }) + .await; + + handle_as_json(res).await } pub async fn get_game_pass( &self, game_pass_id: AssetId, ) -> RobloxApiResult { - let req = self.client.get(format!( - "https://economy.roblox.com/v1/game-pass/{}/game-pass-product-info", - game_pass_id - )); - - let mut model = handle_as_json::(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + let req = self.client.get(format!( + "https://economy.roblox.com/v1/game-pass/{}/game-pass-product-info", + game_pass_id + )); + Ok(req) + }) + .await; + + let mut model = handle_as_json::(res).await?; if model.target_id == 0 { model.target_id = game_pass_id; } @@ -83,18 +95,23 @@ impl RobloxApi { description: String, icon_file: PathBuf, ) -> RobloxApiResult { - let req = self - .client - .post("https://apis.roblox.com/game-passes/v1/game-passes") - .multipart( - Form::new() - .text("Name", name.clone()) - .text("Description", description.clone()) - .text("UniverseId", experience_id.to_string()) - .part("File", get_file_part(icon_file).await?), - ); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post("https://apis.roblox.com/game-passes/v1/game-passes") + .multipart( + Form::new() + .text("Name", name.clone()) + .text("Description", description.clone()) + .text("UniverseId", experience_id.to_string()) + .part("File", get_file_part(&icon_file).await?), + )) + }) + .await; + + handle_as_json(res).await } pub async fn update_game_pass( @@ -105,26 +122,31 @@ impl RobloxApi { price: Option, icon_file: Option, ) -> RobloxApiResult { - let mut form = Form::new() - .text("name", name) - .text("description", description) - .text("isForSale", price.is_some().to_string()); - if let Some(price) = price { - form = form.text("price", price.to_string()); - } - if let Some(icon_file) = icon_file { - form = form.part("file", get_file_part(icon_file).await?); - } - - let req = self - .client - .post(format!( - "https://apis.roblox.com/game-passes/v1/game-passes/{}/details", - game_pass_id - )) - .multipart(form); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + let mut form = Form::new() + .text("name", name.clone()) + .text("description", description.clone()) + .text("isForSale", price.is_some().to_string()); + if let Some(price) = &price { + form = form.text("price", price.to_string()); + } + if let Some(icon_file) = &icon_file { + form = form.part("file", get_file_part(icon_file).await?); + } + + Ok(self + .client + .post(format!( + "https://apis.roblox.com/game-passes/v1/game-passes/{}/details", + game_pass_id + )) + .multipart(form)) + }) + .await; + + handle(res).await?; self.get_game_pass(game_pass_id).await } diff --git a/mantle/rbx_api/src/groups/mod.rs b/mantle/rbx_api/src/groups/mod.rs index 193ed46..cad0209 100644 --- a/mantle/rbx_api/src/groups/mod.rs +++ b/mantle/rbx_api/src/groups/mod.rs @@ -19,15 +19,20 @@ impl RobloxApi { user_id: AssetId, role_id: u64, ) -> RobloxApiResult<()> { - let req = self - .client - .patch(format!( - "https://groups.roblox.com/v1/groups/{}/users/{}", - group_id, user_id - )) - .json(&json!({ "roleId": role_id })); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .patch(format!( + "https://groups.roblox.com/v1/groups/{}/users/{}", + group_id, user_id + )) + .json(&json!({ "roleId": role_id }))) + }) + .await; + + handle(res).await?; Ok(()) } @@ -36,11 +41,16 @@ impl RobloxApi { &self, group_id: AssetId, ) -> RobloxApiResult { - let req = self.client.get(format!( - "https://groups.roblox.com/v1/groups/{}/roles", - group_id - )); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://groups.roblox.com/v1/groups/{}/roles", + group_id + ))) + }) + .await; + + handle_as_json(res).await } } diff --git a/mantle/rbx_api/src/helpers.rs b/mantle/rbx_api/src/helpers.rs index 6ac1ab7..f1eab3b 100644 --- a/mantle/rbx_api/src/helpers.rs +++ b/mantle/rbx_api/src/helpers.rs @@ -1,6 +1,7 @@ -use std::{ffi::OsStr, path::PathBuf}; +use std::{ffi::OsStr, path::Path}; -use log::trace; +use log::{debug, trace}; +use rbx_auth::CsrfTokenRequestError; use reqwest::{multipart::Part, Body}; use scraper::{Html, Selector}; use serde::de; @@ -49,9 +50,8 @@ pub async fn get_roblox_api_error_from_response(response: reqwest::Response) -> } pub async fn handle( - request_builder: reqwest::RequestBuilder, + result: Result, ) -> RobloxApiResult { - let result = request_builder.send().await; match result { Ok(response) => { // Check for redirects to the login page @@ -67,27 +67,30 @@ pub async fn handle( Err(get_roblox_api_error_from_response(response).await) } } + Err(CsrfTokenRequestError::RequestError(error)) => Err(error.into()), Err(error) => Err(error.into()), } } -pub async fn handle_as_json(request_builder: reqwest::RequestBuilder) -> RobloxApiResult +pub async fn handle_as_json( + result: Result, +) -> RobloxApiResult where T: de::DeserializeOwned, { - let res = handle(request_builder).await?; + let res = handle(result).await?; let full = res.text().await?; trace!("Handle JSON: {}", full); serde_json::from_str::(&full).map_err(|e| e.into()) } pub async fn handle_as_json_with_status( - request_builder: reqwest::RequestBuilder, + result: Result, ) -> RobloxApiResult where T: de::DeserializeOwned, { - let response = handle(request_builder).await?; + let response = handle(result).await?; let status_code = response.status(); let data = response.bytes().await?; if let Ok(error) = serde_json::from_slice::(&data) { @@ -101,8 +104,9 @@ where Ok(serde_json::from_slice::(&data)?) } -pub async fn get_file_part(file_path: PathBuf) -> RobloxApiResult { - let file = File::open(&file_path).await?; +pub async fn get_file_part(file_path: &Path) -> RobloxApiResult { + debug!("stream read {:?}", &file_path); + let file = File::open(file_path).await?; let reader = Body::wrap_stream(FramedRead::new(file, BytesCodec::new())); let file_name = file_path @@ -110,7 +114,7 @@ pub async fn get_file_part(file_path: PathBuf) -> RobloxApiResult { .and_then(OsStr::to_str) .ok_or_else(|| RobloxApiError::NoFileName(file_path.display().to_string()))? .to_owned(); - let mime = mime_guess::from_path(&file_path).first_or_octet_stream(); + let mime = mime_guess::from_path(file_path).first_or_octet_stream(); Ok(Part::stream(reader) .file_name(file_name) diff --git a/mantle/rbx_api/src/lib.rs b/mantle/rbx_api/src/lib.rs index e4a5937..454ea45 100644 --- a/mantle/rbx_api/src/lib.rs +++ b/mantle/rbx_api/src/lib.rs @@ -15,31 +15,43 @@ pub mod social_links; pub mod spatial_voice; pub mod thumbnails; +use std::sync::Arc; + use errors::{RobloxApiError, RobloxApiResult}; use helpers::handle; -use rbx_auth::{RobloxAuth, WithRobloxAuth}; +use rbx_auth::{RobloxCookieStore, RobloxCsrfTokenStore}; pub struct RobloxApi { client: reqwest::Client, + csrf_token_store: RobloxCsrfTokenStore, } impl RobloxApi { - pub fn new(roblox_auth: RobloxAuth) -> RobloxApiResult { + pub fn new( + cookie_store: Arc, + csrf_token_store: RobloxCsrfTokenStore, + ) -> RobloxApiResult { Ok(Self { + csrf_token_store, client: reqwest::Client::builder() .connection_verbose(true) .user_agent("Roblox/WinInet") - .roblox_auth(roblox_auth) + .cookie_provider(cookie_store) .build()?, }) } pub async fn validate_auth(&self) -> RobloxApiResult<()> { - let req = self - .client - .get("https://users.roblox.com/v1/users/authenticated"); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .get("https://users.roblox.com/v1/users/authenticated")) + }) + .await; - handle(req) + handle(res) .await .map_err(|_| RobloxApiError::Authorization)?; diff --git a/mantle/rbx_api/src/notifications/mod.rs b/mantle/rbx_api/src/notifications/mod.rs index 010eb94..589d220 100644 --- a/mantle/rbx_api/src/notifications/mod.rs +++ b/mantle/rbx_api/src/notifications/mod.rs @@ -20,16 +20,18 @@ impl RobloxApi { name: String, content: String, ) -> RobloxApiResult { - let req = self + let res = self.csrf_token_store.send_request(||async { + Ok(self .client .post("https://apis.roblox.com/notifications/v1/developer-configuration/create-notification") .json(&json!({ "universeId": experience_id, "name": name, "content": content, - })); + }))) + }).await; - handle_as_json(req).await + handle_as_json(res).await } pub async fn update_notification( @@ -38,29 +40,33 @@ impl RobloxApi { name: String, content: String, ) -> RobloxApiResult<()> { - let req = self + let res = self.csrf_token_store.send_request(||async { + Ok(self .client .post("https://apis.roblox.com/notifications/v1/developer-configuration/update-notification") .json(&json!({ "id": notification_id, "name": name, "content": content, - })); + }))) + }).await; - handle(req).await?; + handle(res).await?; Ok(()) } pub async fn archive_notification(&self, notification_id: String) -> RobloxApiResult<()> { - let req = self + let res = self.csrf_token_store.send_request(||async { + Ok(self .client .post("https://apis.roblox.com/notifications/v1/developer-configuration/archive-notification") .json(&json!({ "id": notification_id, - })); + }))) + }).await; - handle(req).await?; + handle(res).await?; Ok(()) } @@ -71,18 +77,21 @@ impl RobloxApi { count: u8, page_cursor: Option, ) -> RobloxApiResult { - let mut req = self - .client - .get("https://apis.roblox.com/notifications/v1/developer-configuration/experience-notifications-list") - .query(&[ - ("universeId", &experience_id.to_string()), - ("count", &count.to_string()), - ]); - if let Some(page_cursor) = page_cursor { - req = req.query(&[("cursor", &page_cursor)]); - } + let res = self.csrf_token_store.send_request(|| async { + let mut req = self + .client + .get("https://apis.roblox.com/notifications/v1/developer-configuration/experience-notifications-list") + .query(&[ + ("universeId", &experience_id.to_string()), + ("count", &count.to_string()), + ]); + if let Some(page_cursor) = &page_cursor { + req = req.query(&[("cursor", page_cursor)]); + } + Ok(req) + }).await; - handle_as_json(req).await + handle_as_json(res).await } pub async fn get_all_notifications( diff --git a/mantle/rbx_api/src/places/mod.rs b/mantle/rbx_api/src/places/mod.rs index d79fcf4..f1da9ac 100644 --- a/mantle/rbx_api/src/places/mod.rs +++ b/mantle/rbx_api/src/places/mod.rs @@ -33,26 +33,33 @@ impl RobloxApi { } }; - let data = fs::read(&place_file)?; - - let body: Body = match file_format { - PlaceFileFormat::Binary => data.into(), - PlaceFileFormat::Xml => String::from_utf8(data)?.into(), - }; - - let content_type = match file_format { - PlaceFileFormat::Binary => "application/octet-stream", - PlaceFileFormat::Xml => "application/xml", - }; - - let req = self - .client - .post("https://data.roblox.com/Data/Upload.ashx") - .query(&[("assetId", place_id.to_string())]) - .header("Content-Type", content_type) - .body(body); - - let result = handle(req).await; + let res = self + .csrf_token_store + .send_request(|| async { + let data = fs::read(&place_file)?; + + let body: Body = match file_format { + PlaceFileFormat::Binary => data.into(), + PlaceFileFormat::Xml => String::from_utf8(data)?.into(), + }; + + let content_type = match file_format { + PlaceFileFormat::Binary => "application/octet-stream", + PlaceFileFormat::Xml => "application/xml", + }; + + let req = self + .client + .post("https://data.roblox.com/Data/Upload.ashx") + .query(&[("assetId", place_id.to_string())]) + .header("Content-Type", content_type) + .body(body); + + Ok(req) + }) + .await; + + let result = handle(res).await; match result { Err(RobloxApiError::Roblox { @@ -82,11 +89,16 @@ impl RobloxApi { } pub async fn get_place(&self, place_id: AssetId) -> RobloxApiResult { - let req = self - .client - .get(format!("https://develop.roblox.com/v2/places/{}", place_id)); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .get(format!("https://develop.roblox.com/v2/places/{}", place_id))) + }) + .await; + + handle_as_json(res).await } pub async fn list_places( @@ -94,18 +106,23 @@ impl RobloxApi { experience_id: AssetId, page_cursor: Option, ) -> RobloxApiResult { - let mut req = self.client.get(format!( - "https://develop.roblox.com/v1/universes/{}/places", - experience_id - )); - if let Some(page_cursor) = page_cursor { - req = req.query(&[("cursor", &page_cursor)]); - } + let res = self + .csrf_token_store + .send_request(|| async { + let mut req = self.client.get(format!( + "https://develop.roblox.com/v1/universes/{}/places", + experience_id + )); + if let Some(page_cursor) = &page_cursor { + req = req.query(&[("cursor", page_cursor)]); + } + Ok(req) + }) + .await; - handle_as_json(req).await + handle_as_json(res).await } - // TODO: implement generic form pub async fn get_all_places( &self, experience_id: AssetId, @@ -135,15 +152,20 @@ impl RobloxApi { experience_id: AssetId, place_id: AssetId, ) -> RobloxApiResult<()> { - let req = self - .client - .post("https://www.roblox.com/universes/removeplace") - .form(&[ - ("universeId", &experience_id.to_string()), - ("placeId", &place_id.to_string()), - ]); - - handle_as_json_with_status::(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post("https://www.roblox.com/universes/removeplace") + .form(&[ + ("universeId", &experience_id.to_string()), + ("placeId", &place_id.to_string()), + ])) + }) + .await; + + handle_as_json_with_status::(res).await?; Ok(()) } @@ -152,17 +174,22 @@ impl RobloxApi { &self, experience_id: AssetId, ) -> RobloxApiResult { - let req = self - .client - .post(format!( - "https://apis.roblox.com/universes/v1/user/universes/{}/places", - experience_id - )) - .json(&json!({ - "templatePlaceId": 95206881 - })); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( + "https://apis.roblox.com/universes/v1/user/universes/{}/places", + experience_id + )) + .json(&json!({ + "templatePlaceId": 95206881 + }))) + }) + .await; + + handle_as_json(res).await } pub async fn configure_place( @@ -170,12 +197,17 @@ impl RobloxApi { place_id: AssetId, place_configuration: &PlaceConfigurationModel, ) -> RobloxApiResult<()> { - let req = self - .client - .patch(format!("https://develop.roblox.com/v2/places/{}", place_id)) - .json(place_configuration); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .patch(format!("https://develop.roblox.com/v2/places/{}", place_id)) + .json(place_configuration)) + }) + .await; + + handle(res).await?; Ok(()) } diff --git a/mantle/rbx_api/src/social_links/mod.rs b/mantle/rbx_api/src/social_links/mod.rs index abf86e1..dfe468e 100644 --- a/mantle/rbx_api/src/social_links/mod.rs +++ b/mantle/rbx_api/src/social_links/mod.rs @@ -21,19 +21,24 @@ impl RobloxApi { url: String, link_type: SocialLinkType, ) -> RobloxApiResult { - let req = self - .client - .post(format!( - "https://develop.roblox.com/v1/universes/{}/social-links", - experience_id - )) - .json(&json!({ - "title": title, - "url": url, - "type": link_type, - })); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( + "https://develop.roblox.com/v1/universes/{}/social-links", + experience_id + )) + .json(&json!({ + "title": title, + "url": url, + "type": link_type, + }))) + }) + .await; - handle_as_json(req).await + handle_as_json(res).await } pub async fn update_social_link( @@ -44,19 +49,24 @@ impl RobloxApi { url: String, link_type: SocialLinkType, ) -> RobloxApiResult<()> { - let req = self - .client - .patch(format!( - "https://develop.roblox.com/v1/universes/{}/social-links/{}", - experience_id, social_link_id - )) - .json(&json!({ - "title": title, - "url": url, - "type": link_type, - })); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .patch(format!( + "https://develop.roblox.com/v1/universes/{}/social-links/{}", + experience_id, social_link_id + )) + .json(&json!({ + "title": title, + "url": url, + "type": link_type, + }))) + }) + .await; - handle(req).await?; + handle(res).await?; Ok(()) } @@ -66,12 +76,17 @@ impl RobloxApi { experience_id: AssetId, social_link_id: AssetId, ) -> RobloxApiResult<()> { - let req = self.client.delete(format!( - "https://develop.roblox.com/v1/universes/{}/social-links/{}", - experience_id, social_link_id - )); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.delete(format!( + "https://develop.roblox.com/v1/universes/{}/social-links/{}", + experience_id, social_link_id + ))) + }) + .await; - handle(req).await?; + handle(res).await?; Ok(()) } @@ -80,11 +95,16 @@ impl RobloxApi { &self, experience_id: AssetId, ) -> RobloxApiResult> { - let req = self.client.get(format!( - "https://games.roblox.com/v1/games/{}/social-links/list", - experience_id - )); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://games.roblox.com/v1/games/{}/social-links/list", + experience_id + ))) + }) + .await; - Ok(handle_as_json::(req).await?.data) + Ok(handle_as_json::(res).await?.data) } } diff --git a/mantle/rbx_api/src/spatial_voice/mod.rs b/mantle/rbx_api/src/spatial_voice/mod.rs index 6c59e4f..a320658 100644 --- a/mantle/rbx_api/src/spatial_voice/mod.rs +++ b/mantle/rbx_api/src/spatial_voice/mod.rs @@ -13,26 +13,36 @@ impl RobloxApi { experience_id: AssetId, settings: UpdateSpatialVoiceSettingsRequest, ) -> RobloxApiResult { - let req = self - .client - .post(format!( - "https://voice.roblox.com/v1/settings/universe/{}", - experience_id - )) - .json(&settings); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( + "https://voice.roblox.com/v1/settings/universe/{}", + experience_id + )) + .json(&settings)) + }) + .await; - handle_as_json::(req).await + handle_as_json::(res).await } pub async fn get_spatial_voice_settings( &self, experience_id: AssetId, ) -> RobloxApiResult { - let req = self.client.get(format!( - "https://voice.roblox.com/v1/settings/universe/{}", - experience_id - )); + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://voice.roblox.com/v1/settings/universe/{}", + experience_id + ))) + }) + .await; - handle_as_json::(req).await + handle_as_json::(res).await } } diff --git a/mantle/rbx_api/src/thumbnails/mod.rs b/mantle/rbx_api/src/thumbnails/mod.rs index a1ffcf9..9bdb442 100644 --- a/mantle/rbx_api/src/thumbnails/mod.rs +++ b/mantle/rbx_api/src/thumbnails/mod.rs @@ -15,21 +15,25 @@ use crate::{ use self::models::{GetExperienceThumbnailResponse, GetExperienceThumbnailsResponse}; impl RobloxApi { - // TODO: Generic form pub async fn upload_icon( &self, experience_id: AssetId, icon_file: PathBuf, ) -> RobloxApiResult { - let req = self - .client - .post(&format!( - "https://publish.roblox.com/v1/games/{}/icon", - experience_id - )) - .multipart(Form::new().part("request.files", get_file_part(icon_file).await?)); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(&format!( + "https://publish.roblox.com/v1/games/{}/icon", + experience_id + )) + .multipart(Form::new().part("request.files", get_file_part(&icon_file).await?))) + }) + .await; + + handle_as_json(res).await } pub async fn upload_thumbnail( @@ -37,15 +41,22 @@ impl RobloxApi { experience_id: AssetId, thumbnail_file: PathBuf, ) -> RobloxApiResult { - let req = self - .client - .post(&format!( - "https://publish.roblox.com/v1/games/{}/thumbnail/image", - experience_id - )) - .multipart(Form::new().part("request.files", get_file_part(thumbnail_file).await?)); - - handle_as_json(req).await + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(&format!( + "https://publish.roblox.com/v1/games/{}/thumbnail/image", + experience_id + )) + .multipart( + Form::new().part("request.files", get_file_part(&thumbnail_file).await?), + )) + }) + .await; + + handle_as_json(res).await } pub async fn remove_experience_icon( @@ -53,15 +64,20 @@ impl RobloxApi { start_place_id: AssetId, icon_asset_id: AssetId, ) -> RobloxApiResult<()> { - let req = self - .client - .post("https://www.roblox.com/places/icons/remove-icon") - .form(&[ - ("placeId", &start_place_id.to_string()), - ("placeIconId", &icon_asset_id.to_string()), - ]); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post("https://www.roblox.com/places/icons/remove-icon") + .form(&[ + ("placeId", &start_place_id.to_string()), + ("placeIconId", &icon_asset_id.to_string()), + ])) + }) + .await; + + handle(res).await?; Ok(()) } @@ -70,12 +86,17 @@ impl RobloxApi { &self, experience_id: AssetId, ) -> RobloxApiResult> { - let req = self.client.get(format!( - "https://games.roblox.com/v1/games/{}/media", - experience_id - )); - - Ok(handle_as_json::(req) + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.get(format!( + "https://games.roblox.com/v1/games/{}/media", + experience_id + ))) + }) + .await; + + Ok(handle_as_json::(res) .await? .data) } @@ -85,15 +106,20 @@ impl RobloxApi { experience_id: AssetId, new_thumbnail_order: &[AssetId], ) -> RobloxApiResult<()> { - let req = self - .client - .post(format!( - "https://develop.roblox.com/v1/universes/{}/thumbnails/order", - experience_id - )) - .json(&json!({ "thumbnailIds": new_thumbnail_order })); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self + .client + .post(format!( + "https://develop.roblox.com/v1/universes/{}/thumbnails/order", + experience_id + )) + .json(&json!({ "thumbnailIds": new_thumbnail_order }))) + }) + .await; + + handle(res).await?; Ok(()) } @@ -103,12 +129,17 @@ impl RobloxApi { experience_id: AssetId, thumbnail_id: AssetId, ) -> RobloxApiResult<()> { - let req = self.client.delete(format!( - "https://develop.roblox.com/v1/universes/{}/thumbnails/{}", - experience_id, thumbnail_id - )); - - handle(req).await?; + let res = self + .csrf_token_store + .send_request(|| async { + Ok(self.client.delete(format!( + "https://develop.roblox.com/v1/universes/{}/thumbnails/{}", + experience_id, thumbnail_id + ))) + }) + .await; + + handle(res).await?; Ok(()) } diff --git a/mantle/rbx_auth/Cargo.toml b/mantle/rbx_auth/Cargo.toml index e83aa18..aaff8c0 100644 --- a/mantle/rbx_auth/Cargo.toml +++ b/mantle/rbx_auth/Cargo.toml @@ -26,4 +26,7 @@ env_logger = { version = "0.9.0", optional = true } clap = { version = "2.33.0", optional = true } tokio = { version = "1", features = ["full"] } +async-trait = "0.1.51" serde_json = { version = "1.0.59" } +anyhow = "1.0.90" +parking_lot = { version = "0.12.3", features = ["send_guard"] } diff --git a/mantle/rbx_auth/examples/check_auth.rs b/mantle/rbx_auth/examples/check_auth.rs deleted file mode 100644 index 25eee5f..0000000 --- a/mantle/rbx_auth/examples/check_auth.rs +++ /dev/null @@ -1,14 +0,0 @@ -use rbx_auth::RobloxAuth; - -#[tokio::main] -async fn main() { - match RobloxAuth::new().await { - Ok(auth) => { - println!("{:?}", auth); - println!("\nSuccessfully authenticated!"); - } - Err(e) => { - println!("Failed to authenticate: {}", e); - } - } -} diff --git a/mantle/rbx_auth/src/lib.rs b/mantle/rbx_auth/src/lib.rs index 6c40d64..a5ab939 100644 --- a/mantle/rbx_auth/src/lib.rs +++ b/mantle/rbx_auth/src/lib.rs @@ -1,21 +1,19 @@ -use std::sync::Arc; +use std::future::Future; +use log::debug; +use parking_lot::RwLock; use reqwest::{ - cookie::Jar, - header::{self, HeaderMap, HeaderValue}, - Client, ClientBuilder, + cookie::{CookieStore, Jar}, + header::{HeaderMap, HeaderValue}, + RequestBuilder, Response, StatusCode, }; use thiserror::Error; use url::Url; #[derive(Error, Debug)] pub enum RobloxAuthError { - #[error("HTTP client error.")] - HttpClient(#[from] reqwest::Error), #[error("Unable to find ROBLOSECURITY cookie. Login to Roblox Studio or set the ROBLOSECURITY environment variable.")] MissingRoblosecurityCookie, - #[error("Request for CSRF token did not return an X-CSRF-Token header.")] - MissingCsrfToken, } // Temporary to make the new errors backwards compatible with the String errors throughout the project. @@ -25,14 +23,10 @@ impl From for String { } } -#[derive(Debug)] -pub struct RobloxAuth { - pub jar: Jar, - pub headers: HeaderMap, -} +pub struct RobloxCookieStore(Jar); -impl RobloxAuth { - pub async fn new() -> Result { +impl RobloxCookieStore { + pub fn new() -> Result { let roblosecurity_cookie = rbx_cookie::get().ok_or(RobloxAuthError::MissingRoblosecurityCookie)?; @@ -40,35 +34,91 @@ impl RobloxAuth { let url = "https://roblox.com".parse::().unwrap(); jar.add_cookie_str(&roblosecurity_cookie, &url); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert("X-CSRF-Token", get_csrf_token(&roblosecurity_cookie).await?); - - Ok(Self { jar, headers }) + Ok(Self(jar)) } } -async fn get_csrf_token(roblosecurity_cookie: &str) -> Result { - let response = Client::new() - .post("https://auth.roblox.com//") - .header(header::COOKIE, roblosecurity_cookie) - .header(header::CONTENT_LENGTH, 0) - .send() - .await?; - - response - .headers() - .get("X-CSRF-Token") - .map(|v| v.to_owned()) - .ok_or(RobloxAuthError::MissingCsrfToken) +impl CookieStore for RobloxCookieStore { + fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { + self.0.set_cookies(cookie_headers, url) + } + + fn cookies(&self, url: &url::Url) -> Option { + self.0.cookies(url) + } } -pub trait WithRobloxAuth { - fn roblox_auth(self, roblox_auth: RobloxAuth) -> Self; +#[derive(Error, Debug)] +pub enum CsrfTokenRequestError { + #[error("Failed to create request: {0}")] + RequestFactoryError(#[from] anyhow::Error), + #[error(transparent)] + RequestError(#[from] reqwest::Error), } -impl WithRobloxAuth for ClientBuilder { - fn roblox_auth(self, roblox_auth: RobloxAuth) -> Self { - self.cookie_provider(Arc::new(roblox_auth.jar)) - .default_headers(roblox_auth.headers) +const CSRF_TOKEN_HEADER_NAME: &str = "X-CSRF-TOKEN"; + +pub struct RobloxCsrfTokenStore(RwLock>); + +impl RobloxCsrfTokenStore { + pub fn new() -> Self { + Self(RwLock::new(None)) + } + + /** + * Updates the auth instance's CSRF token from a response's headers map if it is different from the + * current value. Returns a boolean indicating whether a new value was found from the headers map. + */ + pub fn set_csrf_token_from_headers(&self, headers: &HeaderMap) -> bool { + let headers_csrf_token = headers.get(CSRF_TOKEN_HEADER_NAME); + let new_value = match (self.0.read().as_ref(), headers_csrf_token) { + (None, Some(new_value)) => Some(new_value.clone()), + (Some(prev_value), Some(new_value)) if prev_value != new_value => { + Some(new_value.clone()) + } + _ => None, + }; + + match new_value { + Some(value) => { + debug!("Store CSRF token: {}", value.to_str().unwrap_or("INVALID")); + self.0.write().replace(value); + true + } + None => false, + } + } + + pub async fn send_request( + &self, + req_factory: F, + ) -> Result + where + F: Fn() -> Fut, + Fut: Future>, + { + let req = match self.0.read().as_ref() { + Some(value) => req_factory().await?.header(CSRF_TOKEN_HEADER_NAME, value), + None => req_factory().await?, + }; + let res = req.send().await?; + + let has_new_token = self.set_csrf_token_from_headers(res.headers()); + + match (res.status(), has_new_token) { + // If the response was forbidden and we have a new CSRF token, retry once + (StatusCode::FORBIDDEN, true) => match self.0.read().as_ref() { + Some(value) => { + debug!( + "Retry Forbidden request with new CSRF token: {}", + value.to_str().unwrap_or("INVALID") + ); + let req = req_factory().await?.header(CSRF_TOKEN_HEADER_NAME, value); + req.send().await.map_err(|e| e.into()) + } + None => Ok(res), + }, + _ => Ok(res), + } } } diff --git a/mantle/rbx_auth/src/main.rs b/mantle/rbx_auth/src/main.rs index a7ba836..b1d3f69 100644 --- a/mantle/rbx_auth/src/main.rs +++ b/mantle/rbx_auth/src/main.rs @@ -1,8 +1,8 @@ -use std::{env, fmt::Display}; +use std::{env, fmt::Display, sync::Arc}; use clap::{crate_version, App, Arg}; use log::error; -use rbx_auth::WithRobloxAuth; +use rbx_auth::{RobloxCookieStore, RobloxCsrfTokenStore}; use reqwest::StatusCode; use serde_json::Value; @@ -44,16 +44,18 @@ async fn main() { } async fn run(format: Option<&str>) -> Result<(), Box> { - let auth = rbx_auth::RobloxAuth::new().await?; + let cookie_store = Arc::new(RobloxCookieStore::new()?); + let csrf_token_store = RobloxCsrfTokenStore::new(); let client = reqwest::Client::builder() .user_agent("Roblox/WinInet") - .roblox_auth(auth) + .cookie_provider(cookie_store) .build()?; - let res = client - .get("https://users.roblox.com/v1/users/authenticated") - .send() + let res = csrf_token_store + .send_request(|| async { + Ok(client.get("https://users.roblox.com/v1/users/authenticated")) + }) .await?; match res.status() { diff --git a/mantle/rbx_mantle/src/roblox_resource_manager.rs b/mantle/rbx_mantle/src/roblox_resource_manager.rs index ab1b940..c5a7779 100644 --- a/mantle/rbx_mantle/src/roblox_resource_manager.rs +++ b/mantle/rbx_mantle/src/roblox_resource_manager.rs @@ -1,6 +1,7 @@ use std::{ env, path::{Path, PathBuf}, + sync::Arc, }; use async_trait::async_trait; @@ -28,7 +29,7 @@ use rbx_api::{ spatial_voice::models::UpdateSpatialVoiceSettingsRequest, RobloxApi, }; -use rbx_auth::RobloxAuth; +use rbx_auth::{RobloxCookieStore, RobloxCsrfTokenStore}; use rbxcloud::rbx::{ types::{PlaceId, UniverseId}, v1::{PublishVersionType, RbxCloud}, @@ -335,8 +336,9 @@ pub struct RobloxResourceManager { impl RobloxResourceManager { pub async fn new(project_path: &Path, payment_source: CreatorType) -> Result { - let roblox_auth = RobloxAuth::new().await?; - let roblox_api = RobloxApi::new(roblox_auth)?; + let cookie_store = Arc::new(RobloxCookieStore::new()?); + let csrf_token_store = RobloxCsrfTokenStore::new(); + let roblox_api = RobloxApi::new(cookie_store, csrf_token_store)?; roblox_api.validate_auth().await?; let open_cloud_api_key = match env::var("MANTLE_OPEN_CLOUD_API_KEY") {