librojo/cli/
upload.rs

1use std::path::PathBuf;
2
3use anyhow::{bail, Context};
4use clap::Parser;
5use memofs::Vfs;
6use reqwest::{
7    header::{ACCEPT, CONTENT_TYPE, COOKIE, USER_AGENT},
8    StatusCode,
9};
10
11use crate::{auth_cookie::get_auth_cookie, serve_session::ServeSession};
12
13use super::resolve_path;
14
15/// Builds the project and uploads it to Roblox.
16#[derive(Debug, Parser)]
17pub struct UploadCommand {
18    /// Path to the project to upload. Defaults to the current directory.
19    #[clap(default_value = "")]
20    pub project: PathBuf,
21
22    /// Authenication cookie to use. If not specified, Rojo will attempt to find one from the system automatically.
23    #[clap(long)]
24    pub cookie: Option<String>,
25
26    /// 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.
27    #[clap(long = "api_key")]
28    pub api_key: Option<String>,
29
30    /// The Universe ID of the given place. Required when using the Open Cloud API.
31    #[clap(long = "universe_id")]
32    pub universe_id: Option<u64>,
33
34    /// Asset ID to upload to.
35    #[clap(long = "asset_id")]
36    pub asset_id: u64,
37}
38
39impl UploadCommand {
40    pub fn run(self) -> Result<(), anyhow::Error> {
41        let project_path = resolve_path(&self.project);
42
43        let vfs = Vfs::new_default();
44
45        let session = ServeSession::new(vfs, project_path)?;
46
47        let tree = session.tree();
48        let inner_tree = tree.inner();
49        let root = inner_tree.root();
50
51        let encode_ids = match root.class.as_str() {
52            "DataModel" => root.children().to_vec(),
53            _ => vec![root.referent()],
54        };
55
56        let mut buffer = Vec::new();
57
58        log::trace!("Encoding binary model");
59        rbx_binary::to_writer(&mut buffer, tree.inner(), &encode_ids)?;
60
61        match (self.cookie, self.api_key, self.universe_id) {
62            (cookie, None, universe) => {
63                // using legacy. notify if universe is provided.
64                if universe.is_some() {
65                    log::warn!(
66                        "--universe_id was provided but is ignored when using legacy upload"
67                    );
68                }
69
70                let cookie = cookie.or_else(get_auth_cookie).context(
71                    "Rojo could not find your Roblox auth cookie. Please pass one via --cookie.",
72                )?;
73                do_upload(buffer, self.asset_id, &cookie)
74            }
75
76            (cookie, Some(api_key), Some(universe_id)) => {
77                // using open cloud. notify if cookie is provided.
78                if cookie.is_some() {
79                    log::warn!("--cookie was provided but is ignored when using Open Cloud API");
80                }
81
82                do_upload_open_cloud(buffer, universe_id, self.asset_id, &api_key)
83            }
84
85            (_, Some(_), None) => {
86                // API key is provided, universe id is not.
87                bail!("--universe_id must be provided to use the Open Cloud API");
88            }
89        }
90    }
91}
92
93fn do_upload(buffer: Vec<u8>, asset_id: u64, cookie: &str) -> anyhow::Result<()> {
94    let url = format!(
95        "https://data.roblox.com/Data/Upload.ashx?assetid={}",
96        asset_id
97    );
98
99    let client = reqwest::blocking::Client::new();
100
101    let build_request = move || {
102        client
103            .post(&url)
104            .header(COOKIE, format!(".ROBLOSECURITY={}", cookie))
105            .header(USER_AGENT, "Roblox/WinInet")
106            .header(CONTENT_TYPE, "application/xml")
107            .header(ACCEPT, "application/json")
108            .body(buffer.clone())
109    };
110
111    log::debug!("Uploading to Roblox...");
112    let mut response = build_request().send()?;
113
114    // Starting in Feburary, 2021, the upload endpoint performs CSRF challenges.
115    // If we receive an HTTP 403 with a X-CSRF-Token reply, we should retry the
116    // request, echoing the value of that header.
117    if response.status() == StatusCode::FORBIDDEN {
118        if let Some(csrf_token) = response.headers().get("X-CSRF-Token") {
119            log::debug!("Received CSRF challenge, retrying with token...");
120            response = build_request().header("X-CSRF-Token", csrf_token).send()?;
121        }
122    }
123
124    let status = response.status();
125    if !status.is_success() {
126        bail!(
127            "The Roblox API returned an unexpected error: {}",
128            response.text()?
129        );
130    }
131
132    Ok(())
133}
134
135/// Implementation of do_upload that supports the new open cloud api.
136/// see https://developer.roblox.com/en-us/articles/open-cloud
137fn do_upload_open_cloud(
138    buffer: Vec<u8>,
139    universe_id: u64,
140    asset_id: u64,
141    api_key: &str,
142) -> anyhow::Result<()> {
143    let url = format!(
144        "https://apis.roblox.com/universes/v1/{}/places/{}/versions?versionType=Published",
145        universe_id, asset_id
146    );
147
148    let client = reqwest::blocking::Client::new();
149
150    log::debug!("Uploading to Roblox...");
151    let response = client
152        .post(url)
153        .header("x-api-key", api_key)
154        .header(CONTENT_TYPE, "application/xml")
155        .header(ACCEPT, "application/json")
156        .body(buffer)
157        .send()?;
158
159    let status = response.status();
160    if !status.is_success() {
161        bail!(
162            "The Roblox API returned an unexpected error: {}",
163            response.text()?
164        );
165    }
166
167    Ok(())
168}