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
128 changes: 112 additions & 16 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,57 @@ 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 iCalendar uploads (10 MiB).
const MAX_CALENDAR_BYTES: u64 = 10 * 1024 * 1024;

fn calendar_upload_metadata(file_path: &str) -> Option<(&'static str, &'static str)> {
std::path::Path::new(file_path)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("ics"))
.then_some(("text/calendar", "ics"))
}

pub(crate) fn sanitize_attachment_filename(file_path: &str) -> String {
let basename = file_path.rsplit(['/', '\\']).next().unwrap_or_default();
let mut sanitized = String::new();
for character in basename.chars().filter(|character| !character.is_control()) {
if sanitized.len() + character.len_utf8() > 255 {
break;
}
sanitized.push(character);
}
let sanitized = sanitized.trim();
if sanitized.is_empty() {
"file".to_string()
} else {
sanitized.to_string()
}
}

pub(crate) fn sanitize_calendar_filename(file_path: &str) -> String {
let basename = sanitize_attachment_filename(file_path);
let stem = basename
.rsplit_once('.')
.map_or(basename.as_str(), |(stem, _)| stem);
let mut sanitized = String::new();
for character in stem.chars().filter(|character| !character.is_control()) {
if sanitized.len() + character.len_utf8() > 255 - ".ics".len() {
break;
}
sanitized.push(character);
}
let sanitized = sanitized.trim();
format!(
"{}.ics",
if sanitized.is_empty() {
"calendar"
} else {
sanitized
}
)
}

/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value.
///
/// The event includes:
Expand Down Expand Up @@ -493,6 +544,27 @@ mod media_download_tests {
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE
));
}

#[test]
fn calendar_upload_metadata_is_extension_specific() {
assert_eq!(
calendar_upload_metadata("Planning.ICS"),
Some(("text/calendar", "ics"))
);
assert_eq!(calendar_upload_metadata("Planning.txt"), None);
}

#[test]
fn calendar_filename_is_sanitized_without_losing_ics_extension() {
let name = format!("folder\\bad\0{}.ics", "é".repeat(200));
let sanitized = sanitize_calendar_filename(&name);

assert!(sanitized.ends_with(".ics"));
assert!(!sanitized.contains(['/', '\\', '\0']));
assert!(sanitized.len() <= 255);
assert_eq!(sanitize_calendar_filename("Agenda.markdown"), "Agenda.ics");
assert_eq!(sanitize_calendar_filename("Agenda"), "Agenda.ics");
}
}

const QUERY_PAGE_SIZE: u32 = 500;
Expand Down Expand Up @@ -1126,21 +1198,38 @@ impl BuzzClient {
return Err(CliError::Usage(format!("{file_path} is not a file")));
}

let calendar_metadata = calendar_upload_metadata(file_path);
if calendar_metadata.is_some() && metadata.len() > MAX_CALENDAR_BYTES {
return Err(CliError::Usage(format!(
"file too large: {} bytes (max {MAX_CALENDAR_BYTES})",
metadata.len()
)));
}

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());
let (mime, extension_hint) = if let Some((mime, extension)) = calendar_metadata {
(mime.to_string(), Some(extension))
} else {
(
infer::get(&bytes)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string()),
None,
)
};

