From 2f1e924745ed4dd8047b6ae62277d9f115b5929f Mon Sep 17 00:00:00 2001 From: Abraham Prieto Date: Tue, 4 Aug 2026 13:09:40 -0400 Subject: [PATCH] fix(cli): allow generic file uploads through buzz messages send --file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buzz-cli's upload_file() rejected any MIME type outside a narrow image/video allowlist before the file ever reached the relay. The relay's /upload endpoint already routes non-image/video bytes through buzz_media::process_file_upload — a generic-file path with its own magic-byte sniffing, size cap, and deny-list for active-content/ executable types (buzz-media/src/validation.rs) — so the CLI's stricter local check was purely redundant and blocked legitimate attachments (docs, text, PDFs) that the server already supports. Replace the local allowlist with a deny-list mirroring the relay's BLOCKED_FILE_MIME_TYPES; anything else now uploads and lets the relay be the authoritative validator, as designed. Also fixes the markdown embed for non-image/video uploads: they were rendered as broken `![image](url)` embeds. Desktop's resolveFileCard renderer expects a markdown *link* (`[filename](url)`) plus the imeta MIME to show a generic-file download card, so route those through a `[filename](url)` link using the original filename (Blossom URLs are content-hash-addressed, not human-readable). Reported by Abraham in #buzz-ops 2026-08-04: Antigravity shared a `file:///home/...` artifact link that only resolved on the VPS, not from a remote Desktop client — this closes the underlying gap that made pasting file:// paths the only option. --- crates/buzz-cli/src/client.rs | 40 ++++++++++++-- crates/buzz-cli/src/commands/messages.rs | 69 ++++++++++++++++++++---- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index ee8868ad92..48238264cd 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -60,7 +60,8 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { tag } -/// MIME types accepted for upload. +/// MIME types recognized as image/video for the size-tier and imeta decision. +/// Not a security allowlist — see `BLOCKED_MIMES` below. const ALLOWED_MIMES: &[&str] = &[ "image/jpeg", "image/png", @@ -69,9 +70,40 @@ const ALLOWED_MIMES: &[&str] = &[ "video/mp4", ]; +/// MIME types rejected client-side before upload, mirroring the relay's +/// generic-file deny-list (`buzz_media::validation::BLOCKED_FILE_MIME_TYPES`). +/// Anything not in `ALLOWED_MIMES` and not here (docs, archives, text, data) +/// is sent to `/upload` and handled by the relay's generic-file path, which +/// does the authoritative magic-byte sniffing and validation server-side — +/// this list only saves a round trip for the categories we already know the +/// relay will refuse. +const BLOCKED_MIMES: &[&str] = &[ + // Active web content — stored-XSS vectors. + "text/html", + "application/xhtml+xml", + "image/svg+xml", + "application/javascript", + "text/javascript", + // Native executables / installers. + "application/x-msdownload", // .exe / .dll + "application/x-executable", // ELF + "application/vnd.microsoft.portable-executable", + "application/x-mach-binary", // Mach-O + "application/x-sharedlib", + "application/x-elf", + "application/x-msi", + "application/vnd.android.package-archive", // .apk + "application/x-apple-diskimage", // .dmg +]; + /// Maximum file size for image uploads (50 MB). const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; +/// Maximum file size for generic file uploads (100 MB) — matches the relay's +/// `default_max_file_bytes` (buzz_media::config); the relay enforces the +/// authoritative cap regardless. +const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; + /// Maximum file size for video uploads (500 MB). const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; @@ -1113,15 +1145,17 @@ impl BuzzClient { .map(|t| t.mime_type().to_string()) .unwrap_or_else(|| "application/octet-stream".to_string()); - if !ALLOWED_MIMES.contains(&mime.as_str()) { + if BLOCKED_MIMES.contains(&mime.as_str()) { return Err(CliError::Usage(format!("unsupported file type: {mime}"))); } // 3. Size check let max = if mime.starts_with("video/") { MAX_VIDEO_BYTES - } else { + } else if ALLOWED_MIMES.contains(&mime.as_str()) { MAX_IMAGE_BYTES + } else { + MAX_FILE_BYTES }; if bytes.len() as u64 > max { return Err(CliError::Usage(format!( diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..7a7d7e5143 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -561,6 +561,28 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri matches } +/// Build the markdown fragment embedded in a message for one uploaded file. +/// +/// Images and video use `![...](url)` so the desktop/mobile renderers treat +/// them as inline media. Everything else (docs, archives, text) uses a plain +/// `[filename](url)` link — the desktop `resolveFileCard` renderer keys off a +/// markdown *link* (not an image embed) plus the accompanying imeta MIME to +/// show a generic-file download card. The link text carries the original +/// filename since Blossom is content-addressed and the URL itself is a hash. +fn media_markdown_fragment(mime_type: &str, url: &str, file_path: &str) -> String { + if mime_type.starts_with("video/") { + format!("![video]({url})") + } else if mime_type.starts_with("image/") { + format!("![image]({url})") + } else { + let filename = std::path::Path::new(file_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file"); + format!("[{filename}]({url})") + } +} + pub struct SendMessageParams { pub channel_id: String, pub content: String, @@ -619,13 +641,12 @@ pub async fn cmd_send_message( .await .map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?; media_tags.push(crate::client::build_imeta_tag(&desc)); - if desc.mime_type.starts_with("video/") { - media_content.push_str("\n![video]("); - } else { - media_content.push_str("\n![image]("); - } - media_content.push_str(&desc.url); - media_content.push(')'); + media_content.push('\n'); + media_content.push_str(&media_markdown_fragment( + &desc.mime_type, + &desc.url, + file_path, + )); } let final_content = if media_content.is_empty() { p.content.clone() @@ -993,9 +1014,9 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, + event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, + media_markdown_fragment, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1372,4 +1393,32 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + #[test] + fn media_markdown_fragment_embeds_images() { + let md = media_markdown_fragment("image/png", "https://relay/x.png", "/tmp/photo.png"); + assert_eq!(md, "![image](https://relay/x.png)"); + } + + #[test] + fn media_markdown_fragment_embeds_video() { + let md = media_markdown_fragment("video/mp4", "https://relay/x.mp4", "/tmp/clip.mp4"); + assert_eq!(md, "![video](https://relay/x.mp4)"); + } + + #[test] + fn media_markdown_fragment_links_generic_files_with_original_filename() { + let md = media_markdown_fragment( + "application/pdf", + "https://relay/deadbeef.pdf", + "/home/abraham/reports/q3-plan.pdf", + ); + assert_eq!(md, "[q3-plan.pdf](https://relay/deadbeef.pdf)"); + } + + #[test] + fn media_markdown_fragment_falls_back_to_file_when_path_has_no_filename() { + let md = media_markdown_fragment("text/plain", "https://relay/x.txt", "/"); + assert_eq!(md, "[file](https://relay/x.txt)"); + } }