Skip to content
Draft
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
28 changes: 28 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ buzz-media = { path = "crates/buzz-media" }
buzz-sdk = { path = "crates/buzz-sdk" }
buzz-ws-client = { path = "crates/buzz-ws-client" }
buzz-relay-mesh = { path = "crates/buzz-relay-mesh" }
# Sonar sticker pack wire model and validators. Keep this exact revision in
# sync with the standalone desktop Tauri workspace.
sonar-stickers = { git = "https://github.com/hedwig-corp/bitchat-to-sonar", rev = "eea8ada304517d6a3ea6c81d6ffa57ce00504bcd", package = "sonar-stickers" }

# CI profile — builds the relay for desktop e2e. Dependencies keep full
# release optimization (warm from main's cache; they carry the runtime hot
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ chrono = { workspace = true }
# Typed event builders for all write operations
buzz-sdk = { workspace = true }
buzz-core = { workspace = true }
sonar-stickers = { workspace = true, features = ["signal-import"] }

# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
Expand All @@ -62,6 +63,8 @@ bytes = "1"

# MIME type detection via magic bytes — file upload validation
infer = "0.19"
imagesize = "0.14"
zeroize = "1"

# URL parsing — extract server domain for Blossom auth tag
url = { workspace = true }
Expand Down
183 changes: 178 additions & 5 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
tag
}

/// MIME types accepted for upload.
/// MIME types accepted for general-purpose uploads (`buzz upload`, attachments).
const ALLOWED_MIMES: &[&str] = &[
"image/jpeg",
"image/png",
Expand All @@ -69,6 +69,28 @@ const ALLOWED_MIMES: &[&str] = &[
"video/mp4",
];

/// MIME types accepted **only** on the sticker asset upload path.
///
/// `sniff_sticker_asset` deliberately reclassifies an animated PNG as
/// `image/apng` — the type the sonar sticker validators and the relay-side
/// sticker checks expect. It is kept out of [`ALLOWED_MIMES`] because plain
/// file uploads never produce it (`infer` reports APNG bytes as `image/png`),
/// and general uploads should not silently gain a new accepted type.
const STICKER_ONLY_MIMES: &[&str] = &["image/apng"];

/// Returns `true` when `mime` may be uploaded through the general file path.
fn is_allowed_upload_mime(mime: &str) -> bool {
ALLOWED_MIMES.contains(&mime)
}

/// Returns `true` when `mime` may be uploaded through the sticker asset path.
///
/// A superset of [`is_allowed_upload_mime`] that additionally accepts the
/// sticker-only types in [`STICKER_ONLY_MIMES`].
fn is_allowed_sticker_upload_mime(mime: &str) -> bool {
is_allowed_upload_mime(mime) || STICKER_ONLY_MIMES.contains(&mime)
}

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

Expand Down Expand Up @@ -1095,8 +1117,120 @@ impl BuzzClient {
.to_string())
}

/// Upload a file to the relay's Blossom endpoint.
/// Returns a BlobDescriptor on success.
/// Upload in-memory sticker asset bytes to the relay's Blossom endpoint.
///
/// Gated by [`is_allowed_sticker_upload_mime`] rather than the general
/// [`ALLOWED_MIMES`] list, so an animated PNG classified as `image/apng`
/// by `sniff_sticker_asset` is not rejected locally before it reaches the
/// relay. Callers sniff and validate the sticker MIME set (WebP, PNG,
/// APNG, GIF) upstream.
/// Returns a `BlobDescriptor` on success.
pub async fn upload_sticker_bytes(
&self,
bytes: Vec<u8>,
mime: &str,
) -> Result<BlobDescriptor, CliError> {
if !is_allowed_sticker_upload_mime(mime) {
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
};
if bytes.len() as u64 > max {
return Err(CliError::Usage(format!(
"file too large: {} bytes (max {})",
bytes.len(),
max
)));
}

// 4. SHA-256
let sha256 = hex::encode(Sha256::digest(&bytes));

// 5. Sign Blossom auth event (kind:24242)
use nostr::Timestamp;
let now = Timestamp::now().as_secs();
let expiry = if mime.starts_with("video/") {
3600
} else {
600
};
let exp_str = (now + expiry).to_string();

let mut blossom_tags = vec![
Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?,
];
// Extract server domain from relay URL for BUD-11 server tag
if let Some(domain) = relay_server_tag(&self.relay_url) {
blossom_tags
.push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?);
}

let auth_event = EventBuilder::new(Kind::from(24242), "Upload file")
.tags(blossom_tags)
.sign_with_keys(&self.keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;

// 6. Base64url encode the auth event for the header
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let auth_header = format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);

// 7. PUT request to the BUD-02 /upload endpoint with a generous timeout.
let upload_timeout = if mime.starts_with("video/") {
Duration::from_secs(600)
} else {
Duration::from_secs(120)
};
let url = format!("{}/upload", self.relay_url);
let upload_body = bytes::Bytes::from(bytes);
let req = self
.http
.put(&url)
.timeout(upload_timeout)
.header("Authorization", &auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", &sha256);

let mut resp = self
.with_auth_tag(req)
.body(upload_body.clone())
.send()
.await?;
if should_retry_legacy_upload(resp.status()) {
let legacy_url = format!("{}/media/upload", self.relay_url);
let legacy_req = self
.http
.put(&legacy_url)
.timeout(upload_timeout)
.header("Authorization", &auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", &sha256);
resp = self
.with_auth_tag(legacy_req)
.body(upload_body)
.send()
.await?;
}
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::Relay { status, body });
}

resp.json::<BlobDescriptor>()
.await
.map_err(|e| CliError::Other(format!("invalid upload response: {e}")))
}

pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
// 1. Read file — validate it exists and is a regular file
let metadata = std::fs::metadata(file_path)
Expand All @@ -1113,7 +1247,7 @@ 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 !is_allowed_upload_mime(&mime) {
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
}

Expand Down Expand Up @@ -2297,10 +2431,49 @@ mod retry_policy_tests {
#[cfg(test)]
mod tests {
use super::{
advance_query_cursor, create_response_with_id, extract_relay_response_field, BuzzClient,
advance_query_cursor, create_response_with_id, extract_relay_response_field,
is_allowed_sticker_upload_mime, is_allowed_upload_mime, BuzzClient,
};
use nostr::{EventBuilder, Keys, Kind, Tag};

#[test]
fn sticker_uploads_accept_apng() {
// `sniff_sticker_asset` classifies animated PNGs as `image/apng`; the
// sticker upload path must not reject them before they reach the relay.
assert!(is_allowed_sticker_upload_mime("image/apng"));
for mime in ["image/webp", "image/png", "image/gif"] {
assert!(
is_allowed_sticker_upload_mime(mime),
"sticker upload must accept {mime}"
);
}
}

#[test]
fn general_uploads_do_not_gain_apng() {
assert!(!is_allowed_upload_mime("image/apng"));
for mime in [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
] {
assert!(
is_allowed_upload_mime(mime),
"general upload must accept {mime}"
);
}
}

#[test]
fn both_upload_paths_reject_unknown_types() {
for mime in ["application/pdf", "text/html", "application/octet-stream"] {
assert!(!is_allowed_upload_mime(mime));
assert!(!is_allowed_sticker_upload_mime(mime));
}
}

#[test]
fn query_cursor_uses_last_events_composite_sort_key() {
let mut filter = serde_json::json!({"kinds": [39000], "limit": 500});
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod pr;
pub mod reactions;
pub mod repos;
pub mod social;
pub mod stickers;
pub mod upload;
pub mod users;
pub mod workflows;
Loading