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
183 changes: 159 additions & 24 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +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",
];

/// Maximum file size for image uploads (50 MB).
const MAX_GIF_BYTES: u64 = 10 * 1024 * 1024;
const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;

/// Maximum file size for video uploads (500 MB).
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;

/// Client-side size cap for `mime`. Mirrors the relay per-category caps, which
/// are resolved from `BUZZ_MAX_IMAGE_BYTES`, `BUZZ_MAX_GIF_BYTES`,
/// `BUZZ_MAX_VIDEO_BYTES`, `BUZZ_MAX_FILE_BYTES` in `buzz_relay::config`
/// (these constants are the env fallbacks, not `MediaConfig` serde defaults).
fn max_bytes_for_mime(mime: &str) -> u64 {
if mime.starts_with("video/") {
MAX_VIDEO_BYTES
} else if mime == "image/gif" {
MAX_GIF_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.
///
Expand Down Expand Up @@ -1108,21 +1114,17 @@ 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
// 2. Detect MIME from magic bytes. No client-side allow/deny gate here —
// the relay's generic-file deny-list (buzz_media::validation) is the sole
// enforcement point; a client-side copy of that policy would be a fourth
// copy to keep in sync with the relay, Desktop, and mobile.
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}")));
}

// 3. Size check
let max = if mime.starts_with("video/") {
MAX_VIDEO_BYTES
} else {
MAX_IMAGE_BYTES
};
// 3. Size check — must match the relay's per-category caps (see
// `max_bytes_for_mime` docs above).
let max = max_bytes_for_mime(&mime);
if bytes.len() as u64 > max {
return Err(CliError::Usage(format!(
"file too large: {} bytes (max {})",
Expand Down Expand Up @@ -1182,6 +1184,14 @@ impl BuzzClient {
// If the primary /upload endpoint definitively doesn't exist on this relay version
// (404 or 405), fall back to the legacy /media/upload endpoint. The 404/405 switch
// itself is not retried; only transient failures on the selected legacy endpoint are.
//
// Known gap: /media/upload is the older media-only alias and hard-rejects non-image/
// video MIME types server-side. Now that this client no longer pre-filters MIME
// client-side, a generic file (JSON/text/PDF/octet-stream) against an old relay that
// lacks /upload will 404 there, fall back here, and then fail with a confusing
// `DisallowedContentType` from /media/upload instead of a clear "relay too old"
// message. Not fixed in this change — flagged for a follow-up on relay-version
// detection or a clearer error translation.
match result {
Ok(desc) => return Ok(desc),
Err(CliError::Relay { status: s, body: _ })
Expand Down Expand Up @@ -1594,7 +1604,7 @@ mod retry_policy_tests {
use tokio::net::TcpListener;

use super::super::error::CliError;
use super::BuzzClient;
use super::{max_bytes_for_mime, BuzzClient};

/// Spawn a one-shot axum server on a random port. The handler `f` receives the
/// attempt counter (incremented before every call) and returns a `(StatusCode,
Expand Down Expand Up @@ -2199,6 +2209,131 @@ mod retry_policy_tests {
);
}

/// Spawn a one-shot axum server that accepts any `PUT /upload` and echoes back
/// a minimal valid `BlobDescriptor` carrying the request's `Content-Type`.
async fn test_upload_server() -> String {
use axum::body::Bytes;
use axum::routing::put;

async fn handler(headers: HeaderMap, body: Bytes) -> Response<Body> {
let mime = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let ok_body = serde_json::json!({
"url": "https://relay.test/media/aabbcc",
"sha256": "a".repeat(64),
"size": body.len(),
"type": mime,
"uploaded": 0,
})
.to_string();
Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(Body::from(ok_body))
.unwrap()
}

let app = Router::new().route("/upload", put(handler));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}

/// Write `contents` to a fresh temp file and return its path.
fn write_temp_file(contents: &[u8]) -> tempfile::NamedTempFile {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(contents).unwrap();
tmp
}

/// Opaque/generic content — JSON, plain text, PDF, and un-sniffable bytes —
/// must upload successfully with no client-side MIME gate at all. These
/// previously hard-failed with "unsupported file type" because of a
/// client-side `ALLOWED_MIMES` allowlist covering only image/video MIME
/// types; the relay's own generic-file deny-list (buzz_media::validation)
/// is now the sole enforcement point, matching Desktop's uploader.
#[tokio::test]
async fn upload_file_accepts_json_text_and_pdf() {
let base = test_upload_server().await;
let client = test_client(&base);

let json_file = write_temp_file(br#"{"a":1}"#);
let result = client
.upload_file(json_file.path().to_str().unwrap())
.await;
assert!(result.is_ok(), "JSON upload should succeed, got {result:?}");

let text_file = write_temp_file(b"hello world, this is plain text");
let result = client
.upload_file(text_file.path().to_str().unwrap())
.await;
assert!(result.is_ok(), "plain text upload should succeed, got {result:?}");

// Minimal PDF header — enough for `infer` to detect application/pdf.
let pdf_file = write_temp_file(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
let result = client.upload_file(pdf_file.path().to_str().unwrap()).await;
assert!(result.is_ok(), "PDF upload should succeed, got {result:?}");

// Content `infer` cannot sniff at all falls back to octet-stream and
// must still be accepted, not rejected client-side.
let opaque_file = write_temp_file(&[0x01, 0x02, 0x03, 0x04, 0x05]);
let result = client
.upload_file(opaque_file.path().to_str().unwrap())
.await;
assert!(
result.is_ok(),
"un-sniffable/octet-stream upload should succeed, got {result:?}"
);
}

/// `upload_file` must consult `max_bytes_for_mime` rather than a hardcoded
/// cap. GIF is deliberate: at 10 MB it is the smallest of the four caps,
/// so this asserts the wiring for the least buffer. Pins the cap value,
/// not just the rejection — a wrong-but-nonzero cap must fail here too.
#[tokio::test]
async fn upload_file_rejects_gif_over_gif_cap() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let client = test_client(&base);

let mut gif = b"GIF89a".to_vec();
gif.resize(10 * 1024 * 1024 + 1, 0);
let f = write_temp_file(&gif);

let err = client
.upload_file(f.path().to_str().unwrap())
.await
.unwrap_err();
assert!(
matches!(err, CliError::Usage(ref m) if m.contains("max 10485760")),
"expected rejection against the 10 MB gif cap, got {err:?}"
);
}

/// Each MIME category must resolve to its own cap, not fall through to a
/// shared 100 MB generic-file cap. `/upload` sniffs jpeg/png/gif/webp to
/// the relay's image pipeline (50 MB, 10 MB for GIF), not the
/// generic-file pipeline (100 MB) — a single cap for every non-video MIME
/// would let an oversized image pass client-side only to be rejected by
/// the relay after a full upload. Zero allocation, zero network: this is
/// a pure function over the branch, not an end-to-end upload.
#[test]
fn max_bytes_for_mime_matches_relay_per_category_caps() {
assert_eq!(max_bytes_for_mime("video/mp4"), 500 * 1024 * 1024);
assert_eq!(max_bytes_for_mime("image/gif"), 10 * 1024 * 1024);
assert_eq!(max_bytes_for_mime("image/png"), 50 * 1024 * 1024);
assert_eq!(
max_bytes_for_mime("application/octet-stream"),
100 * 1024 * 1024
);
assert_eq!(max_bytes_for_mime("application/pdf"), 100 * 1024 * 1024);
}

/// When all retry attempts for a stored event end with a partial body (200
/// headers, dropped connection), the final error must be `DeliveryUnknown`
/// (retryable:false) — the relay may have stored the event on any attempt, so
Expand Down
27 changes: 24 additions & 3 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -621,11 +621,32 @@ pub async fn cmd_send_message(
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(&desc.url);
media_content.push(')');
} else if desc.mime_type.starts_with("image/") {
media_content.push_str("\n![image](");
media_content.push_str(&desc.url);
media_content.push(')');
} else {
// Generic files (JSON, text, PDF, octet-stream, ...) render as a
// plain link, not an inline image — an ![image](...) markdown tag
// for a non-image blob renders as a broken image in every client.
//
// Link text: escape `[`/`]` so an untrusted filename can't break
// out of the markdown link syntax, and never fall back to the
// full local path — that would leak the caller's filesystem
// layout into a channel-visible message.
let name = std::path::Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.replace('[', "\\[").replace(']', "\\]"))
.unwrap_or_else(|| "attachment".to_string());
media_content.push_str("\n[");
media_content.push_str(&name);
media_content.push_str("](");
media_content.push_str(&desc.url);
media_content.push(')');
}
media_content.push_str(&desc.url);
media_content.push(')');
}
let final_content = if media_content.is_empty() {
p.content.clone()
Expand Down