Add .env to .gitignore and update dependencies

- Added .env file to .gitignore to prevent environment secrets from being tracked.
- Updated Cargo.lock to include new dependencies: mime, mime_guess, and unicase.
- Modified Cargo.toml to enable multipart feature for reqwest.
- Introduced a new PowerShell script for building and publishing the Rojo plugin.
- Removed deprecated auth_cookie.rs file and adjusted upload command for new upload methods.
This commit is contained in:
Joe 2026-02-09 22:20:33 -06:00
parent 7b73720143
commit 2803c2d73a
7 changed files with 274 additions and 68 deletions

View file

@ -1,45 +0,0 @@
//! Implementation of automatically fetching authentication cookie from a Roblox
//! Studio installation.
#[cfg(windows)]
pub fn get_auth_cookie() -> Option<String> {
use winreg::{enums::HKEY_CURRENT_USER, RegKey};
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let cookies = hkcu
.open_subkey("Software\\Roblox\\RobloxStudioBrowser\\roblox.com")
.ok()?;
let entry: String = cookies.get_value(".ROBLOSECURITY").ok()?;
let mut cookie = None;
for kv_pair in entry.split(',') {
let mut pieces = kv_pair.split("::");
if let Some("COOK") = pieces.next() {
let value = match pieces.next() {
Some(value) => value,
None => {
log::warn!("Unrecognized Roblox Studio cookie value: missing COOK value");
return None;
}
};
if !value.starts_with('<') || !value.ends_with('>') {
log::warn!("Unrecognized Roblox Studio cookie value: was not wrapped in <>");
return None;
}
let value = &value[1..value.len() - 1];
cookie = Some(value);
}
}
cookie.map(Into::into)
}
#[cfg(not(windows))]
pub fn get_auth_cookie() -> Option<String> {
None
}

View file

