From 013e05b2636a1356df61a9746cfe9e6c31d047f8 Mon Sep 17 00:00:00 2001 From: Raymond Kennedy Date: Mon, 12 Oct 2026 22:08:52 -0700 Subject: [PATCH 1/5] Upload achievement images with signed Transloadit grants --- src/achievements.rs | 118 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/src/achievements.rs b/src/achievements.rs index 83a5fcf..b6b7ec5 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -40,13 +40,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, + error: Option, + message: Option, + assembly_ssl_url: Option, +} + async fn upload_achievement_image( api_key: &str, game_id: &str, @@ -57,12 +72,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() - ) - })?; + .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()))?; @@ -80,22 +90,85 @@ async fn upload_achievement_image( .json(&json!({ "identifier": identifier, "fileExtension": extension, + "provider": "transloadit", })) .send() .await?; 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) + if bytes.is_empty() || bytes.len() as u64 > authorization.max_bytes { + anyhow::bail!( + "Image must be between 1 and {} bytes", + authorization.max_bytes + ); + } + 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 = 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) @@ -379,17 +452,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"); } From 1f28a97e4e648706c841a91e3c0fe82d4dc15131 Mon Sep 17 00:00:00 2001 From: Raymond Kennedy Date: Mon, 12 Oct 2026 22:09:28 -0700 Subject: [PATCH 2/5] Use the direct Transloadit upload contract --- src/achievements.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/achievements.rs b/src/achievements.rs index b6b7ec5..9482c42 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -90,7 +90,6 @@ async fn upload_achievement_image( .json(&json!({ "identifier": identifier, "fileExtension": extension, - "provider": "transloadit", })) .send() .await?; From 761f0967639540c5e9367eb3ada5e7f604297626 Mon Sep 17 00:00:00 2001 From: Raymond Kennedy Date: Mon, 12 Oct 2026 22:11:22 -0700 Subject: [PATCH 3/5] Prepare CLI 0.1.100 release --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 884d8ab..b4791b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "wavedash" -version = "0.1.99" +version = "0.1.100" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5e895a0..ed76fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" From 0b066f102a407ab13a9e81a105e7d447660dafc7 Mon Sep 17 00:00:00 2001 From: Raymond Kennedy Date: Mon, 12 Oct 2026 22:25:51 -0700 Subject: [PATCH 4/5] Bound achievement image reads by the upload size limit --- src/achievements.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/achievements.rs b/src/achievements.rs index 9482c42..0ae3303 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -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 @@ -74,9 +75,6 @@ async fn upload_achievement_image( .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()))?; - let client = config::create_http_client()?; let api_host = config::get("api_host")?; @@ -96,6 +94,19 @@ async fn upload_achievement_image( let resp = config::check_api_response(resp).await?; let authorization: ImageMediaUploadResponse = resp.json().await?; + 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 == 0 || file_size > authorization.max_bytes { + anyhow::bail!( + "Image must be between 1 and {} bytes", + authorization.max_bytes + ); + } + 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", From d952e0f87a2eae02987a05e1da25131313f2d6ed Mon Sep 17 00:00:00 2001 From: Raymond Kennedy Date: Mon, 12 Oct 2026 22:29:33 -0700 Subject: [PATCH 5/5] Validate empty images after the bounded read --- src/achievements.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/achievements.rs b/src/achievements.rs index 0ae3303..ad384d3 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -97,7 +97,7 @@ async fn upload_achievement_image( 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 == 0 || file_size > authorization.max_bytes { + if file_size > authorization.max_bytes { anyhow::bail!( "Image must be between 1 and {} bytes", authorization.max_bytes