librojo/cli/
upload.rs

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