@ -4,30 +4,43 @@ use anyhow::{bail, Context};
use clap::Parser;
use memofs::Vfs;
use reqwest::{
blocking::multipart,
header::{ACCEPT, CONTENT_TYPE, COOKIE, USER_AGENT},
StatusCode,
};
use serde_json::Value;
use crate::serve_session::ServeSession;
use super::resolve_path;
const ASSETS_API_BASE: &str = "https://apis.roblox.com/assets/v1";
const MAX_OPERATION_RETRIES: u32 = 10;
/// Builds the project and uploads it to Roblox.
///
/// Supports three upload modes:
/// - Legacy cookie auth (default): uses Data/Upload.ashx
/// - Open Cloud Assets API (--api_key only): for Creator Store assets (models, plugins)
/// - Open Cloud Places API (--api_key + --universe_id): for places
#[derive(Debug, Parser)]
pub struct UploadCommand {
/// Path to the project to upload. Defaults to the current directory.
#[clap(default_value = "")]
pub project: PathBuf,
/// Authenication cookie to use. If not specified, Rojo will attempt to find one from the system automatically.
/// Authentication cookie to use. If not specified, Rojo will attempt to find one from the system automatically.
#[clap(long)]
pub cookie: Option<String>,
/// API key obtained from create.roblox.com/credentials. Rojo will use the Open Cloud API when this is provided. Only supports uploading to a place.
/// API key obtained from create.roblox.com/credentials.
/// Without --universe_id, uses the Open Cloud Assets API (models, plugins).
/// With --universe_id, uses the Open Cloud Places API.
#[clap(long = "api_key")]
pub api_key: Option<String>,
/// The Universe ID of the given place. Required when using the Open Cloud API.
/// The Universe ID of the given place. When provided with --api_key,
/// uses the Open Cloud Places API instead of the Assets API.
#[clap(long = "universe_id")]
pub universe_id: Option<u64>,
@ -60,7 +73,7 @@ impl UploadCommand {
match (self.cookie, self.api_key, self.universe_id) {
(cookie, None, universe) => {
// using legacy. notify if universe is provided.
// Legacy cookie auth
if universe.is_some() {
log::warn!(
"--universe_id was provided but is ignored when using legacy upload"
@ -72,27 +85,32 @@ impl UploadCommand {
.context(
"Rojo could not find your Roblox auth cookie. Please log into Roblox Studio or pass one via --cookie.",
)?;
do_upload(buffer, self.asset_id, &cookie)
do_upload_legacy(buffer, self.asset_id, &cookie)
}
(cookie, Some(api_key), Some(universe_id)) => {
// using open cloud. notify if cookie is provided.
(cookie, Some(api_key), None) => {
// Open Cloud Assets API (models, plugins, Creator Store assets)
if cookie.is_some() {
log::warn!("--cookie was provided but is ignored when using Open Cloud API");
}
do_upload_open_cloud(buffer, universe_id, self.asset_id, &api_key)
do_upload_asset(buffer, self.asset_id, &api_key)
}
(_, Some(_), None) => {
// API key is provided, universe id is not.
bail!("--universe_id must be provided to use the Open Cloud API");
(cookie, Some(api_key), Some(universe_id)) => {
// Open Cloud Places API
if cookie.is_some() {
log::warn!("--cookie was provided but is ignored when using Open Cloud API");
}
do_upload_place(buffer, universe_id, self.asset_id, &api_key)
}
}
}
}
fn do_upload(buffer: Vec<u8>, asset_id: u64, cookie: &str) -> anyhow::Result<()> {
/// Legacy upload via Data/Upload.ashx with cookie auth.
fn do_upload_legacy(buffer: Vec<u8>, asset_id: u64, cookie: &str) -> anyhow::Result<()> {
let url = format!(
"https://data.roblox.com/Data/Upload.ashx?assetid={}",
asset_id
@ -110,7 +128,7 @@ fn do_upload(buffer: Vec<u8>, asset_id: u64, cookie: &str) -> anyhow::Result<()>
.body(buffer.clone())
};
log::debug!("Uploading to Roblox...");
log::debug!("Uploading to Roblox (legacy)...");
let mut response = build_request().send()?;
// Starting in Feburary, 2021, the upload endpoint performs CSRF challenges.
@ -125,18 +143,182 @@ fn do_upload(buffer: Vec<u8>, asset_id: u64, cookie: &str) -> anyhow::Result<()>
let status = response.status();
if !status.is_success() {
let body = response.text()?;
bail!(
"The Roblox API returned an unexpected error: {}",
response.text()?
"The Roblox API returned HTTP {}: {}",
status,
if body.is_empty() {
"(empty response)".to_owned()
} else {
body
}
);
}
Ok(())
}
/// Implementation of do_upload that supports the new open cloud api.
/// see https://developer.roblox.com/en-us/articles/open-cloud
fn do_upload_open_cloud(
/// Upload via the Open Cloud Assets API (PATCH /assets/v1/assets/{assetId}).
/// Works for Creator Store assets: models, plugins, etc.
///
/// Follows the same flow as Roblox's rbxasset tool:
/// 1. PATCH the asset with multipart form (request JSON + file content)
/// 2. Poll the returned operation until completion
///
/// See https://create.roblox.com/docs/reference/cloud/assets/v1
fn do_upload_asset(buffer: Vec<u8>, asset_id: u64, api_key: &str) -> anyhow::Result<()> {
let client = reqwest::blocking::Client::new();
// Step 1: PATCH the asset with new content
let url = format!("{}/assets/{}", ASSETS_API_BASE, asset_id);
let request_json = serde_json::json!({
"assetId": asset_id,
})
.to_string();
let form = multipart::Form::new()
.part(
"request",
multipart::Part::text(request_json)
.mime_str("application/json")?,
)
.part(
"fileContent",
multipart::Part::bytes(buffer)
.file_name("asset.rbxm")
.mime_str("model/x-rbxm")?,
);
log::info!("Uploading to Roblox (Open Cloud Assets API)...");
let response = client
.patch(&url)
.header("x-api-key", api_key)
.multipart(form)
.send()?;
let status = response.status();
let body: Value = response
.json()
.context("Failed to parse upload response as JSON")?;
if !status.is_success() {
let message = body["message"].as_str().unwrap_or("unknown error");
let code = body["code"].as_str().unwrap_or("");
bail!(
"The Roblox API returned HTTP {} ({}): {}",
status,
code,
message
);
}
// Step 2: Poll the operation until it completes
// The PATCH response returns { "path": "operations/{operationId}", "done": bool, ... }
if let Some(true) = body["done"].as_bool() {
// Operation completed immediately
log_asset_result(&body);
return Ok(());
}
let operation_path = body["path"]
.as_str()
.context("Upload response missing 'path' field for operation polling")?;
// Extract operationId from "operations/{operationId}"
let operation_id = operation_path
.strip_prefix("operations/")
.unwrap_or(operation_path);
log::info!("Upload accepted, waiting for processing (operation: {})...", operation_id);
let mut retry_delay = std::time::Duration::from_secs(1);
for attempt in 1..=MAX_OPERATION_RETRIES {
std::thread::sleep(retry_delay);
let op_url = format!("{}/operations/{}", ASSETS_API_BASE, operation_id);
let op_response = client
.get(&op_url)
.header("x-api-key", api_key)
.send()?;
let op_status = op_response.status();
let op_body: Value = op_response
.json()
.context("Failed to parse operation response as JSON")?;
if !op_status.is_success() {
log::warn!(
"Operation poll attempt {}/{} returned HTTP {}: {}",
attempt,
MAX_OPERATION_RETRIES,
op_status,
op_body
);
retry_delay *= 2;
continue;
}
if let Some(true) = op_body["done"].as_bool() {
// Check for error in the operation result
if op_body.get("error").is_some() {
let err_message = op_body["error"]["message"]
.as_str()
.unwrap_or("unknown error");
bail!("Asset processing failed: {}", err_message);
}
log_asset_result(&op_body);
return Ok(());
}
log::debug!(
"Operation not yet complete (attempt {}/{}), retrying in {}s...",
attempt,
MAX_OPERATION_RETRIES,
retry_delay.as_secs()
);
retry_delay *= 2;
}
bail!(
"Operation {} did not complete within {} attempts",
operation_id,
MAX_OPERATION_RETRIES
);
}
fn log_asset_result(body: &Value) {
if let Some(response) = body.get("response") {
let asset_id = response
.get("assetId")
.and_then(|v| v.as_str().or_else(|| v.as_u64().map(|_| "")));
let revision = response.get("revisionId").and_then(|v| v.as_str());
match (asset_id, revision) {
(Some(_), Some(rev)) => {
log::info!("Upload complete! Revision: {}", rev);
}
_ => {
log::info!("Upload complete!");
}
}
if let Some(moderation) = response.get("moderationResult") {
if let Some(state) = moderation.get("moderationState").and_then(|v| v.as_str()) {
log::info!("Moderation state: {}", state);
}
}
} else {
log::info!("Upload complete!");
}
}
/// Upload via the Open Cloud Places API.
/// See https://create.roblox.com/docs/cloud/guides/usage-place-publishing
fn do_upload_place(
buffer: Vec<u8>,
universe_id: u64,
asset_id: u64,
@ -149,20 +331,26 @@ fn do_upload_open_cloud(
let client = reqwest::blocking::Client::new();
log::debug!("Uploading to Roblox...");
log::debug!("Uploading to Roblox (Open Cloud Places API)...");
let response = client
.post(url)
.header("x-api-key", api_key)
.header(CONTENT_TYPE, "application/xml")
.header(CONTENT_TYPE, "application/octet-stream")
.header(ACCEPT, "application/json")
.body(buffer)
.send()?;
let status = response.status();
if !status.is_success() {
let body = response.text()?;
bail!(
"The Roblox API returned an unexpected error: {}",
response.text()?
"The Roblox API returned HTTP {}: {}",
status,
if body.is_empty() {
"(empty response)".to_owned()
} else {
body
}
);
}

View file

@ -7,7 +7,6 @@ pub mod cli;
#[cfg(test)]
mod tree_view;
mod auth_cookie;
mod change_processor;
mod glob;
mod json;