diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..e72d117e48 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -60,13 +60,26 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { tag } -/// MIME types accepted for upload. -const ALLOWED_MIMES: &[&str] = &[ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "video/mp4", +/// MIME types blocked from upload — mirrors the desktop/relay generic-file deny-list. +/// +/// Active-content XSS carriers and native executables. Everything else (images, +/// video, documents, archives, text, data) is accepted; un-sniffable files fall +/// back to `application/octet-stream` and are served as downloads by the relay. +const BLOCKED_MIMES: &[&str] = &[ + "text/html", + "application/xhtml+xml", + "image/svg+xml", + "application/javascript", + "text/javascript", + "application/x-msdownload", + "application/x-executable", + "application/vnd.microsoft.portable-executable", + "application/x-mach-binary", + "application/x-sharedlib", + "application/x-elf", + "application/x-msi", + "application/vnd.android.package-archive", + "application/x-apple-diskimage", ]; /// Maximum file size for image uploads (50 MB). @@ -75,6 +88,31 @@ 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 (non-image/video) uploads (100 MB). +/// Matches `buzz-media` default `max_file_bytes`. +const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; + +/// Detect MIME and reject blocked active-content / executable types. +fn detect_and_validate_upload_mime(bytes: &[u8]) -> Result { + let mime = infer::get(bytes) + .map(|t| t.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + if BLOCKED_MIMES.contains(&mime.as_str()) { + return Err(CliError::Usage(format!("unsupported file type: {mime}"))); + } + Ok(mime) +} + +fn max_upload_bytes(mime: &str) -> u64 { + if mime.starts_with("video/") { + MAX_VIDEO_BYTES + } else if mime.starts_with("image/") { + MAX_IMAGE_BYTES + } else { + MAX_FILE_BYTES + } +} + /// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value. /// /// The event includes: @@ -449,6 +487,31 @@ mod media_download_tests { } } + #[test] + fn upload_mime_accepts_plain_text_as_octet_stream() { + let mime = detect_and_validate_upload_mime(b"hello world").unwrap(); + assert_eq!(mime, "application/octet-stream"); + assert_eq!(max_upload_bytes(&mime), MAX_FILE_BYTES); + } + + #[test] + fn upload_mime_accepts_jpeg() { + let jpeg = [0xFF, 0xD8, 0xFF, 0xE0]; + let mime = detect_and_validate_upload_mime(&jpeg).unwrap(); + assert_eq!(mime, "image/jpeg"); + assert_eq!(max_upload_bytes(&mime), MAX_IMAGE_BYTES); + } + + #[test] + fn upload_mime_rejects_html() { + let html = b""; + let err = detect_and_validate_upload_mime(html).unwrap_err(); + match err { + CliError::Usage(msg) => assert!(msg.contains("text/html"), "got: {msg}"), + other => panic!("expected Usage error, got {other:?}"), + } + } + #[test] fn media_get_auth_header_is_server_scoped() { let keys = Keys::generate(); @@ -1108,21 +1171,11 @@ impl BuzzClient { 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()); - - if !ALLOWED_MIMES.contains(&mime.as_str()) { - return Err(CliError::Usage(format!("unsupported file type: {mime}"))); - } + // 2. Detect MIME from magic bytes (deny-list, matching desktop/relay) + let mime = detect_and_validate_upload_mime(&bytes)?; // 3. Size check - let max = if mime.starts_with("video/") { - MAX_VIDEO_BYTES - } else { - MAX_IMAGE_BYTES - }; + let max = max_upload_bytes(&mime); if bytes.len() as u64 > max { return Err(CliError::Usage(format!( "file too large: {} bytes (max {})", diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e411827..189b828216 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -1,47 +1,75 @@ use uuid::Uuid; -use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; +use crate::client::{extract_d_tag, extract_tag_value, 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. +/// List DM conversations the current identity belongs to. +/// +/// Queries membership (kind:39002) then channel metadata (kind:39000) and keeps +/// channels tagged `t=dm`. Kind:41001 "DM created" confirmations are no longer +/// emitted by the relay — Desktop lists DMs the same membership+metadata way. pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { let my_pk = client.keys().public_key().to_hex(); let limit = limit.unwrap_or(50).min(200); - let filter = serde_json::json!({ - "kinds": [41001], + + let member_filter = serde_json::json!({ + "kinds": [39002], "#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 member_events = client.query_paginated(member_filter, limit).await?; + let channel_ids: Vec = member_events + .iter() + .map(extract_d_tag) + .filter(|id| !id.is_empty()) + .collect(); + if channel_ids.is_empty() { + println!("[]"); + return Ok(()); + } + + let metadata_filter = serde_json::json!({ + "kinds": [39000], + "#d": channel_ids, + }); + let metadata_events = client.query_paginated(metadata_filter, limit).await?; + + let mut dms: Vec = metadata_events .iter() + .filter(|e| extract_tag_value(e, "t") == "dm") .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 - } + // Prefer live membership p-tags when available; fall back to metadata. + let participants: Vec = member_events + .iter() + .find(|m| extract_d_tag(m) == dm_id) + .map(|m| { + m.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() }) - .collect() + .unwrap_or_default() }) .unwrap_or_default(); serde_json::json!({ "dm_id": dm_id, + "name": extract_tag_value(e, "name"), "participants": participants, "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), }) }) .collect(); + dms.sort_by_key(|d| d.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); let output = serde_json::to_string(&dms).unwrap_or_default(); println!("{output}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..2f9e12720e 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -13,6 +13,149 @@ use buzz_sdk::mentions::{ MENTION_CAP, }; +/// Kind for stream message edits (NIP-Buzz). +const KIND_STREAM_MESSAGE_EDIT: u64 = 40003; + +/// Extract the first `e` tag target from an edit (or any) event. +fn first_e_tag_id(event: &serde_json::Value) -> Option<&str> { + let tags = event.get("tags")?.as_array()?; + for tag in tags { + let Some(parts) = tag.as_array() else { + continue; + }; + if parts.first().and_then(|v| v.as_str()) == Some("e") { + let id = parts.get(1)?.as_str()?; + if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(id); + } + } + } + None +} + +/// Overlay `imeta` (always) and `emoji` (when present on the edit) tags from an +/// edit event onto the original — mirrors desktop `applyEditTagOverlay`. +fn apply_edit_tag_overlay( + original_tags: &serde_json::Value, + edit_tags: &serde_json::Value, +) -> serde_json::Value { + let Some(original) = original_tags.as_array() else { + return original_tags.clone(); + }; + let Some(edit) = edit_tags.as_array() else { + return original_tags.clone(); + }; + let edit_has_emoji = edit.iter().any(|t| { + t.as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + == Some("emoji") + }); + let mut out: Vec = original + .iter() + .filter(|t| { + let key = t + .as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if key == "imeta" { + return false; + } + if edit_has_emoji && key == "emoji" { + return false; + } + true + }) + .cloned() + .collect(); + for t in edit { + let key = t + .as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if key == "imeta" || (edit_has_emoji && key == "emoji") { + out.push(t.clone()); + } + } + serde_json::Value::Array(out) +} + +/// Apply the latest kind:40003 edit onto each targeted message and optionally +/// drop the raw edit events from the result set. +/// +/// When `keep_edit_events` is true (caller explicitly requested kind 40003), +/// edits are left as separate events and originals are still overlaid so both +/// representations are useful. +fn apply_message_edits(events: &mut Vec, keep_edit_events: bool) { + use std::collections::HashMap; + + let mut latest_edit: HashMap = HashMap::new(); + for event in events.iter() { + if event.get("kind").and_then(|v| v.as_u64()) != Some(KIND_STREAM_MESSAGE_EDIT) { + continue; + } + let Some(target) = first_e_tag_id(event) else { + continue; + }; + let created = event.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + match latest_edit.get(target) { + Some(existing) + if existing + .get("created_at") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + >= created => {} + _ => { + latest_edit.insert(target.to_ascii_lowercase(), event.clone()); + } + } + } + + if latest_edit.is_empty() { + return; + } + + for event in events.iter_mut() { + let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + if kind == KIND_STREAM_MESSAGE_EDIT { + continue; + } + let Some(id) = event.get("id").and_then(|v| v.as_str()) else { + continue; + }; + let Some(edit) = latest_edit.get(&id.to_ascii_lowercase()) else { + continue; + }; + if let Some(content) = edit.get("content").cloned() { + event["content"] = content; + } + let original_tags = event + .get("tags") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + let edit_tags = edit + .get("tags") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + event["tags"] = apply_edit_tag_overlay(&original_tags, &edit_tags); + } + + if !keep_edit_events { + events.retain(|e| e.get("kind").and_then(|v| v.as_u64()) != Some(KIND_STREAM_MESSAGE_EDIT)); + } +} + +fn kinds_request_includes_edits(kinds: Option<&str>) -> bool { + kinds + .map(|k| { + k.split(',') + .any(|s| s.trim() == KIND_STREAM_MESSAGE_EDIT.to_string()) + }) + .unwrap_or(false) +} + /// Extract the thread root event ID from a Nostr tag array. /// /// Parses `"e"` tags with NIP-10 markers: @@ -271,6 +414,7 @@ pub async fn cmd_get_messages( ) -> Result<(), CliError> { validate_uuid(channel_id)?; let limit = limit.unwrap_or(50).min(200); + let keep_edit_events = kinds_request_includes_edits(kinds); let mut filter = serde_json::json!({ "kinds": [9, 40002, 40008, 45001, 45003], @@ -295,7 +439,31 @@ pub async fn cmd_get_messages( let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + + // Fetch edits targeting the returned messages so get shows edited content + // (Desktop overlays kind:40003; default get kinds intentionally omit it). + if !keep_edit_events { + let target_ids: Vec = events + .iter() + .filter_map(|e| e.get("id").and_then(|v| v.as_str()).map(str::to_string)) + .collect(); + if !target_ids.is_empty() { + let edit_filter = serde_json::json!({ + "kinds": [KIND_STREAM_MESSAGE_EDIT], + "#h": [channel_id], + "#e": target_ids, + "limit": 200 + }); + if let Ok(edit_resp) = client.query(&edit_filter).await { + let edits: Vec = + serde_json::from_str(&edit_resp).unwrap_or_default(); + events.extend(edits); + } + } + } + events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + apply_message_edits(&mut events, keep_edit_events); let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -314,7 +482,7 @@ pub async fn cmd_get_thread( let limit = limit.unwrap_or(100).min(500); // Two filters ORed in a single HTTP call: - // 1. Replies referencing this event via e-tag (no kind restriction) + // 1. Replies referencing this event via e-tag (includes edits) // 2. The root event itself by ID let mut reply_filter = serde_json::json!({ "kinds": [9, 40002, 40003, 40008, 45003], @@ -332,6 +500,8 @@ pub async fn cmd_get_thread( let resp = client.query_multi(&[reply_filter, root_filter]).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + // Overlay edits onto targets; hide raw 40003 rows so thread reads like Desktop. + apply_message_edits(&mut events, false); let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -357,8 +527,10 @@ pub async fn cmd_search( None => None, }; + // Include kind:40003 so full-text search hits edited bodies (edits are + // separate events; FTS indexes each event's own content). let mut filter = serde_json::json!({ - "kinds": [9, 40002, 45001, 45003], + "kinds": [9, 40002, 40003, 45001, 45003], "limit": limit }); if let Some(q) = query { @@ -372,6 +544,27 @@ pub async fn cmd_search( } let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + + // When FTS hits an edit (kind:40003), pull the targeted originals and + // overlay so results look like Desktop (edited body on the message row). + let edit_targets: Vec = events + .iter() + .filter(|e| e.get("kind").and_then(|v| v.as_u64()) == Some(KIND_STREAM_MESSAGE_EDIT)) + .filter_map(|e| first_e_tag_id(e).map(str::to_string)) + .collect(); + if !edit_targets.is_empty() { + let target_filter = serde_json::json!({ + "ids": edit_targets, + "limit": edit_targets.len().min(100) + }); + if let Ok(target_resp) = client.query(&target_filter).await { + let targets: Vec = + serde_json::from_str(&target_resp).unwrap_or_default(); + events.extend(targets); + } + apply_message_edits(&mut events, false); + } + // The full-text path returns relevance order; a pure author/time query has // no relevance, so present newest-first like `messages get`. if query.is_none() { @@ -876,7 +1069,10 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; + use super::{ + apply_message_edits, find_root_from_tags, kinds_request_includes_edits, + match_profiles_by_name, parse_member_pubkeys, + }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -1164,4 +1360,78 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + #[test] + fn apply_message_edits_overlays_latest_content_and_strips_edits() { + let original_id = "a".repeat(64); + let older_edit_id = "b".repeat(64); + let newer_edit_id = "c".repeat(64); + let mut events = vec![ + json!({ + "id": original_id, + "kind": 9, + "content": "original", + "created_at": 100, + "tags": [["h", "chan"], ["imeta", "url https://old"]], + }), + json!({ + "id": older_edit_id, + "kind": 40003, + "content": "first edit", + "created_at": 200, + "tags": [["e", original_id], ["imeta", "url https://mid"]], + }), + json!({ + "id": newer_edit_id, + "kind": 40003, + "content": "second edit", + "created_at": 300, + "tags": [["e", original_id], ["imeta", "url https://new"]], + }), + ]; + apply_message_edits(&mut events, false); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["content"], "second edit"); + let tags = events[0]["tags"].as_array().unwrap(); + assert!(tags.iter().any(|t| { + t.as_array().map(|a| a.get(1).and_then(|v| v.as_str()) == Some("url https://new")).unwrap_or(false) + })); + assert!(!tags.iter().any(|t| { + t.as_array().map(|a| a.get(1).and_then(|v| v.as_str()) == Some("url https://old")).unwrap_or(false) + })); + } + + #[test] + fn apply_message_edits_can_keep_raw_edit_events() { + let original_id = "d".repeat(64); + let edit_id = "e".repeat(64); + let mut events = vec![ + json!({ + "id": original_id, + "kind": 9, + "content": "original", + "created_at": 1, + "tags": [], + }), + json!({ + "id": edit_id, + "kind": 40003, + "content": "edited", + "created_at": 2, + "tags": [["e", original_id]], + }), + ]; + apply_message_edits(&mut events, true); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["content"], "edited"); + assert_eq!(events[1]["kind"], 40003); + } + + #[test] + fn kinds_request_includes_edits_parses_csv() { + assert!(!kinds_request_includes_edits(None)); + assert!(!kinds_request_includes_edits(Some("9,45001"))); + assert!(kinds_request_includes_edits(Some("9, 40003"))); + assert!(kinds_request_includes_edits(Some("40003"))); + } } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1674b0ec4d..6012c6b815 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -560,7 +560,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 24); + assert_eq!(migrations.len(), 25); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,6 +879,14 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); + + // FTS allowlist expansion: index kind:40003 edits so NIP-50 search can + // find post-edit bodies (0008 is checksum-frozen without 40003). + assert_eq!(migrations[24].version, 25); + let edit_fts = migrations[24].sql.as_str(); + assert!(edit_fts.contains("ARRAY[0, 9, 40002, 40003, 45001, 45003]")); + assert!(edit_fts.contains("kind IN (0, 9, 40002, 40003, 45001, 45003)")); + assert!(edit_fts.contains("events.search_tsv")); } #[test] @@ -1236,7 +1244,7 @@ mod tests { .await .expect("read fresh-install search expression"); assert!( - search_expression.contains("ARRAY[0, 9, 40002, 45001, 45003]"), + search_expression.contains("ARRAY[0, 9, 40002, 40003, 45001, 45003]"), "fresh-install search allowlist has the wrong kinds: {search_expression}" ); assert!( diff --git a/migrations/0025_fts_allowlist_message_edits.sql b/migrations/0025_fts_allowlist_message_edits.sql new file mode 100644 index 0000000000..ec17615959 --- /dev/null +++ b/migrations/0025_fts_allowlist_message_edits.sql @@ -0,0 +1,51 @@ +-- Expand the fresh-install FTS allowlist to include kind:40003 (message edits) +-- so edited bodies are discoverable via NIP-50 search. +-- +-- Migration 0008 (checksum-frozen) allowlists only (0, 9, 40002, 45001, 45003). +-- Edits are separate events; without indexing them, searching for post-edit text +-- misses. Brownfield deny-list installs already index 40003 and are left alone. +-- +-- PostgreSQL cannot alter a generated expression in place. When the allowlist +-- form is present (IN-list or ARRAY-normalized), rewrite the column while +-- preserving any wrappers added by later migrations (e.g. 0014's 30350 guard). +DO $$ +DECLARE + existing_expression TEXT; + new_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + new_expression := replace( + existing_expression, + 'kind IN (0, 9, 40002, 45001, 45003)', + 'kind IN (0, 9, 40002, 40003, 45001, 45003)' + ); + new_expression := replace( + new_expression, + 'ARRAY[0, 9, 40002, 45001, 45003]', + 'ARRAY[0, 9, 40002, 40003, 45001, 45003]' + ); + + IF new_expression = existing_expression THEN + -- Already includes 40003, or this install uses the deny-list form. + RETURN; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (%s) STORED', + new_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/scripts/maintenance/nip_rs_search_allowlist.sql b/scripts/maintenance/nip_rs_search_allowlist.sql index 1dc0a667fc..0f3542c5a5 100644 --- a/scripts/maintenance/nip_rs_search_allowlist.sql +++ b/scripts/maintenance/nip_rs_search_allowlist.sql @@ -10,7 +10,7 @@ SET LOCAL lock_timeout = '5s'; ALTER TABLE events DROP COLUMN search_tsv; ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (0, 9, 40002, 45001, 45003) + CASE WHEN kind IN (0, 9, 40002, 40003, 45001, 45003) THEN to_tsvector('simple', content) ELSE NULL::tsvector END