diff --git a/README.md b/README.md index 15e4843..62a053d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ To install release artifacts from private repositories, drillbit needs a GitHub Fine-grained tokens need the **Contents: Read** permission on the repository. Classic tokens need the `repo` scope. +### Private Roblox Assets + +By default, `cloud` plugins are downloaded using the Roblox cookie from your Roblox Studio login, so private plugins your account has access to will work out of the box. + +If you'd rather use an [Open Cloud API key](https://create.roblox.com/dashboard/credentials) (e.g. in CI), set the `ROBLOX_API_KEY` environment variable. The key needs the **legacy-asset:manage** scope, and must be created by an account with access to the plugin. When set, drillbit uses the API key instead of your cookie. + ## Usage Simply run it in your project's directory: diff --git a/src/backends.rs b/src/backends.rs index 202bcac..2190e0e 100644 --- a/src/backends.rs +++ b/src/backends.rs @@ -62,34 +62,68 @@ impl Backend for LocalBackend { } pub struct CloudBackend { + api_key: Option, cookie: Option, client: Client, } impl CloudBackend { + fn get_api_key() -> Option { + let key = env::var("ROBLOX_API_KEY").ok()?; + let key = key.trim().to_string(); + (!key.is_empty()).then_some(key) + } + fn get_cookie(&mut self) -> Result { if self.cookie.is_none() { - let cookie = rbx_cookie::get().context("Couldn't get Roblox cookie")?; + let cookie = rbx_cookie::get().context( + "Couldn't get Roblox cookie. Log in to Roblox Studio or set ROBLOX_API_KEY.", + )?; self.cookie = Some(cookie); } Ok(self.cookie.as_ref().unwrap().clone()) } -} -#[async_trait] -impl Backend for CloudBackend { - fn new() -> Result { - Ok(Self { - cookie: None, - client: Client::new(), - }) - } + async fn download_with_api_key(&self, api_key: &str, id: u64) -> Result> { + let res = self + .client + .get(format!( + "https://apis.roblox.com/asset-delivery-api/v1/assetId/{id}" + )) + .header("x-api-key", api_key) + .send() + .await?; - async fn download(&mut self, plugin: &Plugin) -> Result<(Vec, Option)> { - let Plugin::Cloud(id) = plugin else { - unreachable!() - }; + if !res.status().is_success() { + if matches!( + res.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) { + bail!( + "Asset delivery for '{id}' failed with status: {}. Make sure ROBLOX_API_KEY has the legacy-asset:manage scope and access to this asset.", + res.status() + ); + } + bail!( + "Asset delivery for '{id}' failed with status: {}", + res.status() + ); + } + + let asset: OpenCloudAssetResponse = res.json().await?; + + // The location is a signed CDN URL, so the API key isn't sent along with it + let file_res = self.client.get(asset.location).send().await?; + + if !file_res.status().is_success() { + bail!("Download failed with status: {}", file_res.status()); + } + + Ok(file_res.bytes().await?.to_vec()) + } + + async fn download_with_cookie(&mut self, id: u64) -> Result> { let cookie = self.get_cookie()?; let url = format!("https://assetdelivery.roblox.com/v2/asset?id={id}"); let res = self @@ -100,6 +134,16 @@ impl Backend for CloudBackend { .await?; if !res.status().is_success() { + if matches!( + res.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) { + bail!( + "Request failed with status: {}. If this is a private asset, make sure your Roblox account has access to it, or set ROBLOX_API_KEY.", + res.status() + ); + } + bail!("Request failed with status: {}", res.status()); } @@ -122,7 +166,31 @@ impl Backend for CloudBackend { bail!("Download failed with status: {}", file_res.status()); } - Ok((file_res.bytes().await?.to_vec(), Some("rbxm".to_string()))) + Ok(file_res.bytes().await?.to_vec()) + } +} + +#[async_trait] +impl Backend for CloudBackend { + fn new() -> Result { + Ok(Self { + api_key: Self::get_api_key(), + cookie: None, + client: Client::new(), + }) + } + + async fn download(&mut self, plugin: &Plugin) -> Result<(Vec, Option)> { + let Plugin::Cloud(id) = plugin else { + unreachable!() + }; + + let data = match self.api_key.clone() { + Some(api_key) => self.download_with_api_key(&api_key, *id).await?, + None => self.download_with_cookie(*id).await?, + }; + + Ok((data, Some("rbxm".to_string()))) } fn plugin_id(&self, plugin: &Plugin, key: &str, cwd: &str) -> String { @@ -280,6 +348,11 @@ struct AssetResponse { locations: Vec, } +#[derive(Deserialize)] +struct OpenCloudAssetResponse { + location: String, +} + #[derive(Deserialize)] struct ReleaseAsset { id: u64,