Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
109 changes: 93 additions & 16 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ 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_calendar_filename(file_path: &str) -> String {
let basename = file_path.rsplit(['/', '\\']).next().unwrap_or_default();
let stem = basename.rsplit_once('.').map_or(basename, |(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 +525,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 @@ -1105,21 +1158,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 @@ -1156,18 +1226,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 @@ -1184,6 +1253,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
60 changes: 53 additions & 7 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,22 @@ pub struct SendMessageParams {
pub mentions: Vec<String>,
}

fn calendar_attachment_metadata(
file_path: &str,
descriptor: &crate::client::BlobDescriptor,
) -> Option<(String, String)> {
if descriptor.mime_type != "text/calendar" {
return None;
}
let filename = crate::client::sanitize_calendar_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 @@ -618,14 +634,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)) = calendar_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 @@ -993,9 +1016,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,
calendar_attachment_metadata, 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,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
Expand All @@ -1006,6 +1029,29 @@ 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) =
calendar_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)"
);
}

// 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
5 changes: 4 additions & 1 deletion crates/buzz-media/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ pub use config::{MediaConfig, S3AddressingStyle};
pub use error::MediaError;
pub use storage::{BlobHeadMeta, BlobMeta, BulkDeleteOutcome, ByteStream, MediaStorage};
pub use types::BlobDescriptor;
pub use upload::{process_file_upload, process_upload, process_video_upload};
pub use upload::{
process_file_upload, process_file_upload_with_hints, process_upload, process_video_upload,
FileUploadHints,
};
pub use upload_record::{
parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo,
UploadRecord, UPLOAD_RECORD_VERSION,
Expand Down
47 changes: 45 additions & 2 deletions crates/buzz-media/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::thumbnail::generate_image_metadata_sync;
use crate::types::BlobDescriptor;
use crate::upload_record::{record_upload_event, UploadAttribution, UploadEventFacts};
use crate::validation::{
looks_like_mp4_iso_bmff, mime_to_ext, validate_content, validate_file_content,
looks_like_mp4_iso_bmff, mime_to_ext, validate_content, validate_file_content_with_hints,
validate_video_file,
};

Expand Down Expand Up @@ -249,6 +249,42 @@ pub async fn process_file_upload(
auth_event: &nostr::Event,
body: Bytes,
attribution: Option<UploadAttribution>,
) -> Result<BlobDescriptor, MediaError> {
process_file_upload_with_hints(
storage,
config,
ctx,
auth_event,
body,
attribution,
FileUploadHints::default(),
)
.await
}

/// Untrusted client hints for an attachment format with no magic bytes.
/// Only the `text/calendar` plus `ics` pair affects validation.
#[derive(Debug, Clone, Default)]
pub struct FileUploadHints {
/// Normalized request MIME type.
pub declared_mime: Option<String>,
/// Normalized original filename extension.
pub extension: Option<String>,
}

/// Process a generic non-media file upload with optional format hints.
///
/// Storage, auth, forced-download serving, and generic deny-list behavior are
/// identical to [`process_file_upload`]. Hints only let the validator recognize
/// a structurally valid iCalendar text file.
pub async fn process_file_upload_with_hints(
storage: &MediaStorage,
config: &MediaConfig,
ctx: &TenantContext,
auth_event: &nostr::Event,
body: Bytes,
attribution: Option<UploadAttribution>,
hints: FileUploadHints,
) -> Result<BlobDescriptor, MediaError> {
process_buffered_upload(
BufferedUploadInput {
Expand All @@ -259,7 +295,14 @@ pub async fn process_file_upload(
body,
attribution,
},
|bytes, cfg| validate_file_content(bytes, cfg),
move |bytes, cfg| {
validate_file_content_with_hints(
bytes,
cfg,
hints.declared_mime.as_deref(),
hints.extension.as_deref(),
)
},
|input| async move {
// Minimal sidecar — no thumbnail/dim/blurhash/duration for generic files.
let meta = BlobMeta {
Expand Down
Loading