Skip to content
Open
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
40 changes: 37 additions & 3 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
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",
Expand All @@ -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;

Expand Down Expand Up @@ -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!(
Expand Down
69 changes: 59 additions & 10 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)");
}
}