Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
103 changes: 88 additions & 15 deletions src/backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,34 +62,68 @@ impl Backend for LocalBackend {
}

pub struct CloudBackend {
api_key: Option<String>,
cookie: Option<String>,
client: Client,
}

impl CloudBackend {
fn get_api_key() -> Option<String> {
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<String> {
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<Self> {
Ok(Self {
cookie: None,
client: Client::new(),
})
}
async fn download_with_api_key(&self, api_key: &str, id: u64) -> Result<Vec<u8>> {
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<u8>, Option<String>)> {
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<Vec<u8>> {
let cookie = self.get_cookie()?;
let url = format!("https://assetdelivery.roblox.com/v2/asset?id={id}");
let res = self
Expand All @@ -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());
}

Expand All @@ -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<Self> {
Ok(Self {
api_key: Self::get_api_key(),
cookie: None,
client: Client::new(),
})
}

async fn download(&mut self, plugin: &Plugin) -> Result<(Vec<u8>, Option<String>)> {
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 {
Expand Down Expand Up @@ -280,6 +348,11 @@ struct AssetResponse {
locations: Vec<Location>,
}

#[derive(Deserialize)]
struct OpenCloudAssetResponse {
location: String,
}

#[derive(Deserialize)]
struct ReleaseAsset {
id: u64,
Expand Down
Loading