mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
fix(cli): list DMs, attach any file, validate kinds, default the relay (#9168)
Fixes five defects in the `buzz` CLI. - **`dms list` returned `[]`.** It queried kind 41001, which the relay no longer publishes. It now reads the viewer's DM group metadata (kind 39000, `t=dm`, `p=viewer`) and drops every DM listed in the viewer's newest relay-signed visibility snapshot (kind 30622), the same hidden set the desktop uses. It keeps paging until it has the requested number of visible DMs or the history runs out, so hidden DMs never crowd out visible ones. Hidden DMs stay hidden; reopening one brings it back. - **Uploads accepted only images and MP4.** The CLI now leaves type acceptance to the relay, as the desktop does: unsniffable files go up as `application/octet-stream`, and the relay's own allow and deny rules apply. Local size caps match the relay's defaults (50 MB images, 500 MB MP4, 100 MB other files). `messages send --file` adds an imeta `filename` (the basename, with control characters and both path separators replaced by spaces) and writes non-media files as a plain link (`[name](<url>)`) instead of ``; inline media still embeds. - **`messages get --kinds` silently ignored bad values.** Any non-numeric or out-of-range (above 65535) kind now fails with a bad-input error, exit 1. - **Upload errors lost their category.** A missing or unreadable file is bad input (exit 1), and `messages send --file` returns the upload's own error instead of wrapping every failure as `Other` (exit 4). The "auth tag may be stale" hint on every 403 is gone: the relay answers 403 for ordinary permission denials, such as a non-moderator reading moderation data, so the hint misled. - **Agents had to pass the relay by hand.** On macOS and Linux, with no `--relay` and no `BUZZ_RELAY_URL`, the CLI uses the relay an agent's inherited `BUZZ_CREDENTIAL_PROVIDER` token acts on, from its `describe` answer. If that lists several relays, it fails with exit 1 and asks for `--relay`. An explicit flag or env var still wins and is still checked against the credential. Paired tokens never choose a relay, `auth desktop unpair` never asks the desktop, and otherwise the default stays `http://localhost:3000`. Help text says so. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: wpfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -65,8 +65,9 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
|
||||
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<String> {
|
||||
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<u32>,
|
||||
) -> Result<Vec<serde_json::Value>, 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<u32>,
|
||||
keep: Option<&dyn Fn(&serde_json::Value) -> bool>,
|
||||
) -> Result<Vec<serde_json::Value>, 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<Vec<serde_json::Value>, 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<BlobDescriptor, CliError> {
|
||||
// 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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user