Fix shared agent avatar import profiles (#3578)

> Carl is updating this pull request on Wes's behalf.

## Summary

- upload an embedded raster avatar through the existing authenticated
media pipeline before minting or persisting an imported shared agent
- store and publish only the resulting hosted URL so agent kind:0
profiles remain within content limits

## Root cause

Snapshot import recovered raster avatar pixels as a large inline base64
data URL. That value was persisted and placed into the agent's kind:0
profile. The relay rejected the oversized profile, so other clients
could not resolve the imported agent's avatar.

## Scope

This is intentionally the forward fix only. It changes two Desktop files
and does **not** add migration or reconciliation behavior for previously
imported agents. Existing affected imports must be re-imported or fixed
manually.

## Validation

- successful pre-push Desktop suite: 1,863 passed, 14 ignored, 0 failed
- all pre-push Rust/Desktop gates green, including all-target clippy
- valid >256 KiB PNG import → production MIME detection/sanitization →
bounded signed kind:0 containing only the hosted URL
- upload failure, malformed data, and URL-only avatar cases covered
- independent fresh review by Princess Donut: clean, no blocking
findings

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-29 11:03:20 -07:00
committed by GitHub
co-authored by Carl
parent 7e9b77f72d
commit 324bd6b464
2 changed files with 159 additions and 9 deletions
+15 -3
View File
@@ -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<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));
@@ -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
@@ -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
@@ -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.
@@ -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.");
}
}