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
93 changes: 73 additions & 20 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,26 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
tag
}

/// MIME types accepted for upload.
const ALLOWED_MIMES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
/// MIME types blocked from upload — mirrors the desktop/relay generic-file deny-list.
///
/// Active-content XSS carriers and native executables. Everything else (images,
/// video, documents, archives, text, data) is accepted; un-sniffable files fall
/// back to `application/octet-stream` and are served as downloads by the relay.
const BLOCKED_MIMES: &[&str] = &[
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"application/javascript",
"text/javascript",
"application/x-msdownload",
"application/x-executable",
"application/vnd.microsoft.portable-executable",
"application/x-mach-binary",
"application/x-sharedlib",
"application/x-elf",
"application/x-msi",
"application/vnd.android.package-archive",
"application/x-apple-diskimage",
];

/// Maximum file size for image uploads (50 MB).
Expand All @@ -75,6 +88,31 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
/// Maximum file size for video uploads (500 MB).
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;

/// Maximum file size for generic (non-image/video) uploads (100 MB).
/// Matches `buzz-media` default `max_file_bytes`.
const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;

/// Detect MIME and reject blocked active-content / executable types.
fn detect_and_validate_upload_mime(bytes: &[u8]) -> Result<String, CliError> {
let mime = infer::get(bytes)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if BLOCKED_MIMES.contains(&mime.as_str()) {
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
}
Ok(mime)
}

fn max_upload_bytes(mime: &str) -> u64 {
if mime.starts_with("video/") {
MAX_VIDEO_BYTES
} else if mime.starts_with("image/") {
MAX_IMAGE_BYTES
} else {
MAX_FILE_BYTES
}
}

/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value.
///
/// The event includes:
Expand Down Expand Up @@ -449,6 +487,31 @@ mod media_download_tests {
}
}

#[test]
fn upload_mime_accepts_plain_text_as_octet_stream() {
let mime = detect_and_validate_upload_mime(b"hello world").unwrap();
assert_eq!(mime, "application/octet-stream");
assert_eq!(max_upload_bytes(&mime), MAX_FILE_BYTES);
}

#[test]
fn upload_mime_accepts_jpeg() {
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
let mime = detect_and_validate_upload_mime(&jpeg).unwrap();
assert_eq!(mime, "image/jpeg");
assert_eq!(max_upload_bytes(&mime), MAX_IMAGE_BYTES);
}

#[test]
fn upload_mime_rejects_html() {
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
let err = detect_and_validate_upload_mime(html).unwrap_err();
match err {
CliError::Usage(msg) => assert!(msg.contains("text/html"), "got: {msg}"),
other => panic!("expected Usage error, got {other:?}"),
}
}

#[test]
fn media_get_auth_header_is_server_scoped() {
let keys = Keys::generate();
Expand Down Expand Up @@ -1108,21 +1171,11 @@ impl BuzzClient {
let bytes = std::fs::read(file_path)
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;

// 2. Detect MIME from magic bytes
let mime = infer::get(&bytes)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());

if !ALLOWED_MIMES.contains(&mime.as_str()) {
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
}
// 2. Detect MIME from magic bytes (deny-list, matching desktop/relay)
let mime = detect_and_validate_upload_mime(&bytes)?;

// 3. Size check
let max = if mime.starts_with("video/") {
MAX_VIDEO_BYTES
} else {
MAX_IMAGE_BYTES
};
let max = max_upload_bytes(&mime);
if bytes.len() as u64 > max {
return Err(CliError::Usage(format!(
"file too large: {} bytes (max {})",
Expand Down
70 changes: 49 additions & 21 deletions crates/buzz-cli/src/commands/dms.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,75 @@
use uuid::Uuid;

use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
use crate::client::{extract_d_tag, extract_tag_value, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{parse_uuid, sdk_err, validate_hex64};

/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey.
/// List DM conversations the current identity belongs to.
///
/// Queries membership (kind:39002) then channel metadata (kind:39000) and keeps
/// channels tagged `t=dm`. Kind:41001 "DM created" confirmations are no longer
/// emitted by the relay — Desktop lists DMs the same membership+metadata way.
pub async fn cmd_list_dms(client: &BuzzClient, limit: Option<u32>) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(50).min(200);
let filter = serde_json::json!({
"kinds": [41001],

let member_filter = serde_json::json!({
"kinds": [39002],
"#p": [my_pk],
"limit": limit
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let dms: Vec<serde_json::Value> = events
let member_events = client.query_paginated(member_filter, limit).await?;
let channel_ids: Vec<String> = member_events
.iter()
.map(extract_d_tag)
.filter(|id| !id.is_empty())
.collect();
if channel_ids.is_empty() {
println!("[]");
return Ok(());
}

let metadata_filter = serde_json::json!({
"kinds": [39000],
"#d": channel_ids,
});
let metadata_events = client.query_paginated(metadata_filter, limit).await?;

let mut dms: Vec<serde_json::Value> = metadata_events
.iter()
.filter(|e| extract_tag_value(e, "t") == "dm")
.map(|e| {
let dm_id = extract_d_tag(e);
let participants: Vec<String> = e
.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| {
let arr = tag.as_array()?;
if arr.first()?.as_str()? == "p" {
arr.get(1)?.as_str().map(|s| s.to_string())
} else {
None
}
// Prefer live membership p-tags when available; fall back to metadata.
let participants: Vec<String> = member_events
.iter()
.find(|m| extract_d_tag(m) == dm_id)
.map(|m| {
m.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| {
let arr = tag.as_array()?;
if arr.first()?.as_str()? == "p" {
arr.get(1)?.as_str().map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.collect()
.unwrap_or_default()
})
.unwrap_or_default();
serde_json::json!({
"dm_id": dm_id,
"name": extract_tag_value(e, "name"),
"participants": participants,
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
dms.sort_by_key(|d| d.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0));
let output = serde_json::to_string(&dms).unwrap_or_default();
println!("{output}");
Ok(())
Expand Down
Loading