diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 9a03e40629..fcc102e4ce 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -65,8 +65,9 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { tag } -/// MIME types accepted for upload. -const ALLOWED_MIMES: &[&str] = &[ +/// MIME types the relay stores as inline media; everything else is a generic +/// file it validates, stores and serves as a download. +const MEDIA_MIMES: &[&str] = &[ "image/jpeg", "image/png", "image/gif", @@ -80,6 +81,9 @@ 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 generic file uploads (100 MB), the relay's default. +const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; + fn relay_server_tag(relay_url: &str) -> Option { let authority = buzz_core::tenant::relay_url_authority(relay_url); if authority.is_empty() { @@ -446,8 +450,21 @@ mod media_download_tests { } } -const QUERY_PAGE_SIZE: u32 = 500; +pub(crate) const QUERY_PAGE_SIZE: u32 = 500; +/// The most pages one limited query reads before failing, so a relay that +/// never runs out of events to skip cannot keep a command running forever. +pub(crate) const MAX_QUERY_PAGES: usize = 100; + +fn stalled_query(detail: &str) -> CliError { + CliError::Relay { + status: 200, + body: format!("query pagination stopped: {detail}"), + } +} + +/// Move the composite cursor to the page's last event, failing unless it +/// lands strictly older than the current cursor. fn advance_query_cursor( filter: &mut serde_json::Value, page: &[serde_json::Value], @@ -464,6 +481,20 @@ fn advance_query_cursor( .and_then(serde_json::Value::as_str) .filter(|id| id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit())) .ok_or_else(|| CliError::Other("query event missing valid id".into()))?; + if let (Some(until), Some(before_id)) = ( + filter.get("until").and_then(serde_json::Value::as_u64), + filter.get("before_id").and_then(serde_json::Value::as_str), + ) { + // The relay pages newest first, equal timestamps by ascending id: + // the next page lies at `created_at < until`, or at `until` past `before_id`. + let older = created_at < until + || (created_at == until && id.to_ascii_lowercase() > before_id.to_ascii_lowercase()); + if !older { + return Err(stalled_query( + "the relay did not move the cursor to older events", + )); + } + } filter["until"] = serde_json::json!(created_at); filter["before_id"] = serde_json::json!(id); Ok(()) @@ -701,8 +732,7 @@ impl BuzzClient { /// /// The relay's `{"error": …}` / `{"message": …}` field becomes the /// message. A 401 without a NIP-FI evidence source names the settings, - /// since an enforcing relay answers every request that way; a 403 with an - /// auth tag configured hints that the tag may be stale. + /// since an enforcing relay answers every request that way. fn relay_error(&self, status: u16, body: String) -> CliError { let message = extract_relay_message_field(&body).unwrap_or(body); let message = message.trim_end().to_owned(); @@ -725,18 +755,6 @@ impl BuzzClient { {FEDERATED_IDENTITY_ADAPTER_ENV} to its adapter, which mints the assertion" )); } - if status == 403 && self.credentials.auth_tag().is_some() { - let hint = if desktop { - "the Buzz desktop's NIP-OA auth tag may be stale or revoked; check this \ - identity's owner in Buzz" - } else { - "BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it" - }; - return CliError::Relay { - status, - body: format!("{message} ({hint})"), - }; - } CliError::Relay { status, body: message, @@ -805,15 +823,37 @@ impl BuzzClient { async fn query_pages( &self, - mut filter: serde_json::Value, + filter: serde_json::Value, limit: Option, + ) -> Result, CliError> { + self.query_pages_keeping(filter, limit, None).await + } + + /// Like `query_pages`, but with `keep`, only events it accepts count + /// toward `limit`: paging continues past the ones it drops, always in + /// full pages, for at most `MAX_QUERY_PAGES` pages. + async fn query_pages_keeping( + &self, + mut filter: serde_json::Value, + limit: Option, + keep: Option<&dyn Fn(&serde_json::Value) -> bool>, ) -> Result, CliError> { let mut events = Vec::new(); + let mut pages = 0; while limit.is_none_or(|limit| events.len() < limit as usize) { - let page_limit = limit - .map(|limit| (limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize)) - .unwrap_or(QUERY_PAGE_SIZE as usize); + if keep.is_some() && pages == MAX_QUERY_PAGES { + return Err(stalled_query(&format!( + "read {MAX_QUERY_PAGES} pages without reaching the end of the results" + ))); + } + pages += 1; + let page_limit = match (limit, keep) { + (Some(limit), None) => { + (limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize) + } + _ => QUERY_PAGE_SIZE as usize, + }; filter["limit"] = serde_json::json!(page_limit); let raw = self.query(&filter).await?; @@ -824,15 +864,30 @@ impl BuzzClient { if !done { advance_query_cursor(&mut filter, &page)?; } - events.extend(page); + events.extend(page.into_iter().filter(|e| keep.is_none_or(|keep| keep(e)))); if done { break; } } + if let Some(limit) = limit { + events.truncate(limit as usize); + } Ok(events) } + /// Query up to `limit` historical events that `keep` accepts, paging past + /// the rest until the limit is met or the history runs out. + pub async fn query_paginated_keeping( + &self, + filter: serde_json::Value, + limit: u32, + keep: impl Fn(&serde_json::Value) -> bool, + ) -> Result, CliError> { + self.query_pages_keeping(filter, Some(limit), Some(&keep)) + .await + } + /// Query up to `limit` historical events, following the relay bridge's /// composite `(until, before_id)` cursor across bounded result pages. pub async fn query_paginated( @@ -1387,28 +1442,26 @@ impl BuzzClient { pub async fn upload_file(&self, file_path: &str) -> Result { // 1. Read file — validate it exists and is a regular file let metadata = std::fs::metadata(file_path) - .map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?; + .map_err(|e| CliError::Usage(format!("cannot access {file_path}: {e}")))?; if !metadata.is_file() { return Err(CliError::Usage(format!("{file_path} is not a file"))); } let bytes = std::fs::read(file_path) - .map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?; + .map_err(|e| CliError::Usage(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()); - 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/") { + // 3. Size check. The relay decides which other types it accepts. + let max = if mime == "video/mp4" { MAX_VIDEO_BYTES - } else { + } else if MEDIA_MIMES.contains(&mime.as_str()) { MAX_IMAGE_BYTES + } else { + MAX_FILE_BYTES }; if bytes.len() as u64 > max { return Err(CliError::Usage(format!( @@ -1423,7 +1476,7 @@ impl BuzzClient { // 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/") { + let upload_timeout = if max > MAX_IMAGE_BYTES { Duration::from_secs(600) } else { Duration::from_secs(120) @@ -2597,6 +2650,27 @@ mod retry_policy_tests { #[cfg(test)] mod tests { + #[cfg(unix)] + #[tokio::test] + async fn an_unreadable_file_is_bad_input() { + use std::os::unix::fs::PermissionsExt; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o000)).unwrap(); + let client = BuzzClient::new( + "http://127.0.0.1:9".into(), + crate::credentials::Credentials::from_keys(nostr::Keys::generate()), + ) + .unwrap(); + let err = client + .upload_file(file.path().to_str().unwrap()) + .await + .unwrap_err(); + assert!( + matches!(err, crate::error::CliError::Usage(ref m) if m.contains("failed to read")), + "{err:?}" + ); + } + use super::{ advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, normalize_events, BuzzClient, Credentials, Proof, @@ -3497,7 +3571,7 @@ mod desktop_throttle_tests { let err = client.get_authed("/forbidden").await.unwrap_err(); let text = err.to_string(); assert!( - text.contains("in Buzz") && !text.contains("BUZZ_AUTH_TAG"), + !text.contains("stale") && !text.contains("BUZZ_AUTH_TAG"), "{text}" ); } diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 65df2bda56..092e4a3cb3 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -4,47 +4,71 @@ use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{parse_uuid, sdk_err, validate_hex64}; -/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. +/// Tag values named `name` on a relay event. +fn tag_values<'a>(event: &'a serde_json::Value, name: &'a str) -> impl Iterator { + event + .get("tags") + .and_then(|t| t.as_array()) + .into_iter() + .flatten() + .filter_map(move |tag| { + let arr = tag.as_array()?; + (arr.first()?.as_str()? == name) + .then(|| arr.get(1)?.as_str()) + .flatten() + }) +} + +/// List visible DM conversations: the viewer's DM group metadata (kind:39000 +/// `t=dm`, one `p` per participant), minus the DMs in the viewer's newest +/// relay-signed visibility snapshot (kind:30622, one `h` per hidden DM). pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { + let dms = list_dms(client, limit.unwrap_or(50).min(200)).await?; + println!("{}", serde_json::Value::Array(dms)); + Ok(()) +} + +async fn list_dms(client: &BuzzClient, limit: u32) -> Result, CliError> { let my_pk = client.public_key().to_hex(); - let limit = limit.unwrap_or(50).min(200); - let filter = serde_json::json!({ - "kinds": [41001], - "#p": [my_pk], - "limit": limit - }); - let resp = client.query(&filter).await?; - let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - let dms: Vec = events + let snapshots = client + .query_paginated( + serde_json::json!({ + "kinds": [buzz_sdk::kind::KIND_DM_VISIBILITY], + "#p": [my_pk], + }), + 10, + ) + .await?; + let hidden: std::collections::HashSet = snapshots + .iter() + .max_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)) + .map(|e| tag_values(e, "h").map(str::to_owned).collect()) + .unwrap_or_default(); + let events = client + .query_paginated_keeping( + serde_json::json!({ + "kinds": [39000], + "#t": ["dm"], + "#p": [my_pk], + }), + limit, + |e| { + let dm_id = extract_d_tag(e); + !dm_id.is_empty() && !hidden.contains(&dm_id) + }, + ) + .await?; + Ok(events .iter() .map(|e| { - let dm_id = extract_d_tag(e); - let participants: Vec = e - .get("tags") - .and_then(|t| t.as_array()) - .map(|tags| { - tags.iter() - .filter_map(|tag| { - let arr = tag.as_array()?; - if arr.first()?.as_str()? == "p" { - arr.get(1)?.as_str().map(|s| s.to_string()) - } else { - None - } - }) - .collect() - }) - .unwrap_or_default(); + let participants: Vec<&str> = tag_values(e, "p").collect(); serde_json::json!({ - "dm_id": dm_id, + "dm_id": extract_d_tag(e), "participants": participants, "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), }) }) - .collect(); - let output = serde_json::to_string(&dms).unwrap_or_default(); - println!("{output}"); - Ok(()) + .collect()) } /// Open a DM with one or more users — sign and submit a kind:41010 event with a d-tag. @@ -134,3 +158,212 @@ pub async fn dispatch(cmd: crate::DmsCmd, client: &BuzzClient) -> Result<(), Cli DmsCmd::Hide { channel } => cmd_hide_dm(client, &channel).await, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + /// A relay holding `events` (newest first) that pages `/query` by its + /// composite cursor, logging each filter it answers. + async fn relay(events: Vec) -> (String, Arc>>) { + use axum::{routing::post, Json, Router}; + let log = Arc::new(Mutex::new(Vec::new())); + let seen = log.clone(); + let app = Router::new().route( + "/query", + post(move |Json(filters): Json>| { + let events = events.clone(); + let seen = seen.clone(); + async move { + let f = filters[0].clone(); + seen.lock().unwrap().push(f.clone()); + let kind = f["kinds"][0].as_u64(); + let limit = f["limit"].as_u64().unwrap_or(500) as usize; + let after_cursor = |e: &&serde_json::Value| match f["before_id"].as_str() { + None => true, + Some(id) => { + let at = f["until"].as_u64().unwrap(); + let t = e["created_at"].as_u64().unwrap(); + t < at || (t == at && e["id"].as_str().unwrap() > id) + } + }; + let page: Vec<_> = events + .iter() + .filter(|e| e["kind"].as_u64() == kind) + .filter(after_cursor) + .take(limit) + .cloned() + .collect(); + Json(page) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (base, log) + } + + fn dm(n: u64) -> serde_json::Value { + serde_json::json!({ + "id": format!("{n:064x}"), + "kind": 39000, + "created_at": 1_000 + n, + "tags": [["d", format!("dm-{n}")], ["t", "dm"]], + }) + } + + #[tokio::test] + async fn hidden_dms_do_not_crowd_out_older_visible_ones() { + // 200 newer hidden DMs, then one older visible DM. + let mut events: Vec<_> = (1..=200).rev().map(dm).collect(); + events.push(dm(0)); + let hidden: Vec<_> = (1..=200) + .map(|n| serde_json::json!(["h", format!("dm-{n}")])) + .collect(); + events.push(serde_json::json!({ + "id": "f".repeat(64), "kind": 30622, "created_at": 5_000, "tags": hidden, + })); + let (base, _) = relay(events).await; + let client = BuzzClient::new( + base, + crate::credentials::Credentials::from_keys(nostr::Keys::generate()), + ) + .unwrap(); + + let dms = list_dms(&client, 200).await.unwrap(); + + assert_eq!(dms.len(), 1, "{dms:?}"); + assert_eq!(dms[0]["dm_id"], "dm-0"); + } + + #[tokio::test] + async fn a_small_limit_still_reads_full_pages_past_hidden_dms() { + // 150 newer hidden DMs, then one older visible DM, with limit 1. + let mut events: Vec<_> = (1..=150).rev().map(dm).collect(); + events.push(dm(0)); + let hidden: Vec<_> = (1..=150) + .map(|n| serde_json::json!(["h", format!("dm-{n}")])) + .collect(); + events.push(serde_json::json!({ + "id": "f".repeat(64), "kind": 30622, "created_at": 5_000, "tags": hidden, + })); + let (base, log) = relay(events).await; + + let dms = list_dms(&client(base), 1).await.unwrap(); + + assert_eq!(dms.len(), 1, "{dms:?}"); + assert_eq!(dms[0]["dm_id"], "dm-0"); + let log = log.lock().unwrap(); + let dm_pages: Vec<_> = log.iter().filter(|f| f["kinds"][0] == 39000).collect(); + assert_eq!(dm_pages.len(), 1, "{dm_pages:?}"); + assert_eq!(dm_pages[0]["limit"], crate::client::QUERY_PAGE_SIZE); + } + + #[tokio::test] + async fn pages_past_a_hidden_dm_with_the_same_timestamp() { + // Equal timestamps come back in ascending id order. + let same = |n: u64| { + let mut e = dm(n); + e["created_at"] = serde_json::json!(1_000); + e + }; + // A full page of hidden DMs, then a visible one with the same + // timestamp and a larger id, so the cursor must stay on that second. + let page = crate::client::QUERY_PAGE_SIZE as u64; + let mut events: Vec<_> = (1..=page + 1).map(same).collect(); + let hidden: Vec<_> = (1..=page) + .map(|n| serde_json::json!(["h", format!("dm-{n}")])) + .collect(); + events.push(serde_json::json!({ + "id": "f".repeat(64), "kind": 30622, "created_at": 5_000, "tags": hidden, + })); + let (base, log) = relay(events).await; + + let dms = list_dms(&client(base), 1).await.unwrap(); + + assert_eq!(dms.len(), 1, "{dms:?}"); + assert_eq!(dms[0]["dm_id"], format!("dm-{}", page + 1)); + assert!(log + .lock() + .unwrap() + .iter() + .any(|f| f["before_id"].is_string())); + } + + /// A relay that answers every DM-metadata query with `page(filter)`. + async fn fixed_relay( + page: fn(&serde_json::Value) -> Vec, + ) -> (String, Arc>) { + use axum::{routing::post, Json, Router}; + let count = Arc::new(Mutex::new(0)); + let seen = count.clone(); + let app = Router::new().route( + "/query", + post(move |Json(filters): Json>| { + let seen = seen.clone(); + async move { + let f = &filters[0]; + if f["kinds"][0].as_u64() != Some(39000) { + return Json(Vec::new()); + } + *seen.lock().unwrap() += 1; + Json(page(f)) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (base, count) + } + + /// A full page of DMs with no `d` tag, which `list_dms` drops, older + /// than the filter's cursor. + fn dropped_page(f: &serde_json::Value) -> Vec { + let top = f["until"].as_u64().unwrap_or(1_000_000); + (1..=f["limit"].as_u64().unwrap()) + .map(|n| { + serde_json::json!({ + "id": "a".repeat(64), "kind": 39000, "created_at": top - n, "tags": [], + }) + }) + .collect() + } + + fn client(base: String) -> BuzzClient { + BuzzClient::new( + base, + crate::credentials::Credentials::from_keys(nostr::Keys::generate()), + ) + .unwrap() + } + + #[tokio::test] + async fn a_relay_repeating_the_same_page_fails() { + let (base, count) = fixed_relay(|f| { + let mut f = f.clone(); + f["until"] = serde_json::Value::Null; + dropped_page(&f) + }) + .await; + + let err = list_dms(&client(base), 10).await.unwrap_err(); + + assert_eq!(crate::error::exit_code(&err), 2, "{err}"); + assert!(err.to_string().contains("cursor"), "{err}"); + assert_eq!(*count.lock().unwrap(), 2); + } + + #[tokio::test] + async fn a_relay_that_never_runs_out_fails_at_the_page_cap() { + let (base, count) = fixed_relay(dropped_page).await; + + let err = list_dms(&client(base), 10).await.unwrap_err(); + + assert_eq!(crate::error::exit_code(&err), 2, "{err}"); + assert!(err.to_string().contains("pages"), "{err}"); + assert_eq!(*count.lock().unwrap(), crate::client::MAX_QUERY_PAGES); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 6e3d476823..2bf9434c83 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -347,10 +347,7 @@ pub async fn cmd_get_messages( // If specific kinds requested, override if let Some(k) = kinds { - let kind_list: Vec = k.split(',').filter_map(|s| s.trim().parse().ok()).collect(); - if !kind_list.is_empty() { - filter["kinds"] = serde_json::json!(kind_list); - } + filter["kinds"] = serde_json::json!(parse_kinds(k)?); } if let Some(b) = before { @@ -674,18 +671,13 @@ pub async fn cmd_send_message( let mut media_tags: Vec> = Vec::new(); let mut media_content = String::new(); for file_path in &p.files { - let desc = client - .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/") { - media_content.push_str("\n![video]("); - } else { - media_content.push_str("\n![image]("); - } - media_content.push_str(&desc.url); - media_content.push(')'); + let desc = client.upload_file(file_path).await?; + let name = attachment_name(file_path); + let mut tag = crate::client::build_imeta_tag(&desc); + tag.push(format!("filename {name}")); + media_tags.push(tag); + media_content.push('\n'); + media_content.push_str(&attachment_markdown(&name, &desc)); } let final_content = if media_content.is_empty() { p.content.clone() @@ -1129,8 +1121,99 @@ pub async fn dispatch( } } +/// A file's name as relay filename metadata takes it: its basename with +/// control characters and path separators as spaces, capped at 255 UTF-8 bytes. +fn attachment_name(file_path: &str) -> String { + let base = std::path::Path::new(file_path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut name = String::new(); + for c in base.chars() { + let c = if c.is_control() || c == '/' || c == '\\' { + ' ' + } else { + c + }; + if name.len() + c.len_utf8() > 255 { + break; + } + name.push(c); + } + match name.trim() { + "" => "File".to_string(), + trimmed => trimmed.to_string(), + } +} + +/// An uploaded file's Markdown link: inline media embeds, other files link. +fn attachment_markdown(name: &str, desc: &crate::client::BlobDescriptor) -> String { + let mut label = String::new(); + for c in name.chars() { + if "\\`*_{}[]()!<>&".contains(c) { + label.push('\\'); + } + label.push(c); + } + let embed = matches!( + desc.mime_type.as_str(), + "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "video/mp4" + ); + format!("{}[{label}](<{}>)", if embed { "!" } else { "" }, desc.url) +} + +/// A `--kinds` list: comma-separated event kinds, each 0–65535. +fn parse_kinds(list: &str) -> Result, CliError> { + list.split(',') + .map(|s| { + s.trim() + .parse() + .map_err(|_| CliError::Usage(format!("--kinds: invalid kind {s:?}"))) + }) + .collect() +} + #[cfg(test)] mod tests { + #[test] + fn parse_kinds_rejects_any_invalid_kind() { + use super::{parse_kinds, CliError}; + assert_eq!(parse_kinds("9, 40002").unwrap(), vec![9, 40002]); + for bad in ["bad", "65536", "9,typo", "", "-1"] { + assert!(matches!(parse_kinds(bad), Err(CliError::Usage(_))), "{bad}"); + } + } + + #[test] + fn attachments_embed_media_and_link_other_files() { + use super::{attachment_markdown, attachment_name}; + let blob = |mime: &str| crate::client::BlobDescriptor { + url: "https://r.example/media/x.bin".into(), + sha256: "x".into(), + size: 1, + mime_type: mime.into(), + uploaded: 0, + dim: None, + blurhash: None, + thumb: None, + duration: None, + }; + assert_eq!( + attachment_markdown("notes [v1].txt", &blob("application/octet-stream")), + "[notes \\[v1\\].txt]()" + ); + assert_eq!( + attachment_markdown("a.png", &blob("image/png")), + "![a.png]()" + ); + assert_eq!(attachment_name("/tmp/dir/report.pdf"), "report.pdf"); + assert_eq!(attachment_name("/tmp/a\nb.txt"), "a b.txt"); + assert_eq!(attachment_name("/"), "File"); + // On Windows a backslash is a path separator, not part of the name. + #[cfg(unix)] + assert_eq!(attachment_name("/tmp/a\\b.txt"), "a b.txt"); + } + use super::{ channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, diff --git a/crates/buzz-cli/src/desktop_provider.rs b/crates/buzz-cli/src/desktop_provider.rs index b0a4247768..0f864e0e98 100644 --- a/crates/buzz-cli/src/desktop_provider.rs +++ b/crates/buzz-cli/src/desktop_provider.rs @@ -1049,6 +1049,36 @@ pub async fn discover(relay_url: &str) -> Result>, CliError discover_with(inherited()?, &Keychain, advertisements, relay_url).await } +/// The relay an agent's inherited credential acts on, for a command given +/// none. `None` without an inherited token, or when `describe` fails (the +/// credential chain reports that); bad input when it acts on several relays. +pub async fn inherited_relay() -> Result, CliError> { + let Some((endpoint, token)) = inherited()? else { + return Ok(None); + }; + let Ok(raw) = call(&endpoint, Some(&token), true, "describe", &[]).await else { + return Ok(None); + }; + let relays = serde_json::from_str::(&raw) + .map(|d| d.relays) + .unwrap_or_default(); + let mut distinct: Vec = Vec::new(); + for relay in relays { + if !distinct.iter().any(|known| same_relay(known, &relay)) { + distinct.push(provider_relay_url(&relay).unwrap_or(relay)); + } + } + match distinct.len() { + 0 => Ok(None), + 1 => Ok(distinct.pop()), + _ => Err(CliError::Usage(format!( + "this agent's Buzz credential acts on several relays ({}); pass --relay or set \ + BUZZ_RELAY_URL", + distinct.join(", ") + ))), + } +} + async fn discover_with( inherited: Option<(Endpoint, String)>, store: &dyn PairingStore, @@ -2022,16 +2052,21 @@ pub(crate) mod tests { let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![TEAM.into()]).await; let cli = parse(&["buzz", "--relay", TEAM, "users", "get"]); - let (_, client) = crate::connect(&cli.relay, cli.command, async |_, _| unreachable!(), { - let store = &store; - async move |relay| { - let provider = - discover_with(Some((endpoint, "agent".into())), store, Vec::new, relay) - .await? - .unwrap(); - crate::credentials::Credentials::from_desktop(provider).await - } - }) + let (_, client) = crate::connect( + cli.relay.as_deref().unwrap(), + cli.command, + async |_, _| unreachable!(), + { + let store = &store; + async move |relay| { + let provider = + discover_with(Some((endpoint, "agent".into())), store, Vec::new, relay) + .await? + .unwrap(); + crate::credentials::Credentials::from_desktop(provider).await + } + }, + ) .await .unwrap() .unwrap(); @@ -2050,7 +2085,7 @@ pub(crate) mod tests { "buzz", "--relay", TEAM, "auth", "desktop", "pair", "--name", "cli", ]); let paired = crate::connect( - &cli.relay, + cli.relay.as_deref().unwrap(), cli.command, async |sub, relay| { let crate::AuthCmd::Desktop(crate::DesktopAuthCmd::Pair { name, .. }) = sub else { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 40c58992a7..9380e82655 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -128,7 +128,8 @@ fn env_output_format(value: Option<&std::ffi::OsStr>) -> OutputFormat { Buzz CLI — interact with a Buzz relay Configuration (flags override env vars): - BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] + BUZZ_RELAY_URL Relay base URL [default: the relay an agent's inherited + Buzz desktop credential acts on, else http://localhost:3000] BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [unless a Buzz desktop provides it] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] BUZZ_FEDERATED_IDENTITY_TOKEN Your NIP-FA sign-in session token [optional] @@ -145,8 +146,10 @@ Errors are JSON on stderr: {\"error\": \"\", \"message\": \"\" )] struct Cli { /// Relay URL (http:// or https://). Overrides BUZZ_RELAY_URL env var. - #[arg(long, env = "BUZZ_RELAY_URL", default_value = "http://localhost:3000")] - relay: String, + /// Without either, the relay an agent's inherited Buzz desktop credential + /// acts on; otherwise http://localhost:3000. + #[arg(long, env = "BUZZ_RELAY_URL")] + relay: Option, /// Nostr private key (hex or nsec). This is the CLI's identity. #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] @@ -372,6 +375,24 @@ enum DesktopAuthCmd { }, } +/// The relay for a command given none: the one an agent's inherited Buzz +/// desktop credential acts on, else `http://localhost:3000`. Local-only +/// unpairing never asks the desktop. +async fn default_relay(command: &Cmd) -> Result { + #[cfg(unix)] + if !matches!( + command, + Cmd::Auth(AuthCmd::Desktop(DesktopAuthCmd::Unpair { .. })) + ) { + if let Some(relay) = desktop_provider::inherited_relay().await? { + return Ok(relay); + } + } + #[cfg(not(unix))] + let _ = command; + Ok("http://localhost:3000".to_owned()) +} + #[cfg(unix)] async fn run_auth(cmd: AuthCmd, relay_url: &str) -> Result<(), CliError> { let AuthCmd::Desktop(cmd) = cmd; @@ -2392,7 +2413,11 @@ async fn run(cli: Cli) -> Result<(), CliError> { ..Default::default() } .with_env_evidence(); - let Some((command, client)) = connect(&cli.relay, cli.command, run_auth, async |relay| { + let relay = match cli.relay { + Some(relay) => relay, + None => default_relay(&cli.command).await?, + }; + let Some((command, client)) = connect(&relay, cli.command, run_auth, async |relay| { credentials::resolve(settings, relay).await }) .await?