diff --git a/.gitignore b/.gitignore index a660919d..970054a8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ # JetBrains IDEs /.idea/ + +# Environment secrets +.env diff --git a/Cargo.lock b/Cargo.lock index ec4e0296..89dedbf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 8ac6f74b..5b6cc68f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ rbx_cookie = "0.1.5" reqwest = { version = "0.13", default-features = false, features = [ "blocking", "json", + "multipart", "rustls", "gzip", "brotli", diff --git a/scripts/publish-plugin.ps1 b/scripts/publish-plugin.ps1 new file mode 100644 index 00000000..6c974936 --- /dev/null +++ b/scripts/publish-plugin.ps1 @@ -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 " -ForegroundColor Yellow + exit 1 +} + +Write-Host "Building and uploading plugin..." -ForegroundColor Cyan +cargo run -- upload plugin.project.json --asset_id 111151139098227 diff --git a/src/auth_cookie.rs b/src/auth_cookie.rs deleted file mode 100644 index 83b2ca61..00000000 --- a/src/auth_cookie.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Implementation of automatically fetching authentication cookie from a Roblox -//! Studio installation. - -#[cfg(windows)] -pub fn get_auth_cookie() -> Option { - 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 { - None -} diff --git a/src/cli/upload.rs b/src/cli/upload.rs index c72f96f5..cf8f0e53 100644 --- a/src/cli/upload.rs +++ b/src/cli/upload.rs @@ -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, - /// 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, - /// 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, @@ -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, asset_id: u64, cookie: &str) -> anyhow::Result<()> { +/// Legacy upload via Data/Upload.ashx with cookie auth. +fn do_upload_legacy(buffer: Vec, 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, 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, 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, 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, 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 + } ); } diff --git a/src/lib.rs b/src/lib.rs index 5727197b..889de19e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,6 @@ pub mod cli; #[cfg(test)] mod tree_view; -mod auth_cookie; mod change_processor; mod glob; mod json;