Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "wavedash"
version = "0.1.99"
version = "0.1.100"
edition = "2021"
authors = ["Wavedash Team"]
description = "Cross-platform CLI tool for uploading game projects to wavedash.com"
Expand Down
134 changes: 107 additions & 27 deletions src/achievements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use comfy_table::presets::UTF8_FULL;
use comfy_table::{Cell, ContentArrangement, Table};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::io::Read;
use std::path::Path;

/// The create response, narrowed to the fields printed by the command. This is
Expand Down Expand Up @@ -40,13 +41,28 @@ struct AchievementsResponse {

#[derive(Debug, Deserialize)]
struct ImageMediaUploadResponse {
#[serde(rename = "transformUrl")]
transform_url: String,
token: String,
#[serde(rename = "assemblyOptions")]
assembly_options: AssemblyOptions,
#[serde(rename = "maxBytes")]
max_bytes: u64,
#[serde(rename = "r2Key")]
r2_key: String,
}

#[derive(Debug, Deserialize)]
struct AssemblyOptions {
params: String,
signature: String,
}

#[derive(Debug, Deserialize)]
struct AssemblyStatus {
ok: Option<String>,
error: Option<String>,
message: Option<String>,
assembly_ssl_url: Option<String>,
}

async fn upload_achievement_image(
api_key: &str,
game_id: &str,
Expand All @@ -57,15 +73,7 @@ async fn upload_achievement_image(
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())
.ok_or_else(|| {
anyhow::anyhow!(
"Image file has no extension: {}",
image_path.display()
)
})?;

let bytes = std::fs::read(image_path)
.with_context(|| format!("Failed to read image file: {}", image_path.display()))?;
.ok_or_else(|| anyhow::anyhow!("Image file has no extension: {}", image_path.display()))?;

let client = config::create_http_client()?;
let api_host = config::get("api_host")?;
Expand All @@ -86,16 +94,91 @@ async fn upload_achievement_image(
let resp = config::check_api_response(resp).await?;
let authorization: ImageMediaUploadResponse = resp.json().await?;

let transform_resp = client
.post(&authorization.transform_url)
.header("Authorization", format!("Bearer {}", authorization.token))
.body(bytes)
let file = std::fs::File::open(image_path)
.with_context(|| format!("Failed to open image file: {}", image_path.display()))?;
let file_size = file.metadata()?.len();
if file_size > authorization.max_bytes {
anyhow::bail!(
"Image must be between 1 and {} bytes",
authorization.max_bytes
);
}
Comment thread
franzwarning marked this conversation as resolved.
let mut bytes = Vec::new();
file.take(authorization.max_bytes.saturating_add(1))
.read_to_end(&mut bytes)
.with_context(|| format!("Failed to read image file: {}", image_path.display()))?;
if bytes.is_empty() || bytes.len() as u64 > authorization.max_bytes {
anyhow::bail!(
"Image must be between 1 and {} bytes",
authorization.max_bytes
);
}
Comment thread
franzwarning marked this conversation as resolved.
let form = reqwest::multipart::Form::new()
.text("params", authorization.assembly_options.params)
.text("signature", authorization.assembly_options.signature)
.part(
"file",
reqwest::multipart::Part::bytes(bytes).file_name(
image_path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned(),
),
);
let media_client = reqwest::Client::new();
let mut response = media_client
.post("https://api2.transloadit.com/assemblies")
.multipart(form)
.timeout(std::time::Duration::from_secs(900))
.send()
.await?;
if !transform_resp.status().is_success() {
let status = transform_resp.status();
let body = transform_resp.text().await.unwrap_or_default();
anyhow::bail!("Image transform failed ({}): {}", status, body);
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(900);
let mut status_url: Option<String> = None;
loop {
let http_status = response.status();
let status: AssemblyStatus = response
.json()
.await
.context("Invalid Transloadit response")?;
if !http_status.is_success() || status.error.is_some() {
anyhow::bail!(
"Image processing failed: {}",
status
.message
.or(status.error)
.unwrap_or_else(|| http_status.to_string())
);
}
match status.ok.as_deref() {
Some("ASSEMBLY_COMPLETED") => break,
Some("ASSEMBLY_UPLOADING" | "ASSEMBLY_EXECUTING") => {}
_ => anyhow::bail!("Unexpected image processing status: {:?}", status.ok),
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!("Image processing timed out");
}
if status_url.is_none() {
status_url = status.assembly_ssl_url;
}
let url = reqwest::Url::parse(
status_url
.as_deref()
.context("Missing Assembly status URL")?,
)?;
if url.scheme() != "https"
|| !url
.host_str()
.is_some_and(|host| host.ends_with(".transloadit.com"))
{
anyhow::bail!("Invalid Transloadit Assembly status URL");
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
response = media_client
.get(url)
.timeout(std::time::Duration::from_secs(30))
.send()
.await?;
}

Ok(authorization.r2_key)
Expand Down Expand Up @@ -379,17 +462,14 @@ mod tests {
#[test]
fn parses_an_image_media_upload_authorization() {
let response: ImageMediaUploadResponse = serde_json::from_value(json!({
"transformUrl": "https://media.wavedash.com/transform",
"token": "signed-token",
"assemblyOptions": {"params": "{}", "signature": "sha384:signed"},
"maxBytes": 25000000,
"r2Key": "org/game/achievements/first-win.webp"
}))
.expect("the media upload authorization should deserialize");

assert_eq!(
response.transform_url,
"https://media.wavedash.com/transform"
);
assert_eq!(response.token, "signed-token");
assert_eq!(response.assembly_options.signature, "sha384:signed");
assert_eq!(response.max_bytes, 25_000_000);
assert_eq!(response.r2_key, "org/game/achievements/first-win.webp");
}

Expand Down
Loading