From d69529fb1f4ad83e227fd5d6c2ba53d32e1229eb Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 11:05:35 -0600 Subject: [PATCH] Fix shared agent avatar imports Upload embedded raster avatars through the existing media path before minting or persisting an imported agent, and store only the hosted URL. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/commands/media.rs | 18 ++- .../src/commands/personas/snapshot/import.rs | 150 +++++++++++++++++- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index bf8692ff70..ed3b340238 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -411,7 +411,7 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { } async fn send_upload_attempt( - state: &State<'_, AppState>, + state: &AppState, url: String, auth_header: &str, mime: &str, @@ -455,10 +455,22 @@ async fn send_upload_attempt( response.map_err(|error| classify_request_error(&error)) } +pub(crate) async fn upload_image_bytes( + body: Vec, + state: &AppState, +) -> Result { + let mime = detect_and_validate_mime(&body)?; + if !mime.starts_with("image/") { + return Err("profile avatar must be an image".to_string()); + } + let body = sanitize_image_for_upload(body, &mime)?; + do_upload(body, &mime, state, None).await +} + async fn do_upload( body: Vec, mime: &str, - state: &State<'_, AppState>, + state: &AppState, progress: Option<(tauri::AppHandle, String)>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); @@ -559,7 +571,7 @@ pub async fn upload_media( /// files from ever leaving the client on image-only surfaces. async fn process_picked_path( path: std::path::PathBuf, - state: &State<'_, AppState>, + state: &AppState, images_only: bool, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 7648941d23..d23efe7730 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -256,6 +256,24 @@ pub(crate) fn decode_snapshot_from_bytes( Ok(snapshot) } +async fn materialize_import_avatar( + avatar_data_url: Option<&str>, + avatar_url: Option<&str>, + upload: F, +) -> Result, String> +where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future>, +{ + let Some(avatar_data_url) = avatar_data_url else { + return Ok(avatar_url.map(str::to_string)); + }; + let avatar_bytes = + crate::managed_agents::agent_snapshot::decode_avatar_data_url(avatar_data_url) + .ok_or_else(|| "Snapshot avatar data is malformed.".to_string())?; + upload(avatar_bytes).await.map(Some) +} + // ── `preview_agent_snapshot_import` ────────────────────────────────────────── /// Decode and validate a snapshot file, returning a preview for the @@ -354,12 +372,21 @@ pub async fn confirm_agent_snapshot_import( )?; let minted_parallelism = minted.parallelism; - // Effective avatar: data URL wins; URL fallback when data URL is absent. - let effective_avatar: Option = snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()); + // Profile metadata must contain a hosted URL. Inline avatar data can be far + // larger than the relay's kind:0 content limit, so upload imported pixels + // before minting or persisting the new agent. Failing here keeps import + // atomic instead of creating an agent whose profile can never publish. + let effective_avatar = materialize_import_avatar( + snapshot.profile.avatar_data_url.as_deref(), + snapshot.profile.avatar_url.as_deref(), + |avatar_bytes| async { + crate::commands::media::upload_image_bytes(avatar_bytes, &state) + .await + .map(|descriptor| descriptor.url) + .map_err(|error| format!("Could not upload the imported avatar: {error}")) + }, + ) + .await?; // Wire-format string for the persona definition's respond_to field. // Omit when it is the default (owner-only) to keep definitions clean. @@ -711,3 +738,114 @@ async fn submit_engram_event( } Ok(()) } + +#[cfg(test)] +mod import_avatar_tests { + use super::materialize_import_avatar; + use std::cell::Cell; + + #[tokio::test] + async fn inline_avatar_is_uploaded_and_replaced_with_hosted_url() { + let uploaded = Cell::new(false); + let result = materialize_import_avatar( + Some("data:image/png;base64,iVBORw0KGgo="), + Some("https://sender.invalid/avatar.png"), + |bytes| { + uploaded.set(true); + async move { + assert_eq!(bytes, b"\x89PNG\r\n\x1a\n"); + Ok("https://relay.example/media/avatar.png".to_string()) + } + }, + ) + .await + .unwrap(); + + assert!(uploaded.get()); + assert_eq!( + result.as_deref(), + Some("https://relay.example/media/avatar.png") + ); + } + + #[tokio::test] + async fn hosted_avatar_skips_upload() { + let result = + materialize_import_avatar(None, Some("https://sender.example/avatar.png"), |_| async { + panic!("hosted avatars must not be uploaded") + }) + .await + .unwrap(); + + assert_eq!(result.as_deref(), Some("https://sender.example/avatar.png")); + } + + #[tokio::test] + async fn relay_sized_inline_avatar_becomes_bounded_signed_profile() { + use base64::{engine::general_purpose::STANDARD, Engine}; + use image::ImageEncoder; + use nostr::JsonUtil; + + let mut pixels = vec![0_u8; 512 * 512 * 4]; + let mut seed = 0x1234_5678_u32; + for byte in &mut pixels { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + *byte = seed as u8; + } + let mut source = Vec::new(); + image::codecs::png::PngEncoder::new(&mut source) + .write_image(&pixels, 512, 512, image::ExtendedColorType::Rgba8) + .unwrap(); + assert!(source.len() > 256 * 1024); + let data_url = format!("data:image/png;base64,{}", STANDARD.encode(&source)); + assert!(data_url.len() > 256 * 1024); + + let avatar = materialize_import_avatar(Some(&data_url), None, |bytes| async move { + let mime = crate::commands::media::detect_and_validate_mime(&bytes)?; + assert_eq!(mime, "image/png"); + let sanitized = crate::commands::media::sanitize_image_for_upload(bytes, &mime)?; + image::load_from_memory(&sanitized).map_err(|error| error.to_string())?; + Ok("https://relay.example/media/avatar.png".to_string()) + }) + .await + .unwrap() + .unwrap(); + + let event = + crate::events::build_profile(Some("Imported agent"), None, Some(&avatar), None, None) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + assert!(event.content.len() < 64 * 1024); + assert!(!event.content.contains("data:image/")); + assert!(event + .content + .contains("https://relay.example/media/avatar.png")); + assert!(event.as_json().len() < 256 * 1024); + } + + #[tokio::test] + async fn upload_failure_aborts_avatar_materialization() { + let result = materialize_import_avatar( + Some("data:image/png;base64,iVBORw0KGgo="), + None, + |_| async { Err("relay upload failed".to_string()) }, + ) + .await; + + assert_eq!(result.unwrap_err(), "relay upload failed"); + } + + #[tokio::test] + async fn malformed_inline_avatar_fails_before_upload() { + let result = + materialize_import_avatar(Some("data:image/png;base64,not-base64!"), None, |_| async { + panic!("malformed avatars must not be uploaded") + }) + .await; + + assert_eq!(result.unwrap_err(), "Snapshot avatar data is malformed."); + } +}