mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user