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

3
.gitignore vendored
View file

@ -26,3 +26,6 @@
# JetBrains IDEs
/.idea/
# Environment secrets
.env

23
Cargo.lock generated
View file

@ -1810,6 +1810,22 @@ dependencies = [
"tempfile",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -2545,6 +2561,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -3617,6 +3634,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.23"

View file

@ -79,6 +79,7 @@ rbx_cookie = "0.1.5"
reqwest = { version = "0.13", default-features = false, features = [
"blocking",
"json",
"multipart",
"rustls",
"gzip",
"brotli",

View file

@ -0,0 +1,37 @@
# Build and publish the Rojo plugin to the Creator Store
# Loads API key from .env file, ROBLOX_API_KEY env var, or -ApiKey argument.
# Get an API key at https://create.roblox.com/credentials (assets read+write).
param(
[string]$ApiKey
)
$ErrorActionPreference = "Stop"
# Load .env file if it exists
$envFile = Join-Path $PSScriptRoot "..\.env"
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
$line = $line.Trim()
if ($line -and -not $line.StartsWith("#")) {
$key, $value = $line -split "=", 2
if ($key -and $value) {
[Environment]::SetEnvironmentVariable($key.Trim(), $value.Trim(), "Process")
}
}
}
}
# Resolve API key: argument > env var > .env (already loaded above)
if (-not $ApiKey) {
$ApiKey = $env:ROBLOX_API_KEY
}
if (-not $ApiKey -or $ApiKey -eq "your-key-here") {
Write-Host "Error: No API key provided." -ForegroundColor Red
Write-Host "Set ROBLOX_API_KEY in .env, env var, or pass -ApiKey <key>" -ForegroundColor Yellow
exit 1
}
Write-Host "Building and uploading plugin..." -ForegroundColor Cyan
cargo run -- upload plugin.project.json --asset_id 111151139098227

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;