mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
- Updated CloneCommand to support multiple place IDs, allowing the creation of multi-place projects with individual project files for each place. - Refactored the initialization logic in InitCommand to streamline project setup and ensure proper handling of project templates. - Added a new function to fetch place names from the Roblox API, improving the user experience during project creation. - Updated TODO.txt with a new entry for fixing the luau-lsp sourcemap generator command.
312 lines
9.2 KiB
Rust
312 lines
9.2 KiB
Rust
use std::collections::HashMap;
|
|
use std::io::{self, Write as _};
|
|
|
|
use anyhow::{bail, Context};
|
|
use reqwest::header::{CACHE_CONTROL, COOKIE, PRAGMA, USER_AGENT};
|
|
use serde::Deserialize;
|
|
use tempfile::NamedTempFile;
|
|
|
|
/// Authentication method for Roblox API calls.
|
|
pub enum RobloxAuth {
|
|
Cookie(String),
|
|
ApiKey(String),
|
|
}
|
|
|
|
/// Resolve authentication: prefer `--opencloud` API key, fall back to system cookie.
|
|
pub fn resolve_auth(opencloud_key: Option<&str>) -> anyhow::Result<RobloxAuth> {
|
|
if let Some(key) = opencloud_key {
|
|
return Ok(RobloxAuth::ApiKey(key.to_string()));
|
|
}
|
|
|
|
let cookie = rbx_cookie::get_value().context(
|
|
"No OpenCloud API key (--opencloud) or Roblox auth cookie found. \
|
|
Pass --opencloud <KEY> or log into Roblox Studio.",
|
|
)?;
|
|
Ok(RobloxAuth::Cookie(cookie))
|
|
}
|
|
|
|
/// Try to resolve authentication, returning `None` if neither key nor cookie is available.
|
|
pub fn try_resolve_auth(opencloud_key: Option<&str>) -> Option<RobloxAuth> {
|
|
if let Some(key) = opencloud_key {
|
|
return Some(RobloxAuth::ApiKey(key.to_string()));
|
|
}
|
|
rbx_cookie::get_value().map(RobloxAuth::Cookie)
|
|
}
|
|
|
|
/// Download a place file from Roblox.
|
|
///
|
|
/// With API key: uses OpenCloud Asset Delivery API (`apis.roblox.com/asset-delivery-api`).
|
|
/// With cookie: uses legacy asset delivery (`assetdelivery.roblox.com`).
|
|
pub fn download_place(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<NamedTempFile> {
|
|
let bytes = match auth {
|
|
RobloxAuth::ApiKey(key) => download_place_opencloud(place_id, key)?,
|
|
RobloxAuth::Cookie(cookie) => download_place_cookie(place_id, cookie)?,
|
|
};
|
|
|
|
let mut temp_file = tempfile::Builder::new()
|
|
.prefix("rojo-syncback-")
|
|
.suffix(".rbxl")
|
|
.tempfile()
|
|
.context("Failed to create temporary file")?;
|
|
|
|
io::copy(&mut bytes.as_slice(), &mut temp_file)?;
|
|
temp_file.flush()?;
|
|
|
|
log::debug!(
|
|
"Downloaded {} bytes to {}",
|
|
bytes.len(),
|
|
temp_file.path().display()
|
|
);
|
|
|
|
Ok(temp_file)
|
|
}
|
|
|
|
fn download_place_opencloud(place_id: u64, api_key: &str) -> anyhow::Result<Vec<u8>> {
|
|
let url = format!(
|
|
"https://apis.roblox.com/asset-delivery-api/v1/assetId/{}",
|
|
place_id
|
|
);
|
|
|
|
let client = reqwest::blocking::Client::builder()
|
|
.gzip(true)
|
|
.brotli(true)
|
|
.deflate(true)
|
|
.build()?;
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.header("x-api-key", api_key)
|
|
.header("Accept-Encoding", "gzip")
|
|
.send()?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
bail!(
|
|
"Failed to download place {} via OpenCloud: HTTP {} - {}",
|
|
place_id,
|
|
status,
|
|
response.text().unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
let body: AssetDeliveryResponse = response.json().with_context(|| {
|
|
format!(
|
|
"Failed to parse OpenCloud asset delivery response for place {}",
|
|
place_id
|
|
)
|
|
})?;
|
|
|
|
let cdn_url = body.location.with_context(|| {
|
|
format!(
|
|
"OpenCloud asset delivery response for place {} has no location URL. \
|
|
Ensure the API key has the 'legacy-asset:manage' scope.",
|
|
place_id
|
|
)
|
|
})?;
|
|
|
|
let cdn_response = client.get(&cdn_url).send()?;
|
|
|
|
let cdn_status = cdn_response.status();
|
|
if !cdn_status.is_success() {
|
|
bail!(
|
|
"Failed to download place {} from CDN: HTTP {} - {}",
|
|
place_id,
|
|
cdn_status,
|
|
cdn_response.text().unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
Ok(cdn_response.bytes()?.to_vec())
|
|
}
|
|
|
|
fn download_place_cookie(place_id: u64, cookie: &str) -> anyhow::Result<Vec<u8>> {
|
|
let url = format!("https://assetdelivery.roblox.com/v1/asset/?id={}", place_id);
|
|
|
|
let client = reqwest::blocking::Client::builder()
|
|
.gzip(true)
|
|
.brotli(true)
|
|
.deflate(true)
|
|
.build()?;
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.header(COOKIE, format!(".ROBLOSECURITY={}", cookie))
|
|
.header(CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
|
.header(PRAGMA, "no-cache")
|
|
.header("Expires", "0")
|
|
.header(USER_AGENT, "Rojo")
|
|
.send()?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
bail!(
|
|
"Failed to download place {}: HTTP {} - {}",
|
|
place_id,
|
|
status,
|
|
response.text().unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
Ok(response.bytes()?.to_vec())
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AssetDeliveryResponse {
|
|
location: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct UniverseResponse {
|
|
#[serde(rename = "universeId")]
|
|
universe_id: u64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GamesResponse {
|
|
data: Vec<GameData>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GameData {
|
|
name: String,
|
|
}
|
|
|
|
/// Resolve a place ID to a universe ID.
|
|
///
|
|
/// Returns `None` if no auth is available. Works with both API key and cookie.
|
|
pub fn get_universe_id(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<u64> {
|
|
let url = format!(
|
|
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
|
place_id
|
|
);
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let req = apply_auth(client.get(&url), auth);
|
|
|
|
let response: UniverseResponse = req
|
|
.send()
|
|
.context("Failed to fetch universe ID from Roblox API")?
|
|
.json()
|
|
.context("Failed to parse universe ID response")?;
|
|
|
|
Ok(response.universe_id)
|
|
}
|
|
|
|
/// Resolve a place ID to its experience name via the Roblox API.
|
|
///
|
|
/// With cookie auth, uses both the universe API and the games API.
|
|
/// With API key, the games.roblox.com endpoint may not support API key auth,
|
|
/// so this falls back gracefully.
|
|
pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result<Option<String>> {
|
|
let client = reqwest::blocking::Client::new();
|
|
|
|
let universe_url = format!(
|
|
"https://apis.roblox.com/universes/v1/places/{}/universe",
|
|
place_id
|
|
);
|
|
let req = apply_auth(client.get(&universe_url), auth);
|
|
let universe: UniverseResponse = req
|
|
.send()
|
|
.context("Failed to reach Roblox universe API")?
|
|
.json()
|
|
.context("Failed to parse universe response")?;
|
|
|
|
let games_url = format!(
|
|
"https://games.roblox.com/v1/games?universeIds={}",
|
|
universe.universe_id
|
|
);
|
|
|
|
// games.roblox.com may not support API key auth; try with auth, fall back to no-auth
|
|
let req = apply_auth(client.get(&games_url), auth);
|
|
let games_result: Result<GamesResponse, _> = req.send().and_then(|r| r.json());
|
|
|
|
match games_result {
|
|
Ok(games) => {
|
|
if let Some(game) = games.data.into_iter().next() {
|
|
println!("Experience: {}", game.name);
|
|
return Ok(Some(game.name));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::debug!(
|
|
"Could not fetch game name (games API may not support this auth method): {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// If API key was used and games endpoint failed, try without auth
|
|
if matches!(auth, RobloxAuth::ApiKey(_)) {
|
|
let req = client.get(&games_url);
|
|
if let Ok(resp) = req.send() {
|
|
if let Ok(games) = resp.json::<GamesResponse>() {
|
|
if let Some(game) = games.data.into_iter().next() {
|
|
println!("Experience: {}", game.name);
|
|
return Ok(Some(game.name));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PlaceDetail {
|
|
#[serde(rename = "placeId")]
|
|
place_id: u64,
|
|
name: String,
|
|
}
|
|
|
|
/// Fetch place names for one or more place IDs via the multiget-place-details endpoint.
|
|
///
|
|
/// Returns a map of place_id -> place_name. Places that cannot be resolved are omitted.
|
|
/// Falls back to no-auth if API key auth fails (games.roblox.com may not support it).
|
|
pub fn fetch_place_names(
|
|
place_ids: &[u64],
|
|
auth: &RobloxAuth,
|
|
) -> anyhow::Result<HashMap<u64, String>> {
|
|
let client = reqwest::blocking::Client::new();
|
|
|
|
let mut url = "https://games.roblox.com/v1/games/multiget-place-details?".to_string();
|
|
for (i, id) in place_ids.iter().enumerate() {
|
|
if i > 0 {
|
|
url.push('&');
|
|
}
|
|
url.push_str(&format!("placeIds={id}"));
|
|
}
|
|
|
|
let try_parse = |resp: reqwest::blocking::Response| -> Option<HashMap<u64, String>> {
|
|
let details: Vec<PlaceDetail> = resp.json().ok()?;
|
|
let map: HashMap<u64, String> = details.into_iter().map(|d| (d.place_id, d.name)).collect();
|
|
Some(map)
|
|
};
|
|
|
|
let req = apply_auth(client.get(&url), auth);
|
|
if let Ok(resp) = req.send() {
|
|
if let Some(map) = try_parse(resp) {
|
|
return Ok(map);
|
|
}
|
|
}
|
|
|
|
if matches!(auth, RobloxAuth::ApiKey(_)) {
|
|
let req = client.get(&url);
|
|
if let Ok(resp) = req.send() {
|
|
if let Some(map) = try_parse(resp) {
|
|
return Ok(map);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(HashMap::new())
|
|
}
|
|
|
|
fn apply_auth(
|
|
req: reqwest::blocking::RequestBuilder,
|
|
auth: &RobloxAuth,
|
|
) -> reqwest::blocking::RequestBuilder {
|
|
match auth {
|
|
RobloxAuth::ApiKey(key) => req.header("x-api-key", key),
|
|
RobloxAuth::Cookie(cookie) => req.header(COOKIE, format!(".ROBLOSECURITY={}", cookie)),
|
|
}
|
|
}
|