mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
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:
parent
7b73720143
commit
2803c2d73a
7 changed files with 274 additions and 68 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -26,3 +26,6 @@
|
|||
|
||||
# JetBrains IDEs
|
||||
/.idea/
|
||||
|
||||
# Environment secrets
|
||||
.env
|
||||
|
|
|
|||
23
Cargo.lock
generated
23
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ rbx_cookie = "0.1.5"
|
|||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"blocking",
|
||||
"json",
|
||||
"multipart",
|
||||
"rustls",
|
||||
"gzip",
|
||||
"brotli",
|
||||
|
|
|
|||
37
scripts/publish-plugin.ps1
Normal file
37
scripts/publish-plugin.ps1
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ pub mod cli;
|
|||
#[cfg(test)]
|
||||
mod tree_view;
|
||||
|
||||
mod auth_cookie;
|
||||
mod change_processor;
|
||||
mod glob;
|
||||
mod json;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue