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
18 changes: 15 additions & 3 deletions desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<u8>,
state: &AppState,
) -> Result<BlobDescriptor, String> {
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<u8>,
mime: &str,
state: &State<'_, AppState>,
state: &AppState,
progress: Option<(tauri::AppHandle, String)>,
) -> Result<BlobDescriptor, String> {
let sha256 = hex::encode(Sha256::digest(&body));
Expand Down Expand Up @@ -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<BlobDescriptor, String> {
// Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a
Expand Down
150 changes: 144 additions & 6 deletions desktop/src-tauri/src/commands/personas/snapshot/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,24 @@ pub(crate) fn decode_snapshot_from_bytes(
Ok(snapshot)
}

async fn materialize_import_avatar<F, Fut>(
avatar_data_url: Option<&str>,
avatar_url: Option<&str>,
upload: F,
) -> Result<Option<String>, String>
where
F: FnOnce(Vec<u8>) -> Fut,
Fut: std::future::Future<Output = Result<String, String>>,
{
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
Expand Down Expand Up @@ -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<String> = 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.
Expand Down Expand Up @@ -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.");
}
}
Loading