Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ BUZZ_S3_ADDRESSING_STYLE=path
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2
# BUZZ_MEDIA_UPLOADS_PER_MINUTE=30
# Keep false through the first rolling deployment of calendar classification;
# set true only after all relay writers run the new conditional sidecar path.
# Valid calendars retain legacy `.bin` metadata while this fence is false.
# BUZZ_MEDIA_CALENDAR_CLASSIFICATION_READY=false
# GET/HEAD /media/* always require Blossom t=get auth and relay membership.
# BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer
# read; setting either (including to false) changes nothing and the relay warns
Expand Down
206 changes: 184 additions & 22 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 @@ -1167,21 +1239,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 All @@ -1197,39 +1286,40 @@ impl BuzzClient {
let sha256 = hex::encode(Sha256::digest(&bytes));

// 5. PUT request to the BUD-02 /upload endpoint with a generous timeout.
// Auth is signed per attempt — matches the per-attempt signing pattern in download_media.
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);
// One signed authorization is one logical upload event. Reuse it across
// transport retries so the relay's durable moderation record keeps the
// same idempotency identity even when a retry crosses a clock second.
let auth_header = sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?;

// The full upload operation — network send AND response body read — lives inside
// with_retry_body so that a dropped body after 200 headers is retried with the
// same file bytes and a fresh Blossom auth per attempt.
// same file bytes and signed upload authorization.
let result: Result<BlobDescriptor, CliError> = self
.with_retry_body(|| {
let upload_body = upload_body.clone();
let url = url.clone();
let mime = mime.clone();
let sha256 = sha256.clone();
let auth_header = auth_header.clone();
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 @@ -1246,6 +1336,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 All @@ -1262,8 +1360,8 @@ impl BuzzClient {
let legacy_url = legacy_url.clone();
let mime = mime.clone();
let sha256 = sha256.clone();
let auth_header = auth_header.clone();
async move {
let auth_header = sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?;
let resp = self
.with_auth_tag(
self.http
Expand Down Expand Up @@ -1655,7 +1753,8 @@ mod retry_tests {
mod retry_policy_tests {
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use axum::body::Body;
use axum::extract::State;
Expand Down Expand Up @@ -1724,6 +1823,69 @@ mod retry_policy_tests {
.unwrap()
}

#[tokio::test]
async fn upload_retry_reuses_the_same_signed_authorization() {
type StateData = (Arc<AtomicU32>, Arc<Mutex<Vec<String>>>);

let attempts = Arc::new(AtomicU32::new(0));
let authorizations = Arc::new(Mutex::new(Vec::new()));
let state = (attempts.clone(), authorizations.clone());
let app = Router::new()
.route(
"/upload",
axum::routing::put(
|State((attempts, authorizations)): State<StateData>,
headers: HeaderMap,
_body: Body| async move {
let authorization = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap()
.to_string();
authorizations.lock().unwrap().push(authorization);
let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
if attempt == 1 {
// Cross a Nostr timestamp boundary before the retry.
tokio::time::sleep(Duration::from_millis(1_100)).await;
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.body(Body::from("retry"))
.unwrap()
} else {
Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(Body::from(
r#"{"url":"http://localhost/media/a.bin","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":3,"type":"application/octet-stream","uploaded":1}"#,
))
.unwrap()
}
},
),
)
.with_state(state);
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() });

let client = test_client(&format!("http://{addr}"));
let mut file = tempfile::Builder::new().suffix(".ics").tempfile().unwrap();
std::io::Write::write_all(
&mut file,
b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Buzz//Test//EN\r\nEND:VCALENDAR\r\n",
)
.unwrap();
client
.upload_file(file.path().to_str().unwrap())
.await
.unwrap();

let authorizations = authorizations.lock().unwrap();
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_eq!(authorizations.len(), 2);
assert_eq!(authorizations[0], authorizations[1]);
}

/// A moderation command (kind 9040) that fails the first attempt with HTTP 429
/// carrying a plain (non-relay-ingest) body is NOT retried — surfaces as
/// `DeliveryUnknown`.
Expand Down
Loading