if !ALLOWED_MIMES.contains(&mime.as_str()) {
if extension_hint.is_none() && !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 if extension_hint.is_some() {
MAX_CALENDAR_BYTES
} else {
MAX_IMAGE_BYTES
};
Expand Down Expand Up @@ -1177,18 +1266,17 @@ impl BuzzClient {
async move {
let auth_header =
sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?;
let resp = self
.with_auth_tag(
self.http
.put(&url)
.timeout(upload_timeout)
.header("Authorization", auth_header)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256)
.body(upload_body),
)
.send()
.await?;
let mut request = self
.http
.put(&url)
.timeout(upload_timeout)
.header("Authorization", auth_header)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256);
if let Some(extension) = extension_hint {
request = request.header("X-Buzz-File-Extension", extension);
}
let resp = self.with_auth_tag(request.body(upload_body)).send().await?;
let status = resp.status();
if !status.is_success() {
let s = status.as_u16();
Expand All @@ -1205,6 +1293,14 @@ impl BuzzClient {
// itself is not retried; only transient failures on the selected legacy endpoint are.
match result {
Ok(desc) => return Ok(desc),
Err(CliError::Relay { status: s, body })
if extension_hint.is_some()
&& should_retry_legacy_upload(
reqwest::StatusCode::from_u16(s).unwrap_or(reqwest::StatusCode::NOT_FOUND),
) =>
{
return Err(CliError::Relay { status: s, body });
}
Err(CliError::Relay { status: s, body: _ })
if should_retry_legacy_upload(
reqwest::StatusCode::from_u16(s).unwrap_or(reqwest::StatusCode::NOT_FOUND),
Expand Down
91 changes: 82 additions & 9 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,26 @@ pub struct SendMessageParams {
pub mentions: Vec<String>,
}

fn attachment_metadata(
file_path: &str,
descriptor: &crate::client::BlobDescriptor,
) -> Option<(String, String)> {
if descriptor.mime_type.starts_with("image/") || descriptor.mime_type.starts_with("video/") {
return None;
}
let filename = if descriptor.mime_type == "text/calendar" {
crate::client::sanitize_calendar_filename(file_path)
} else {
crate::client::sanitize_attachment_filename(file_path)
};
let label = filename
.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]");
let markdown = format!("[{label}]({})", descriptor.url);
Some((filename, markdown))
}

pub async fn cmd_send_message(
client: &BuzzClient,
mut p: SendMessageParams,
Expand Down Expand Up @@ -655,14 +675,21 @@ pub async fn cmd_send_message(
.upload_file(file_path)
.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/") {
let mut imeta = crate::client::build_imeta_tag(&desc);
if let Some((filename, markdown)) = attachment_metadata(file_path, &desc) {
imeta.push(format!("filename {filename}"));
media_content.push('\n');
media_content.push_str(&markdown);
} else if desc.mime_type.starts_with("video/") {
media_content.push_str("\n![video](");
media_content.push_str(&desc.url);
media_content.push(')');
} else {
media_content.push_str("\n![image](");
media_content.push_str(&desc.url);
media_content.push(')');
Comment thread
liowald marked this conversation as resolved.
}
media_content.push_str(&desc.url);
media_content.push(')');
media_tags.push(imeta);
}
let final_content = if media_content.is_empty() {
p.content.clone()
Expand Down Expand Up @@ -1056,11 +1083,11 @@ pub async fn dispatch(
#[cfg(test)]
mod tests {
use super::{
channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags,
format_events, match_profiles_by_name, merge_message_mentions, missing_members,
normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys,
resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient,
CliError, Uuid,
attachment_metadata, channel_id_from_event, cmd_get_thread, event_mention_pubkeys,
find_root_from_tags, format_events, match_profiles_by_name, merge_message_mentions,
missing_members, normalize_explicit_mentions, parse_member_pubkeys,
resolve_names_to_pubkeys, resolve_thread_target, thread_ref_from_event,
thread_ref_from_parent_tags, BuzzClient, CliError, Uuid,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
Expand All @@ -1072,6 +1099,52 @@ mod tests {
const ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const PUBKEY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";

#[test]
fn calendar_attachment_uses_named_download_markdown_and_imeta() {
let descriptor = crate::client::BlobDescriptor {
url: "https://relay.example/media/abc.ics".to_string(),
sha256: "a".repeat(64),
size: 42,
mime_type: "text/calendar".to_string(),
uploaded: 1,
dim: None,
blurhash: None,
thumb: None,
duration: None,
};
let (filename, markdown) =
attachment_metadata(r"folder\Planning[1].ics", &descriptor).unwrap();

assert_eq!(filename, "Planning[1].ics");
assert_eq!(
markdown,
r"[Planning\[1\].ics](https://relay.example/media/abc.ics)"
);
}

#[test]
fn generic_descriptor_uses_named_download_markdown_and_imeta() {
let descriptor = crate::client::BlobDescriptor {
url: "https://relay.example/media/abc.bin".to_string(),
sha256: "a".repeat(64),
size: 42,
mime_type: "application/octet-stream".to_string(),
uploaded: 1,
dim: None,
blurhash: None,
thumb: None,
duration: None,
};
let (filename, markdown) =
attachment_metadata(r"folder\Planning[1].ics", &descriptor).unwrap();

assert_eq!(filename, "Planning[1].ics");
assert_eq!(
markdown,
r"[Planning\[1\].ics](https://relay.example/media/abc.bin)"
);
}

// Three real pubkeys (lowercase 64-char hex) used by parse_member_pubkeys tests.
// See the test's own comment on what `PublicKey::from_hex` actually validates.
const PK_VALID_A: &str = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4";
Expand Down
Loading