lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 36d87d367542e911bd1c4e0ad647b490d2b6f827
parent f6fbab7e1dc5f27528ced08256ee09cc0cbffc77
Author: triesap <tyson@radroots.org>
Date:   Fri, 12 Jun 2026 03:29:37 -0700

events_codec: harden social existing codecs

- preserve post social metadata tags and strict public file metadata boundaries
- enforce strict NIP-22 comment targets without legacy fallback tags
- accept empty NIP-25 reactions while validating event and address targets
- cover Farm Ops isolation, NIP-29 boundaries, and upgraded codec paths with serde_json tests

Diffstat:
Mcrates/events/src/comment.rs | 6+++---
Mcrates/events/src/reaction.rs | 4++--
Mcrates/events_codec/src/comment/decode.rs | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/events_codec/src/comment/encode.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/events_codec/src/farm/decode.rs | 29+++++++++++++++++++++++++++++
Mcrates/events_codec/src/file_metadata/decode.rs | 13+++++++++++++
Mcrates/events_codec/src/post/decode.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/events_codec/src/post/encode.rs | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/events_codec/src/reaction/decode.rs | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/events_codec/src/reaction/encode.rs | 103++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/events_codec/src/tag_builders.rs | 5+++--
Mcrates/events_codec/tests/comment.rs | 439++++++++++++++++++++++++++++++++++++-------------------------------------------
Mcrates/events_codec/tests/post.rs | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/tests/reaction.rs | 360++++++++++++++++++++++++++++++++++++++-----------------------------------------
Acrates/events_codec/tests/social_events.rs | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/tests/tag_builders.rs | 25+++++++++++++++----------
16 files changed, 1687 insertions(+), 545 deletions(-)

diff --git a/crates/events/src/comment.rs b/crates/events/src/comment.rs @@ -1,4 +1,4 @@ -use crate::RadrootsNostrEventRef; +use crate::social::RadrootsSocialTarget; #[cfg(not(feature = "std"))] use alloc::string::String; @@ -6,7 +6,7 @@ use alloc::string::String; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct RadrootsComment { - pub root: RadrootsNostrEventRef, - pub parent: RadrootsNostrEventRef, + pub root: RadrootsSocialTarget, + pub parent: RadrootsSocialTarget, pub content: String, } diff --git a/crates/events/src/reaction.rs b/crates/events/src/reaction.rs @@ -1,4 +1,4 @@ -use crate::RadrootsNostrEventRef; +use crate::social::RadrootsSocialTarget; #[cfg(not(feature = "std"))] use alloc::string::String; @@ -6,6 +6,6 @@ use alloc::string::String; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct RadrootsReaction { - pub root: RadrootsNostrEventRef, + pub target: RadrootsSocialTarget, pub content: String, } diff --git a/crates/events_codec/src/comment/decode.rs b/crates/events_codec/src/comment/decode.rs @@ -7,12 +7,13 @@ use alloc::{ use radroots_events::{ RadrootsNostrEvent, comment::RadrootsComment, - kinds::KIND_COMMENT, + kinds::{KIND_COMMENT, KIND_POST}, + social::RadrootsSocialTarget, tags::{TAG_E_PREV, TAG_E_ROOT}, }; use crate::error::EventParseError; -use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag, parse_nip10_ref_tags}; +use crate::field_helpers::{parse_address_tag, validate_lowercase_hex_64_tag}; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; const DEFAULT_KIND: u32 = KIND_COMMENT; @@ -31,22 +32,21 @@ pub fn comment_from_tags( if content.trim().is_empty() { return Err(EventParseError::InvalidTag("content")); } + if tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT)) + { + return Err(EventParseError::InvalidTag(TAG_E_ROOT)); + } + if tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_PREV)) + { + return Err(EventParseError::InvalidTag(TAG_E_PREV)); + } - let root = if find_event_ref_tag(tags, "E").is_some() { - parse_nip10_ref_tags(tags, "E", "P", "K", "A")? - } else if let Some(root_tag) = find_event_ref_tag(tags, TAG_E_ROOT) { - parse_event_ref_tag(root_tag, TAG_E_ROOT)? - } else { - return Err(EventParseError::MissingTag("E")); - }; - - let parent = if find_event_ref_tag(tags, "e").is_some() { - parse_nip10_ref_tags(tags, "e", "p", "k", "a")? - } else if let Some(tag) = find_event_ref_tag(tags, TAG_E_PREV) { - parse_event_ref_tag(tag, TAG_E_PREV)? - } else { - root.clone() - }; + let root = parse_comment_target(tags, CommentTargetTags::root())?; + let parent = parse_comment_target(tags, CommentTargetTags::parent())?; Ok(RadrootsComment { root, @@ -55,6 +55,163 @@ pub fn comment_from_tags( }) } +struct CommentTargetTags { + event: &'static str, + address: &'static str, + external: &'static str, + author: &'static str, + kind: &'static str, +} + +impl CommentTargetTags { + fn root() -> Self { + Self { + event: "E", + address: "A", + external: "I", + author: "P", + kind: "K", + } + } + + fn parent() -> Self { + Self { + event: "e", + address: "a", + external: "i", + author: "p", + kind: "k", + } + } +} + +fn parse_comment_target( + tags: &[Vec<String>], + keys: CommentTargetTags, +) -> Result<RadrootsSocialTarget, EventParseError> { + let event_tag = find_tag(tags, keys.event); + let address_tag = find_tag(tags, keys.address); + let external_tag = find_tag(tags, keys.external); + let target_count = usize::from(event_tag.is_some()) + + usize::from(address_tag.is_some()) + + usize::from(external_tag.is_some()); + if target_count == 0 { + return Err(EventParseError::MissingTag(keys.event)); + } + if target_count > 1 { + return Err(EventParseError::InvalidTag(keys.event)); + } + + if let Some(tag) = event_tag { + let id = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(keys.event))?; + validate_lowercase_hex_64_tag(&id, keys.event)?; + let kind = required_numeric_kind(tags, keys.kind)?; + validate_comment_target_kind(kind, keys.kind)?; + let author = required_author(tags, keys.author)?; + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + return Ok(RadrootsSocialTarget::Event { + id, + author: Some(author), + event_kind: Some(kind), + relays, + }); + } + + if let Some(tag) = address_tag { + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(keys.address))?; + let address = parse_address_tag(&value, keys.address)?; + let kind = required_numeric_kind(tags, keys.kind)?; + if kind != address.kind { + return Err(EventParseError::InvalidTag(keys.kind)); + } + validate_comment_target_kind(kind, keys.kind)?; + let author = required_author(tags, keys.author)?; + if author != address.pubkey { + return Err(EventParseError::InvalidTag(keys.author)); + } + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + return Ok(RadrootsSocialTarget::Address { + address: value, + author: Some(author), + event_kind: Some(kind), + relays, + }); + } + + let Some(tag) = external_tag else { + return Err(EventParseError::MissingTag(keys.external)); + }; + let id = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(keys.external))?; + if id.trim().is_empty() { + return Err(EventParseError::InvalidTag(keys.external)); + } + let external_kind = required_kind_value(tags, keys.kind)?; + let hint = tag.get(2).filter(|value| !value.trim().is_empty()).cloned(); + Ok(RadrootsSocialTarget::External { + id, + external_kind, + hint, + }) +} + +fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> { + tags.iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(key)) +} + +fn required_author(tags: &[Vec<String>], key: &'static str) -> Result<String, EventParseError> { + let value = find_tag(tags, key) + .and_then(|tag| tag.get(1)) + .cloned() + .ok_or(EventParseError::MissingTag(key))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(key)); + } + Ok(value) +} + +fn required_kind_value(tags: &[Vec<String>], key: &'static str) -> Result<String, EventParseError> { + let value = find_tag(tags, key) + .and_then(|tag| tag.get(1)) + .cloned() + .ok_or(EventParseError::MissingTag(key))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(key)); + } + Ok(value) +} + +fn required_numeric_kind(tags: &[Vec<String>], key: &'static str) -> Result<u32, EventParseError> { + required_kind_value(tags, key)? + .parse::<u32>() + .map_err(|err| EventParseError::InvalidNumber(key, err)) +} + +fn validate_comment_target_kind(kind: u32, key: &'static str) -> Result<(), EventParseError> { + if kind == KIND_POST { + Err(EventParseError::InvalidTag(key)) + } else { + Ok(()) + } +} + pub fn data_from_event( id: String, author: String, diff --git a/crates/events_codec/src/comment/encode.rs b/crates/events_codec/src/comment/encode.rs @@ -1,47 +1,28 @@ #[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; -use radroots_events::{RadrootsNostrEventRef, comment::RadrootsComment}; +use radroots_events::{ + comment::RadrootsComment, + kinds::{KIND_COMMENT, KIND_POST}, + social::RadrootsSocialTarget, +}; use crate::error::EventEncodeError; -use crate::event_ref::push_nip10_ref_tags; +use crate::field_helpers::{ + parse_address_tag, validate_lowercase_hex_64, validate_non_empty_field, +}; use crate::wire::WireEventParts; -use radroots_events::kinds::KIND_COMMENT; const DEFAULT_KIND: u32 = KIND_COMMENT; -fn validate_ref( - event: &RadrootsNostrEventRef, - id_label: &'static str, - author_label: &'static str, -) -> Result<(), EventEncodeError> { - if event.id.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField(id_label)); - } - if event.author.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField(author_label)); - } - Ok(()) -} - pub fn comment_build_tags(comment: &RadrootsComment) -> Result<Vec<Vec<String>>, EventEncodeError> { - validate_ref(&comment.root, "root.id", "root.author")?; - validate_ref(&comment.parent, "parent.id", "parent.author")?; - - let root_has_addr = comment - .root - .d_tag - .as_deref() - .map_or(false, |v| !v.is_empty()); - let parent_has_addr = comment - .parent - .d_tag - .as_deref() - .map_or(false, |v| !v.is_empty()); - let mut tags = - Vec::with_capacity(6 + usize::from(root_has_addr) + usize::from(parent_has_addr)); - push_nip10_ref_tags(&mut tags, &comment.root, "E", "P", "K", "A"); - push_nip10_ref_tags(&mut tags, &comment.parent, "e", "p", "k", "a"); + let mut tags = Vec::with_capacity(8); + push_comment_target(&mut tags, &comment.root, CommentTargetTags::root())?; + push_comment_target(&mut tags, &comment.parent, CommentTargetTags::parent())?; Ok(tags) } @@ -63,3 +44,125 @@ pub fn to_wire_parts_with_kind( tags, }) } + +struct CommentTargetTags { + event: &'static str, + address: &'static str, + external: &'static str, + author: &'static str, + kind: &'static str, + field: &'static str, +} + +impl CommentTargetTags { + fn root() -> Self { + Self { + event: "E", + address: "A", + external: "I", + author: "P", + kind: "K", + field: "root", + } + } + + fn parent() -> Self { + Self { + event: "e", + address: "a", + external: "i", + author: "p", + kind: "k", + field: "parent", + } + } +} + +fn push_comment_target( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, + keys: CommentTargetTags, +) -> Result<(), EventEncodeError> { + match target { + RadrootsSocialTarget::Event { + id, + author, + event_kind, + relays, + } => { + validate_lowercase_hex_64(id, keys.field)?; + let author = author + .as_deref() + .ok_or(EventEncodeError::EmptyRequiredField(keys.field))?; + validate_non_empty_field(author, keys.field)?; + let kind = event_kind.ok_or(EventEncodeError::EmptyRequiredField(keys.field))?; + validate_comment_target_kind(kind, keys.field)?; + let mut event_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); + event_tag.push(keys.event.to_string()); + event_tag.push(id.clone()); + if let Some(relays) = relays { + event_tag.extend(relays.iter().cloned()); + } + tags.push(event_tag); + tags.push(vec![keys.author.to_string(), author.to_string()]); + tags.push(vec![keys.kind.to_string(), kind.to_string()]); + } + RadrootsSocialTarget::Address { + address, + author, + event_kind, + relays, + } => { + let parsed = parse_address_tag(address, keys.field) + .map_err(|_| EventEncodeError::InvalidField(keys.field))?; + validate_comment_target_kind(parsed.kind, keys.field)?; + if let Some(kind) = event_kind { + if *kind != parsed.kind { + return Err(EventEncodeError::InvalidField(keys.field)); + } + } + if let Some(author) = author.as_deref() { + if author != parsed.pubkey { + return Err(EventEncodeError::InvalidField(keys.field)); + } + } + let mut address_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); + address_tag.push(keys.address.to_string()); + address_tag.push(format!( + "{}:{}:{}", + parsed.kind, parsed.pubkey, parsed.d_tag + )); + if let Some(relays) = relays { + address_tag.extend(relays.iter().cloned()); + } + tags.push(address_tag); + tags.push(vec![keys.author.to_string(), parsed.pubkey]); + tags.push(vec![keys.kind.to_string(), parsed.kind.to_string()]); + } + RadrootsSocialTarget::External { + id, + external_kind, + hint, + } => { + validate_non_empty_field(id, keys.field)?; + validate_non_empty_field(external_kind, keys.field)?; + let mut external_tag = Vec::with_capacity(3); + external_tag.push(keys.external.to_string()); + external_tag.push(id.clone()); + if let Some(hint) = hint.as_deref().filter(|value| !value.trim().is_empty()) { + external_tag.push(hint.to_string()); + } + tags.push(external_tag); + tags.push(vec![keys.kind.to_string(), external_kind.clone()]); + } + } + Ok(()) +} + +fn validate_comment_target_kind(kind: u32, field: &'static str) -> Result<(), EventEncodeError> { + if kind == KIND_POST { + Err(EventEncodeError::InvalidField(field)) + } else { + Ok(()) + } +} diff --git a/crates/events_codec/src/farm/decode.rs b/crates/events_codec/src/farm/decode.rs @@ -45,6 +45,7 @@ pub fn farm_from_event( return Err(EventParseError::InvalidJson("content")); } let d_tag = parse_d_tag(tags)?; + reject_private_farm_ops_content(content)?; let mut farm: RadrootsFarm = serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; @@ -57,6 +58,34 @@ pub fn farm_from_event( Ok(farm) } +fn reject_private_farm_ops_content(content: &str) -> Result<(), EventParseError> { + let value: serde_json::Value = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + let Some(object) = value.as_object() else { + return Err(EventParseError::InvalidJson("content")); + }; + for key in [ + "workspace", + "farm_group_id", + "document_id", + "document_kind", + "crdt_backend", + "encoded_change", + "semantic_kind", + "owner_document_kind", + "owner_document_id", + "relays", + "media_servers", + "supported_kinds", + "protocol_version", + ] { + if object.contains_key(key) { + return Err(EventParseError::InvalidJson("content")); + } + } + Ok(()) +} + pub fn data_from_event( id: String, author: String, diff --git a/crates/events_codec/src/file_metadata/decode.rs b/crates/events_codec/src/file_metadata/decode.rs @@ -20,6 +20,7 @@ use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; use crate::social_helpers::{first_tag_value, parse_dimensions_tag}; const EXPECTED_KIND: &str = "1063"; +const TAG_RADROOTS_OWNER_DOCUMENT: &str = "radroots:owner_document"; pub fn file_metadata_from_event( kind: u32, @@ -32,6 +33,7 @@ pub fn file_metadata_from_event( got: kind, }); } + reject_private_farm_file_tags(tags)?; let url = required_tag_value(tags, TAG_URL)?; let mime_type = required_tag_value(tags, TAG_MIME)?; let sha256 = required_tag_value(tags, TAG_SHA256)?; @@ -71,6 +73,17 @@ pub fn file_metadata_from_event( }) } +fn reject_private_farm_file_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> { + if tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_OWNER_DOCUMENT)) + { + Err(EventParseError::InvalidTag(TAG_RADROOTS_OWNER_DOCUMENT)) + } else { + Ok(()) + } +} + pub fn data_from_event( id: String, author: String, diff --git a/crates/events_codec/src/post/decode.rs b/crates/events_codec/src/post/decode.rs @@ -4,10 +4,19 @@ use alloc::{ vec::Vec, }; -use radroots_events::{RadrootsNostrEvent, kinds::KIND_POST, post::RadrootsPost}; +use radroots_events::{ + RadrootsNostrEvent, + farm::RadrootsFarmRef, + kinds::{KIND_FARM, KIND_POST}, + post::RadrootsPost, + social::{RadrootsSocialFarmAnchor, RadrootsSocialMediaMetadata, RadrootsSocialTarget}, + tags::{TAG_A, TAG_IMETA, TAG_Q, TAG_T}, +}; use crate::error::EventParseError; +use crate::field_helpers::{parse_address_tag, tag_values, validate_lowercase_hex_64_tag}; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; +use crate::social_helpers::{location_from_tags, parse_dimensions_tag}; const DEFAULT_KIND: u32 = KIND_POST; @@ -32,15 +41,30 @@ pub fn post_from_content(kind: u32, content: &str) -> Result<RadrootsPost, Event }) } +pub fn post_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsPost, EventParseError> { + let mut post = post_from_content(kind, content)?; + post.farm = farm_anchor_from_tags(tags)?; + post.address_refs = address_refs_from_tags(tags)?; + post.location = location_from_tags(tags); + post.topics = non_empty_vec(tag_values(tags, TAG_T)?); + post.quote_refs = quote_refs_from_tags(tags)?; + post.media = media_from_tags(tags)?; + Ok(post) +} + pub fn data_from_event( id: String, author: String, published_at: u32, kind: u32, content: String, - _tags: Vec<Vec<String>>, + tags: Vec<Vec<String>>, ) -> Result<RadrootsParsedData<RadrootsPost>, EventParseError> { - let post = post_from_content(kind, &content)?; + let post = post_from_event(kind, &tags, &content)?; Ok(RadrootsParsedData::new( id, author, @@ -80,3 +104,169 @@ pub fn parsed_from_event( data, }) } + +fn farm_anchor_from_tags( + tags: &[Vec<String>], +) -> Result<Option<RadrootsSocialFarmAnchor>, EventParseError> { + for tag in tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) + { + let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_A))?; + let address = parse_address_tag(value, TAG_A)?; + if address.kind == KIND_FARM { + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + return Ok(Some(RadrootsSocialFarmAnchor { + farm: RadrootsFarmRef { + pubkey: address.pubkey, + d_tag: address.d_tag, + }, + relays, + })); + } + } + Ok(None) +} + +fn address_refs_from_tags( + tags: &[Vec<String>], +) -> Result<Option<Vec<RadrootsSocialTarget>>, EventParseError> { + let mut refs = Vec::new(); + for tag in tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) + { + let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_A))?; + let address = parse_address_tag(value, TAG_A)?; + if address.kind == KIND_FARM { + continue; + } + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + refs.push(RadrootsSocialTarget::Address { + address: value.clone(), + author: Some(address.pubkey), + event_kind: Some(address.kind), + relays, + }); + } + Ok(non_empty_vec(refs)) +} + +fn quote_refs_from_tags( + tags: &[Vec<String>], +) -> Result<Option<Vec<RadrootsSocialTarget>>, EventParseError> { + let mut refs = Vec::new(); + for tag in tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_Q)) + { + let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_Q))?; + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + match parse_address_tag(value, TAG_Q) { + Ok(address) => refs.push(RadrootsSocialTarget::Address { + address: value.clone(), + author: Some(address.pubkey), + event_kind: Some(address.kind), + relays, + }), + Err(_) => { + validate_lowercase_hex_64_tag(value, TAG_Q)?; + refs.push(RadrootsSocialTarget::Event { + id: value.clone(), + author: None, + event_kind: None, + relays, + }); + } + } + } + Ok(non_empty_vec(refs)) +} + +fn media_from_tags( + tags: &[Vec<String>], +) -> Result<Option<Vec<RadrootsSocialMediaMetadata>>, EventParseError> { + let mut media = Vec::new(); + for tag in tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_IMETA)) + { + if tag.len() < 2 { + return Err(EventParseError::InvalidTag(TAG_IMETA)); + } + let raw = tag[1..].to_vec(); + if raw.iter().any(|value| value.trim().is_empty()) { + return Err(EventParseError::InvalidTag(TAG_IMETA)); + } + let mut item = RadrootsSocialMediaMetadata { + imeta: Some(vec![raw.clone()]), + ..RadrootsSocialMediaMetadata::default() + }; + for entry in raw { + parse_imeta_entry(&mut item, &entry)?; + } + media.push(item); + } + Ok(non_empty_vec(media)) +} + +fn parse_imeta_entry( + item: &mut RadrootsSocialMediaMetadata, + entry: &str, +) -> Result<(), EventParseError> { + let Some((key, value)) = entry.split_once(' ') else { + return Err(EventParseError::InvalidTag(TAG_IMETA)); + }; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_IMETA)); + } + match key { + "url" => item.url = Some(value.to_string()), + "m" => item.mime_type = Some(value.to_string()), + "x" => item.sha256 = Some(value.to_string()), + "ox" => item.original_sha256 = Some(value.to_string()), + "size" => { + item.size = Some( + value + .parse::<u64>() + .map_err(|err| EventParseError::InvalidNumber(TAG_IMETA, err))?, + ); + } + "dim" => item.dimensions = Some(parse_dimensions_tag(value, TAG_IMETA)?), + "blurhash" => item.blurhash = Some(value.to_string()), + "image" => item.image = Some(value.to_string()), + "summary" => item.summary = Some(value.to_string()), + "alt" => item.alt = Some(value.to_string()), + "fallback" => item.fallback = Some(value.to_string()), + "magnet" => item.magnet = Some(value.to_string()), + "i" => push_repeated_value(&mut item.content_hashes, value), + "service" => push_repeated_value(&mut item.services, value), + "thumb" => {} + _ => {} + } + Ok(()) +} + +fn push_repeated_value(values: &mut Option<Vec<String>>, value: &str) { + values.get_or_insert_with(Vec::new).push(value.to_string()); +} + +fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> { + if values.is_empty() { + None + } else { + Some(values) + } +} diff --git a/crates/events_codec/src/post/encode.rs b/crates/events_codec/src/post/encode.rs @@ -1,14 +1,58 @@ #[cfg(not(feature = "std"))] -use alloc::vec::Vec; +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; -use radroots_events::post::RadrootsPost; +use radroots_events::{ + kinds::{KIND_FARM, KIND_POST}, + post::RadrootsPost, + social::{RadrootsSocialFarmAnchor, RadrootsSocialMediaMetadata, RadrootsSocialTarget}, + tags::{TAG_A, TAG_IMETA, TAG_Q, TAG_T}, +}; use crate::error::EventEncodeError; +use crate::field_helpers::{parse_address_tag, validate_lowercase_hex_64}; +use crate::social_helpers::{dimensions_tag, push_location_tags}; use crate::wire::WireEventParts; -use radroots_events::kinds::KIND_POST; const DEFAULT_KIND: u32 = KIND_POST; +pub fn post_build_tags(post: &RadrootsPost) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = Vec::new(); + if let Some(farm) = post.farm.as_ref() { + push_farm_anchor(&mut tags, farm)?; + } + if let Some(refs) = post.address_refs.as_ref() { + for target in refs { + push_address_ref(&mut tags, target)?; + } + } + if let Some(location) = post.location.as_ref() { + push_location_tags(&mut tags, location); + } + if let Some(topics) = post.topics.as_ref() { + for topic in topics { + if !topic.trim().is_empty() { + tags.push(vec![TAG_T.to_string(), topic.clone()]); + } + } + } + if let Some(quote_refs) = post.quote_refs.as_ref() { + for target in quote_refs { + push_quote_ref(&mut tags, target)?; + } + } + if let Some(media) = post.media.as_ref() { + for item in media { + push_media_tags(&mut tags, item)?; + } + } + Ok(tags) +} + pub fn to_wire_parts(post: &RadrootsPost) -> Result<WireEventParts, EventEncodeError> { to_wire_parts_with_kind(post, DEFAULT_KIND) } @@ -20,9 +64,188 @@ pub fn to_wire_parts_with_kind( if post.content.trim().is_empty() { return Err(EventEncodeError::EmptyRequiredField("content")); } + let tags = post_build_tags(post)?; Ok(WireEventParts { kind, content: post.content.clone(), - tags: Vec::new(), + tags, }) } + +fn push_farm_anchor( + tags: &mut Vec<Vec<String>>, + farm: &RadrootsSocialFarmAnchor, +) -> Result<(), EventEncodeError> { + if farm.farm.pubkey.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("farm.pubkey")); + } + if farm.farm.d_tag.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); + } + let address = format!("{}:{}:{}", KIND_FARM, farm.farm.pubkey, farm.farm.d_tag); + parse_address_tag(&address, "farm").map_err(|_| EventEncodeError::InvalidField("farm"))?; + let mut tag = Vec::with_capacity(2 + farm.relays.as_ref().map_or(0, Vec::len)); + tag.push(TAG_A.to_string()); + tag.push(address); + if let Some(relays) = farm.relays.as_ref() { + tag.extend(relays.iter().cloned()); + } + tags.push(tag); + Ok(()) +} + +fn push_address_ref( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, +) -> Result<(), EventEncodeError> { + let RadrootsSocialTarget::Address { + address, + author, + event_kind, + relays, + } = target + else { + return Err(EventEncodeError::InvalidField("address_refs")); + }; + let parsed = parse_address_tag(address, "address_refs") + .map_err(|_| EventEncodeError::InvalidField("address_refs"))?; + if parsed.kind == KIND_FARM { + return Err(EventEncodeError::InvalidField("address_refs")); + } + if let Some(kind) = event_kind { + if *kind != parsed.kind { + return Err(EventEncodeError::InvalidField("address_refs")); + } + } + if let Some(author) = author.as_deref() { + if author != parsed.pubkey { + return Err(EventEncodeError::InvalidField("address_refs")); + } + } + let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); + tag.push(TAG_A.to_string()); + tag.push(format!( + "{}:{}:{}", + parsed.kind, parsed.pubkey, parsed.d_tag + )); + if let Some(relays) = relays { + tag.extend(relays.iter().cloned()); + } + tags.push(tag); + Ok(()) +} + +fn push_quote_ref( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, +) -> Result<(), EventEncodeError> { + match target { + RadrootsSocialTarget::Event { id, relays, .. } => { + validate_lowercase_hex_64(id, "quote_refs")?; + let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); + tag.push(TAG_Q.to_string()); + tag.push(id.clone()); + if let Some(relays) = relays { + tag.extend(relays.iter().cloned()); + } + tags.push(tag); + Ok(()) + } + RadrootsSocialTarget::Address { + address, + event_kind, + relays, + .. + } => { + let parsed = parse_address_tag(address, "quote_refs") + .map_err(|_| EventEncodeError::InvalidField("quote_refs"))?; + if let Some(kind) = event_kind { + if *kind != parsed.kind { + return Err(EventEncodeError::InvalidField("quote_refs")); + } + } + let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); + tag.push(TAG_Q.to_string()); + tag.push(format!( + "{}:{}:{}", + parsed.kind, parsed.pubkey, parsed.d_tag + )); + if let Some(relays) = relays { + tag.extend(relays.iter().cloned()); + } + tags.push(tag); + Ok(()) + } + RadrootsSocialTarget::External { .. } => Err(EventEncodeError::InvalidField("quote_refs")), + } +} + +fn push_media_tags( + tags: &mut Vec<Vec<String>>, + media: &RadrootsSocialMediaMetadata, +) -> Result<(), EventEncodeError> { + if let Some(raw_tags) = media.imeta.as_ref() { + for raw in raw_tags { + if raw.is_empty() || raw.iter().any(|value| value.trim().is_empty()) { + return Err(EventEncodeError::InvalidField("imeta")); + } + let mut tag = Vec::with_capacity(1 + raw.len()); + tag.push(TAG_IMETA.to_string()); + tag.extend(raw.iter().cloned()); + tags.push(tag); + } + return Ok(()); + } + + let mut fields = Vec::new(); + push_imeta_field(&mut fields, "url", media.url.as_deref()); + push_imeta_field(&mut fields, "m", media.mime_type.as_deref()); + push_imeta_field(&mut fields, "x", media.sha256.as_deref()); + push_imeta_field(&mut fields, "ox", media.original_sha256.as_deref()); + if let Some(size) = media.size { + fields.push(format!("size {size}")); + } + if let Some(dimensions) = media.dimensions.as_ref() { + fields.push(format!("dim {}", dimensions_tag(dimensions))); + } + push_imeta_field(&mut fields, "blurhash", media.blurhash.as_deref()); + if let Some(thumbnails) = media.thumbnails.as_ref() { + for thumbnail in thumbnails { + if thumbnail.url.trim().is_empty() { + return Err(EventEncodeError::InvalidField("imeta")); + } + fields.push(format!("thumb {}", thumbnail.url)); + if let Some(dimensions) = thumbnail.dimensions.as_ref() { + fields.push(format!("dim {}", dimensions_tag(dimensions))); + } + } + } + push_imeta_field(&mut fields, "image", media.image.as_deref()); + push_imeta_field(&mut fields, "summary", media.summary.as_deref()); + push_imeta_field(&mut fields, "alt", media.alt.as_deref()); + push_imeta_field(&mut fields, "fallback", media.fallback.as_deref()); + push_imeta_field(&mut fields, "magnet", media.magnet.as_deref()); + if let Some(values) = media.content_hashes.as_ref() { + for value in values { + push_imeta_field(&mut fields, "i", Some(value.as_str())); + } + } + if let Some(values) = media.services.as_ref() { + for value in values { + push_imeta_field(&mut fields, "service", Some(value.as_str())); + } + } + if !fields.is_empty() { + let mut tag = Vec::with_capacity(1 + fields.len()); + tag.push(TAG_IMETA.to_string()); + tag.extend(fields); + tags.push(tag); + } + Ok(()) +} + +fn push_imeta_field(fields: &mut Vec<String>, key: &str, value: Option<&str>) { + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + fields.push(format!("{key} {value}")); + } +} diff --git a/crates/events_codec/src/reaction/decode.rs b/crates/events_codec/src/reaction/decode.rs @@ -5,11 +5,12 @@ use alloc::{ }; use radroots_events::{ - RadrootsNostrEvent, kinds::KIND_REACTION, reaction::RadrootsReaction, tags::TAG_E_ROOT, + RadrootsNostrEvent, kinds::KIND_REACTION, reaction::RadrootsReaction, + social::RadrootsSocialTarget, tags::TAG_E_ROOT, }; use crate::error::EventParseError; -use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag, parse_nip10_ref_tags}; +use crate::field_helpers::{parse_address_tag, validate_lowercase_hex_64_tag}; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; const DEFAULT_KIND: u32 = KIND_REACTION; @@ -25,18 +26,15 @@ pub fn reaction_from_tags( got: kind, }); } - if content.trim().is_empty() { - return Err(EventParseError::InvalidTag("content")); + if tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E_ROOT)) + { + return Err(EventParseError::InvalidTag(TAG_E_ROOT)); } - let root = if find_event_ref_tag(tags, "e").is_some() { - parse_nip10_ref_tags(tags, "e", "p", "k", "a")? - } else if let Some(root_tag) = find_event_ref_tag(tags, TAG_E_ROOT) { - parse_event_ref_tag(root_tag, TAG_E_ROOT)? - } else { - return Err(EventParseError::MissingTag("e")); - }; + let target = parse_reaction_target(tags)?; Ok(RadrootsReaction { - root, + target, content: content.to_string(), }) } @@ -89,3 +87,91 @@ pub fn parsed_from_event( data, }) } + +fn parse_reaction_target(tags: &[Vec<String>]) -> Result<RadrootsSocialTarget, EventParseError> { + let event_tag = find_tag(tags, "e"); + let address_tag = find_tag(tags, "a"); + match (event_tag, address_tag) { + (Some(_), Some(_)) => Err(EventParseError::InvalidTag("e")), + (None, None) => Err(EventParseError::MissingTag("e")), + (Some(tag), None) => { + let id = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag("e"))?; + validate_lowercase_hex_64_tag(&id, "e")?; + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + Ok(RadrootsSocialTarget::Event { + id, + author: optional_tag_value(tags, "p")?, + event_kind: optional_numeric_tag(tags, "k")?, + relays, + }) + } + (None, Some(tag)) => { + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag("a"))?; + let address = parse_address_tag(&value, "a")?; + let kind = optional_numeric_tag(tags, "k")?.unwrap_or(address.kind); + if kind != address.kind { + return Err(EventParseError::InvalidTag("k")); + } + let author = optional_tag_value(tags, "p")?.unwrap_or_else(|| address.pubkey.clone()); + if author != address.pubkey { + return Err(EventParseError::InvalidTag("p")); + } + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + Ok(RadrootsSocialTarget::Address { + address: value, + author: Some(author), + event_kind: Some(kind), + relays, + }) + } + } +} + +fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> { + tags.iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(key)) +} + +fn optional_tag_value( + tags: &[Vec<String>], + key: &'static str, +) -> Result<Option<String>, EventParseError> { + let Some(tag) = find_tag(tags, key) else { + return Ok(None); + }; + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(key))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(key)); + } + Ok(Some(value)) +} + +fn optional_numeric_tag( + tags: &[Vec<String>], + key: &'static str, +) -> Result<Option<u32>, EventParseError> { + optional_tag_value(tags, key)? + .map(|value| { + value + .parse::<u32>() + .map_err(|err| EventParseError::InvalidNumber(key, err)) + }) + .transpose() +} diff --git a/crates/events_codec/src/reaction/encode.rs b/crates/events_codec/src/reaction/encode.rs @@ -1,36 +1,27 @@ #[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; -use radroots_events::{RadrootsNostrEventRef, reaction::RadrootsReaction}; +use radroots_events::{ + kinds::KIND_REACTION, reaction::RadrootsReaction, social::RadrootsSocialTarget, +}; use crate::error::EventEncodeError; -use crate::event_ref::push_nip10_ref_tags; +use crate::field_helpers::{ + parse_address_tag, validate_lowercase_hex_64, validate_non_empty_field, +}; use crate::wire::WireEventParts; -use radroots_events::kinds::KIND_REACTION; const DEFAULT_KIND: u32 = KIND_REACTION; -fn validate_ref(event: &RadrootsNostrEventRef) -> Result<(), EventEncodeError> { - if event.id.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("root.id")); - } - if event.author.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("root.author")); - } - Ok(()) -} - pub fn reaction_build_tags( reaction: &RadrootsReaction, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - validate_ref(&reaction.root)?; - let has_addr = reaction - .root - .d_tag - .as_deref() - .map_or(false, |v| !v.is_empty()); - let mut tags = Vec::with_capacity(3 + usize::from(has_addr)); - push_nip10_ref_tags(&mut tags, &reaction.root, "e", "p", "k", "a"); + let mut tags = Vec::with_capacity(4); + push_reaction_target(&mut tags, &reaction.target)?; Ok(tags) } @@ -42,9 +33,6 @@ pub fn to_wire_parts_with_kind( reaction: &RadrootsReaction, kind: u32, ) -> Result<WireEventParts, EventEncodeError> { - if reaction.content.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("content")); - } let tags = reaction_build_tags(reaction)?; Ok(WireEventParts { kind, @@ -52,3 +40,68 @@ pub fn to_wire_parts_with_kind( tags, }) } + +fn push_reaction_target( + tags: &mut Vec<Vec<String>>, + target: &RadrootsSocialTarget, +) -> Result<(), EventEncodeError> { + match target { + RadrootsSocialTarget::Event { + id, + author, + event_kind, + relays, + } => { + validate_lowercase_hex_64(id, "target.id")?; + let mut event_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); + event_tag.push("e".to_string()); + event_tag.push(id.clone()); + if let Some(relays) = relays { + event_tag.extend(relays.iter().cloned()); + } + tags.push(event_tag); + if let Some(author) = author.as_deref() { + validate_non_empty_field(author, "target.author")?; + tags.push(vec!["p".to_string(), author.to_string()]); + } + if let Some(kind) = event_kind { + tags.push(vec!["k".to_string(), kind.to_string()]); + } + } + RadrootsSocialTarget::Address { + address, + author, + event_kind, + relays, + } => { + let parsed = parse_address_tag(address, "target.address") + .map_err(|_| EventEncodeError::InvalidField("target.address"))?; + if let Some(kind) = event_kind { + if *kind != parsed.kind { + return Err(EventEncodeError::InvalidField("target.kind")); + } + } + if let Some(author) = author.as_deref() { + if author != parsed.pubkey { + return Err(EventEncodeError::InvalidField("target.author")); + } + } + let mut address_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); + address_tag.push("a".to_string()); + address_tag.push(format!( + "{}:{}:{}", + parsed.kind, parsed.pubkey, parsed.d_tag + )); + if let Some(relays) = relays { + address_tag.extend(relays.iter().cloned()); + } + tags.push(address_tag); + tags.push(vec!["p".to_string(), parsed.pubkey]); + tags.push(vec!["k".to_string(), parsed.kind.to_string()]); + } + RadrootsSocialTarget::External { .. } => { + return Err(EventEncodeError::InvalidField("target")); + } + } + Ok(()) +} diff --git a/crates/events_codec/src/tag_builders.rs b/crates/events_codec/src/tag_builders.rs @@ -35,6 +35,7 @@ use crate::listing::tags::listing_tags; use crate::message::encode::message_build_tags; use crate::message_file::encode::message_file_build_tags; use crate::plot::encode::plot_build_tags; +use crate::post::encode::post_build_tags; use crate::reaction::encode::reaction_build_tags; use crate::resource_area::encode::resource_area_build_tags; use crate::resource_cap::encode::resource_harvest_cap_build_tags; @@ -225,9 +226,9 @@ impl RadrootsEventTagBuilder for RadrootsProfile { } impl RadrootsEventTagBuilder for RadrootsPost { - type Error = Infallible; + type Error = EventEncodeError; fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { - Ok(Vec::new()) + post_build_tags(self) } } diff --git a/crates/events_codec/tests/comment.rs b/crates/events_codec/tests/comment.rs @@ -1,65 +1,116 @@ -mod common; - -use radroots_events::tags::{TAG_E_PREV, TAG_E_ROOT}; use radroots_events::{ comment::RadrootsComment, - kinds::{KIND_COMMENT, KIND_POST}, + kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_POST}, + social::RadrootsSocialTarget, + tags::{TAG_E_PREV, TAG_E_ROOT}, +}; +use radroots_events_codec::comment::decode::{ + comment_from_tags, data_from_event, parsed_from_event, }; - -use radroots_events_codec::comment::decode::comment_from_tags; -use radroots_events_codec::comment::decode::{data_from_event, parsed_from_event}; use radroots_events_codec::comment::encode::{ comment_build_tags, to_wire_parts, to_wire_parts_with_kind, }; use radroots_events_codec::error::{EventEncodeError, EventParseError}; -use radroots_events_codec::event_ref::{build_event_ref_tag, push_nip10_ref_tags}; -fn assert_event_ref_fields( - actual: &radroots_events::RadrootsNostrEventRef, - expected: &radroots_events::RadrootsNostrEventRef, -) { - assert_eq!(actual.id, expected.id); - assert_eq!(actual.author, expected.author); - assert_eq!(actual.kind, expected.kind); - assert_eq!(actual.d_tag, expected.d_tag); - assert_eq!(actual.relays, expected.relays); +const ROOT_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const PARENT_ID: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const AUTHOR: &str = "author_pubkey"; +const PARENT_AUTHOR: &str = "parent_pubkey"; +const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + +fn event_target(id: &str, author: &str, kind: u32) -> RadrootsSocialTarget { + RadrootsSocialTarget::Event { + id: id.to_string(), + author: Some(author.to_string()), + event_kind: Some(kind), + relays: Some(vec!["wss://relay.example.test".to_string()]), + } +} + +fn address_target(author: &str, kind: u32, d_tag: &str) -> RadrootsSocialTarget { + RadrootsSocialTarget::Address { + address: format!("{kind}:{author}:{d_tag}"), + author: Some(author.to_string()), + event_kind: Some(kind), + relays: Some(vec!["wss://relay2.example.test".to_string()]), + } +} + +fn external_target(id: &str, kind: &str) -> RadrootsSocialTarget { + RadrootsSocialTarget::External { + id: id.to_string(), + external_kind: kind.to_string(), + hint: Some("https://example.test/object".to_string()), + } +} + +fn assert_event_target(target: &RadrootsSocialTarget, id: &str, author: &str, kind: u32) { + match target { + RadrootsSocialTarget::Event { + id: actual_id, + author: actual_author, + event_kind, + relays, + } => { + assert_eq!(actual_id, id); + assert_eq!(actual_author.as_deref(), Some(author)); + assert_eq!(*event_kind, Some(kind)); + assert_eq!(relays.as_ref().map(Vec::len), Some(1)); + } + _ => panic!("expected event target"), + } +} + +fn assert_address_target(target: &RadrootsSocialTarget, author: &str, kind: u32, d_tag: &str) { + match target { + RadrootsSocialTarget::Address { + address, + author: actual_author, + event_kind, + relays, + } => { + assert_eq!(address, &format!("{kind}:{author}:{d_tag}")); + assert_eq!(actual_author.as_deref(), Some(author)); + assert_eq!(*event_kind, Some(kind)); + assert_eq!(relays.as_ref().map(Vec::len), Some(1)); + } + _ => panic!("expected address target"), + } } #[test] -fn comment_build_tags_requires_root_id() { +fn comment_build_tags_requires_strict_nip22_target_fields() { let comment = RadrootsComment { - root: common::event_ref("", "author", KIND_POST), - parent: common::event_ref("parent", "author", KIND_POST), + root: RadrootsSocialTarget::Event { + id: "not-hex".to_string(), + author: Some(AUTHOR.to_string()), + event_kind: Some(KIND_ARTICLE), + relays: None, + }, + parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE), content: "hello".to_string(), }; - - let err = comment_build_tags(&comment).unwrap_err(); assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("root.id") + comment_build_tags(&comment), + Err(EventEncodeError::InvalidField("root")) )); -} -#[test] -fn comment_build_tags_requires_parent_author() { let comment = RadrootsComment { - root: common::event_ref("root", "author", KIND_POST), - parent: common::event_ref("parent", "", KIND_POST), + root: event_target(ROOT_ID, AUTHOR, KIND_POST), + parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE), content: "hello".to_string(), }; - - let err = comment_build_tags(&comment).unwrap_err(); assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("parent.author") + comment_build_tags(&comment), + Err(EventEncodeError::InvalidField("root")) )); } #[test] fn comment_to_wire_parts_requires_content() { let comment = RadrootsComment { - root: common::event_ref("root", "author", KIND_POST), - parent: common::event_ref("parent", "author", KIND_POST), + root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE), + parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE), content: " ".to_string(), }; @@ -68,266 +119,172 @@ fn comment_to_wire_parts_requires_content() { err, EventEncodeError::EmptyRequiredField("content") )); - - let comment = RadrootsComment { - root: common::event_ref("", "author", KIND_POST), - parent: common::event_ref("parent", "author", KIND_POST), - content: "hello".to_string(), - }; - let err = to_wire_parts(&comment).unwrap_err(); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("root.id") - )); } #[test] -fn comment_to_wire_parts_sets_kind_content_and_tags() { +fn comment_roundtrips_event_and_address_targets() { let comment = RadrootsComment { - root: common::event_ref("root", "author", KIND_POST), - parent: common::event_ref("parent", "author", KIND_POST), + root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE), + parent: address_target(PARENT_AUTHOR, KIND_ARTICLE, D_TAG), content: "hello".to_string(), }; let parts = to_wire_parts(&comment).unwrap(); + assert_eq!(parts.kind, KIND_COMMENT); - assert_eq!(parts.content, "hello"); - assert_eq!(parts.tags.len(), 6); + assert!(parts.tags.iter().any(|tag| tag[0] == "E")); + assert!(parts.tags.iter().any(|tag| tag[0] == "P")); + assert!(parts.tags.iter().any(|tag| tag[0] == "K")); + assert!(parts.tags.iter().any(|tag| tag[0] == "a")); + assert!(parts.tags.iter().any(|tag| tag[0] == "p")); + assert!(parts.tags.iter().any(|tag| tag[0] == "k")); + + let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_event_target(&parsed.root, ROOT_ID, AUTHOR, KIND_ARTICLE); + assert_address_target(&parsed.parent, PARENT_AUTHOR, KIND_ARTICLE, D_TAG); + assert_eq!(parsed.content, "hello"); let custom_parts = to_wire_parts_with_kind(&comment, KIND_POST).unwrap(); assert_eq!(custom_parts.kind, KIND_POST); - assert_eq!(custom_parts.content, "hello"); - assert_eq!(custom_parts.tags.len(), 6); } #[test] -fn comment_build_tags_includes_address_tags_when_refs_have_d_tag() { +fn comment_roundtrips_external_targets() { let comment = RadrootsComment { - root: common::event_ref_with_d( - "root", - "author", - KIND_POST, - "root-d", - Some(vec!["wss://relay".to_string()]), - ), - parent: common::event_ref_with_d( - "parent", - "author", - KIND_POST, - "parent-d", - Some(vec!["wss://relay-2".to_string()]), - ), - content: "hello".to_string(), + root: external_target("https://example.test/root", "web"), + parent: external_target("https://example.test/parent", "web"), + content: "external comment".to_string(), }; - let tags = comment_build_tags(&comment).unwrap(); - assert_eq!(tags.len(), 8); - assert!(tags.iter().any(|tag| tag[0] == "A")); - assert!(tags.iter().any(|tag| tag[0] == "a")); -} - -#[test] -fn comment_roundtrip_from_tags_with_parent() { - let root = common::event_ref_with_d( - "root", - "author", - KIND_POST, - "root-d", - Some(vec!["wss://relay".to_string()]), - ); - let parent = common::event_ref_with_d( - "parent", - "author", - KIND_POST, - "parent-d", - Some(vec!["wss://relay-2".to_string()]), - ); - - let mut tags = Vec::new(); - push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A"); - push_nip10_ref_tags(&mut tags, &parent, "e", "p", "k", "a"); - - let comment = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap(); - - assert_event_ref_fields(&comment.root, &root); - assert_event_ref_fields(&comment.parent, &parent); - assert_eq!(comment.content, "hello"); -} - -#[test] -fn comment_from_tags_defaults_parent_to_root() { - let root = common::event_ref("root", "author", KIND_POST); - let mut tags = Vec::new(); - push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A"); - - let comment = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap(); - - assert_event_ref_fields(&comment.root, &root); - assert_event_ref_fields(&comment.parent, &root); -} - -#[test] -fn comment_roundtrip_from_legacy_tags() { - let root = common::event_ref("root", "author", KIND_POST); - let parent = common::event_ref("parent", "author", KIND_POST); - - let tags = vec![ - build_event_ref_tag(TAG_E_ROOT, &root), - build_event_ref_tag(TAG_E_PREV, &parent), - ]; - - let comment = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap(); - - assert_event_ref_fields(&comment.root, &root); - assert_event_ref_fields(&comment.parent, &parent); -} - -#[test] -fn comment_from_tags_requires_root_tag() { - let tags = vec![vec!["p".to_string(), "x".to_string()]]; - - let err = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap_err(); - assert!(matches!(err, EventParseError::MissingTag("E"))); + let parts = to_wire_parts(&comment).unwrap(); + let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap(); + + match parsed.root { + RadrootsSocialTarget::External { + id, + external_kind, + hint, + } => { + assert_eq!(id, "https://example.test/root"); + assert_eq!(external_kind, "web"); + assert_eq!(hint.as_deref(), Some("https://example.test/object")); + } + _ => panic!("expected external root"), + } + match parsed.parent { + RadrootsSocialTarget::External { id, .. } => { + assert_eq!(id, "https://example.test/parent"); + } + _ => panic!("expected external parent"), + } } #[test] -fn comment_from_tags_propagates_root_and_parent_reference_parse_errors() { - let err = comment_from_tags( - KIND_COMMENT, - &[ - vec!["E".to_string()], - vec!["P".to_string(), "author".to_string()], - vec!["K".to_string(), KIND_POST.to_string()], - ], - "hello", - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("E"))); +fn comment_from_tags_rejects_legacy_missing_and_kind1_shapes() { + let legacy_tags = vec![vec![ + TAG_E_ROOT.to_string(), + ROOT_ID.to_string(), + AUTHOR.to_string(), + KIND_ARTICLE.to_string(), + ]]; + assert!(matches!( + comment_from_tags(KIND_COMMENT, &legacy_tags, "hello"), + Err(EventParseError::InvalidTag(TAG_E_ROOT)) + )); - let err = comment_from_tags( - KIND_COMMENT, - &[ - vec!["e".to_string()], - vec!["p".to_string(), "author".to_string()], - vec!["k".to_string(), KIND_POST.to_string()], - build_event_ref_tag(TAG_E_ROOT, &common::event_ref("root", "author", KIND_POST)), + let legacy_parent_tags = vec![ + vec!["E".to_string(), ROOT_ID.to_string()], + vec!["P".to_string(), AUTHOR.to_string()], + vec!["K".to_string(), KIND_ARTICLE.to_string()], + vec![ + TAG_E_PREV.to_string(), + PARENT_ID.to_string(), + PARENT_AUTHOR.to_string(), + KIND_ARTICLE.to_string(), ], - "hello", - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("e"))); + ]; + assert!(matches!( + comment_from_tags(KIND_COMMENT, &legacy_parent_tags, "hello"), + Err(EventParseError::InvalidTag(TAG_E_PREV)) + )); - let err = comment_from_tags( - KIND_COMMENT, - &[vec![TAG_E_ROOT.to_string(), "root".to_string()]], - "hello", - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("e_root"))); + let missing_parent_tags = vec![ + vec!["E".to_string(), ROOT_ID.to_string()], + vec!["P".to_string(), AUTHOR.to_string()], + vec!["K".to_string(), KIND_ARTICLE.to_string()], + ]; + assert!(matches!( + comment_from_tags(KIND_COMMENT, &missing_parent_tags, "hello"), + Err(EventParseError::MissingTag("e")) + )); - let err = comment_from_tags( - KIND_COMMENT, - &[ - build_event_ref_tag(TAG_E_ROOT, &common::event_ref("root", "author", KIND_POST)), - vec![TAG_E_PREV.to_string(), "parent".to_string()], - ], - "hello", - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("e_prev"))); + let kind1_tags = vec![ + vec!["E".to_string(), ROOT_ID.to_string()], + vec!["P".to_string(), AUTHOR.to_string()], + vec!["K".to_string(), KIND_POST.to_string()], + vec!["e".to_string(), PARENT_ID.to_string()], + vec!["p".to_string(), PARENT_AUTHOR.to_string()], + vec!["k".to_string(), KIND_ARTICLE.to_string()], + ]; + assert!(matches!( + comment_from_tags(KIND_COMMENT, &kind1_tags, "hello"), + Err(EventParseError::InvalidTag("K")) + )); } #[test] -fn comment_from_tags_rejects_empty_content() { - let root = common::event_ref("root", "author", KIND_POST); - let mut tags = Vec::new(); - push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A"); - - let err = comment_from_tags(KIND_COMMENT, &tags, " ").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("content"))); -} +fn comment_from_tags_rejects_empty_content_and_wrong_kind() { + let tags = comment_build_tags(&RadrootsComment { + root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE), + parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE), + content: "hello".to_string(), + }) + .unwrap(); -#[test] -fn comment_from_tags_rejects_wrong_kind() { - let tags = vec![vec!["e".to_string(), "x".to_string()]]; - let err = comment_from_tags(KIND_POST, &tags, "hello").unwrap_err(); assert!(matches!( - err, - EventParseError::InvalidKind { + comment_from_tags(KIND_COMMENT, &tags, " "), + Err(EventParseError::InvalidTag("content")) + )); + assert!(matches!( + comment_from_tags(KIND_POST, &tags, "hello"), + Err(EventParseError::InvalidKind { expected: "1111", got: KIND_POST - } + }) )); } #[test] fn comment_metadata_and_index_from_event_roundtrip() { - let root = common::event_ref_with_d( - "root", - "author", - KIND_POST, - "root-d", - Some(vec!["wss://relay".to_string()]), - ); - let parent = common::event_ref_with_d( - "parent", - "author", - KIND_POST, - "parent-d", - Some(vec!["wss://relay-2".to_string()]), - ); - - let mut tags = Vec::new(); - push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A"); - push_nip10_ref_tags(&mut tags, &parent, "e", "p", "k", "a"); + let parts = to_wire_parts(&RadrootsComment { + root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE), + parent: address_target(PARENT_AUTHOR, KIND_ARTICLE, D_TAG), + content: "hello".to_string(), + }) + .unwrap(); let metadata = data_from_event( "id".to_string(), "author".to_string(), 77, KIND_COMMENT, - "hello".to_string(), - tags.clone(), + parts.content.clone(), + parts.tags.clone(), ) .unwrap(); assert_eq!(metadata.id, "id"); - assert_eq!(metadata.author, "author"); assert_eq!(metadata.published_at, 77); - assert_eq!(metadata.data.content, "hello"); - assert_event_ref_fields(&metadata.data.root, &root); - assert_event_ref_fields(&metadata.data.parent, &parent); + assert_event_target(&metadata.data.root, ROOT_ID, AUTHOR, KIND_ARTICLE); let index = parsed_from_event( "id".to_string(), "author".to_string(), 77, KIND_COMMENT, - "hello".to_string(), - tags, + parts.content, + parts.tags, "sig".to_string(), ) .unwrap(); assert_eq!(index.event.created_at, 77); - assert_eq!(index.event.kind, KIND_COMMENT); assert_eq!(index.event.sig, "sig"); - assert_eq!(index.data.data.content, "hello"); -} - -#[test] -fn comment_index_from_event_propagates_parse_errors() { - let err = parsed_from_event( - "id".to_string(), - "author".to_string(), - 77, - KIND_POST, - "hello".to_string(), - Vec::new(), - "sig".to_string(), - ) - .unwrap_err(); - assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "1111", - got: KIND_POST - } - )); + assert_address_target(&index.data.data.parent, PARENT_AUTHOR, KIND_ARTICLE, D_TAG); } diff --git a/crates/events_codec/tests/post.rs b/crates/events_codec/tests/post.rs @@ -1,10 +1,22 @@ use radroots_events::{ + farm::RadrootsFarmRef, kinds::{KIND_COMMENT, KIND_POST}, post::RadrootsPost, + social::{ + RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaMetadata, + RadrootsSocialTarget, + }, + tags::{TAG_A, TAG_G, TAG_IMETA, TAG_LOCATION, TAG_Q, TAG_T}, }; use radroots_events_codec::error::{EventEncodeError, EventParseError}; -use radroots_events_codec::post::decode::{data_from_event, parsed_from_event, post_from_content}; -use radroots_events_codec::post::encode::to_wire_parts; +use radroots_events_codec::post::decode::{ + data_from_event, parsed_from_event, post_from_content, post_from_event, +}; +use radroots_events_codec::post::encode::{post_build_tags, to_wire_parts}; + +const QUOTE_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; +const ARTICLE_D_TAG: &str = "BBBBBBBBBBBBBBBBBBBBBA"; #[test] fn post_to_wire_parts_requires_content() { @@ -44,6 +56,162 @@ fn post_to_wire_parts_sets_kind_and_content() { } #[test] +fn post_to_wire_parts_roundtrips_optional_social_tags() { + let post = RadrootsPost { + content: "field update".to_string(), + farm: Some(RadrootsSocialFarmAnchor { + farm: RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: FARM_D_TAG.to_string(), + }, + relays: Some(vec!["wss://farm-relay.example.test".to_string()]), + }), + address_refs: Some(vec![RadrootsSocialTarget::Address { + address: format!("30023:article_author:{ARTICLE_D_TAG}"), + author: Some("article_author".to_string()), + event_kind: Some(30023), + relays: Some(vec!["wss://article-relay.example.test".to_string()]), + }]), + location: Some(RadrootsSocialLocation { + name: Some("North field".to_string()), + geohash: Some("c23nb62w20st".to_string()), + }), + topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]), + quote_refs: Some(vec![ + RadrootsSocialTarget::Event { + id: QUOTE_ID.to_string(), + author: None, + event_kind: None, + relays: Some(vec!["wss://quote-relay.example.test".to_string()]), + }, + RadrootsSocialTarget::Address { + address: format!("30023:quote_author:{ARTICLE_D_TAG}"), + author: Some("quote_author".to_string()), + event_kind: Some(30023), + relays: None, + }, + ]), + media: Some(vec![RadrootsSocialMediaMetadata { + imeta: Some(vec![vec![ + "url https://media.example.test/field.jpg".to_string(), + "m image/jpeg".to_string(), + format!("x {QUOTE_ID}"), + "dim 1200x800".to_string(), + "alt Field rows".to_string(), + "service https://media.example.test".to_string(), + ]]), + ..RadrootsSocialMediaMetadata::default() + }]), + }; + + let parts = to_wire_parts(&post).unwrap(); + assert_eq!(parts.kind, KIND_POST); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_A) + && tag.get(1).map(|value| value.as_str()) + == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA") + })); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_A) + && tag.get(1).map(|value| value.as_str()) + == Some("30023:article_author:BBBBBBBBBBBBBBBBBBBBBA") + })); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION) + && tag.get(1).map(|value| value.as_str()) == Some("North field") + })); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_G) + && tag.get(1).map(|value| value.as_str()) == Some("c23nb62w20st") + })); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_T) + && tag.get(1).map(|value| value.as_str()) == Some("soil") + })); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_Q) + && tag.get(1).map(|value| value.as_str()) == Some(QUOTE_ID) + })); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some(TAG_IMETA) + && tag + .iter() + .any(|value| value == "url https://media.example.test/field.jpg") + })); + + let decoded = post_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.content, "field update"); + assert_eq!( + decoded.farm.as_ref().map(|farm| farm.farm.pubkey.as_str()), + Some("farm_pubkey") + ); + assert_eq!(decoded.address_refs.as_ref().map(Vec::len), Some(1)); + assert_eq!( + decoded + .location + .as_ref() + .and_then(|location| location.name.as_deref()), + Some("North field") + ); + assert_eq!(decoded.topics.as_ref().map(Vec::len), Some(2)); + assert_eq!(decoded.quote_refs.as_ref().map(Vec::len), Some(2)); + let media = decoded.media.as_ref().expect("media"); + assert_eq!( + media[0].url.as_deref(), + Some("https://media.example.test/field.jpg") + ); + assert_eq!(media[0].mime_type.as_deref(), Some("image/jpeg")); + assert_eq!( + media[0].dimensions.as_ref().map(|value| value.width), + Some(1200) + ); + assert_eq!(media[0].alt.as_deref(), Some("Field rows")); + assert_eq!(media[0].services.as_ref().map(Vec::len), Some(1)); +} + +#[test] +fn post_social_tags_reject_malformed_supported_structures() { + let mut post = RadrootsPost { + content: "field update".to_string(), + farm: None, + address_refs: Some(vec![RadrootsSocialTarget::Event { + id: QUOTE_ID.to_string(), + author: None, + event_kind: None, + relays: None, + }]), + location: None, + topics: None, + quote_refs: None, + media: None, + }; + assert!(matches!( + post_build_tags(&post), + Err(EventEncodeError::InvalidField("address_refs")) + )); + + post.address_refs = None; + post.quote_refs = Some(vec![RadrootsSocialTarget::Event { + id: "not-hex".to_string(), + author: None, + event_kind: None, + relays: None, + }]); + assert!(matches!( + post_build_tags(&post), + Err(EventEncodeError::InvalidField("quote_refs")) + )); + + let err = post_from_event( + KIND_POST, + &[vec![TAG_IMETA.to_string(), "bad-imeta-entry".to_string()]], + "hello", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_IMETA))); +} + +#[test] fn post_from_content_requires_kind_and_content() { let err = post_from_content(KIND_COMMENT, "hello").unwrap_err(); assert!(matches!( diff --git a/crates/events_codec/tests/reaction.rs b/crates/events_codec/tests/reaction.rs @@ -1,13 +1,10 @@ -mod common; - -use radroots_events::tags::TAG_E_ROOT; use radroots_events::{ - kinds::{KIND_POST, KIND_REACTION}, + kinds::{KIND_ARTICLE, KIND_POST, KIND_REACTION}, reaction::RadrootsReaction, + social::RadrootsSocialTarget, + tags::TAG_E_ROOT, }; - use radroots_events_codec::error::{EventEncodeError, EventParseError}; -use radroots_events_codec::event_ref::{build_event_ref_tag, push_nip10_ref_tags}; use radroots_events_codec::reaction::decode::{ data_from_event, parsed_from_event, reaction_from_tags, }; @@ -15,259 +12,244 @@ use radroots_events_codec::reaction::encode::{ reaction_build_tags, to_wire_parts, to_wire_parts_with_kind, }; -#[test] -fn reaction_build_tags_requires_root_fields() { - let reaction = RadrootsReaction { - root: common::event_ref("", "author", KIND_POST), - content: "like".to_string(), - }; +const EVENT_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const AUTHOR: &str = "author_pubkey"; +const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + +fn event_target() -> RadrootsSocialTarget { + RadrootsSocialTarget::Event { + id: EVENT_ID.to_string(), + author: Some(AUTHOR.to_string()), + event_kind: Some(KIND_ARTICLE), + relays: Some(vec!["wss://relay.example.test".to_string()]), + } +} - let err = reaction_build_tags(&reaction).unwrap_err(); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("root.id") - )); +fn address_target() -> RadrootsSocialTarget { + RadrootsSocialTarget::Address { + address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE), + author: Some(AUTHOR.to_string()), + event_kind: Some(KIND_ARTICLE), + relays: Some(vec!["wss://relay2.example.test".to_string()]), + } } -#[test] -fn reaction_build_tags_requires_root_author() { - let reaction = RadrootsReaction { - root: common::event_ref("root", "", KIND_POST), - content: "like".to_string(), - }; +fn assert_event_target(target: &RadrootsSocialTarget) { + match target { + RadrootsSocialTarget::Event { + id, + author, + event_kind, + relays, + } => { + assert_eq!(id, EVENT_ID); + assert_eq!(author.as_deref(), Some(AUTHOR)); + assert_eq!(*event_kind, Some(KIND_ARTICLE)); + assert_eq!(relays.as_ref().map(Vec::len), Some(1)); + } + _ => panic!("expected event target"), + } +} - let err = reaction_build_tags(&reaction).unwrap_err(); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("root.author") - )); +fn assert_address_target(target: &RadrootsSocialTarget) { + match target { + RadrootsSocialTarget::Address { + address, + author, + event_kind, + relays, + } => { + assert_eq!(address, &format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE)); + assert_eq!(author.as_deref(), Some(AUTHOR)); + assert_eq!(*event_kind, Some(KIND_ARTICLE)); + assert_eq!(relays.as_ref().map(Vec::len), Some(1)); + } + _ => panic!("expected address target"), + } } #[test] -fn reaction_to_wire_parts_requires_content() { +fn reaction_build_tags_requires_valid_event_or_address_target() { let reaction = RadrootsReaction { - root: common::event_ref("root", "author", KIND_POST), - content: " ".to_string(), + target: RadrootsSocialTarget::Event { + id: "not-hex".to_string(), + author: Some(AUTHOR.to_string()), + event_kind: Some(KIND_ARTICLE), + relays: None, + }, + content: "+".to_string(), }; - - let err = to_wire_parts(&reaction).unwrap_err(); assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("content") + reaction_build_tags(&reaction), + Err(EventEncodeError::InvalidField("target.id")) )); let reaction = RadrootsReaction { - root: common::event_ref("", "author", KIND_POST), + target: RadrootsSocialTarget::External { + id: "https://example.test".to_string(), + external_kind: "web".to_string(), + hint: None, + }, content: "+".to_string(), }; - let err = to_wire_parts(&reaction).unwrap_err(); assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("root.id") + reaction_build_tags(&reaction), + Err(EventEncodeError::InvalidField("target")) )); } #[test] +fn reaction_to_wire_parts_accepts_empty_plus_minus_emoji_and_custom_content() { + for content in ["", "+", "-", "🔥", "harvest"] { + let reaction = RadrootsReaction { + target: event_target(), + content: content.to_string(), + }; + let parts = to_wire_parts(&reaction).unwrap(); + assert_eq!(parts.kind, KIND_REACTION); + assert_eq!(parts.content, content); + assert!(parts.tags.iter().any(|tag| tag[0] == "e")); + } +} + +#[test] fn reaction_to_wire_parts_with_kind_keeps_requested_kind() { let reaction = RadrootsReaction { - root: common::event_ref("root", "author", KIND_POST), + target: event_target(), content: "+".to_string(), }; let parts = to_wire_parts_with_kind(&reaction, KIND_POST).unwrap(); assert_eq!(parts.kind, KIND_POST); assert_eq!(parts.content, "+"); - assert_eq!(parts.tags.len(), 3); - - let default_parts = to_wire_parts(&reaction).unwrap(); - assert_eq!(default_parts.kind, KIND_REACTION); } #[test] -fn reaction_from_tags_requires_root_tag() { - let tags = vec![vec!["p".to_string(), "x".to_string()]]; - let err = reaction_from_tags(KIND_REACTION, &tags, "+").unwrap_err(); - assert!(matches!(err, EventParseError::MissingTag("e"))); +fn reaction_roundtrips_event_target() { + let reaction = RadrootsReaction { + target: event_target(), + content: "+".to_string(), + }; + let parts = to_wire_parts(&reaction).unwrap(); + let parsed = reaction_from_tags(parts.kind, &parts.tags, &parts.content).unwrap(); + + assert_event_target(&parsed.target); + assert_eq!(parsed.content, "+"); } #[test] -fn reaction_from_tags_propagates_reference_parse_errors() { - let err = reaction_from_tags( - KIND_REACTION, - &[ - vec!["e".to_string()], - vec!["p".to_string(), "author".to_string()], - vec!["k".to_string(), KIND_POST.to_string()], - ], - "+", - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("e"))); +fn reaction_roundtrips_address_target() { + let reaction = RadrootsReaction { + target: address_target(), + content: "".to_string(), + }; + let parts = to_wire_parts(&reaction).unwrap(); + let parsed = reaction_from_tags(parts.kind, &parts.tags, &parts.content).unwrap(); - let err = reaction_from_tags( - KIND_REACTION, - &[vec![TAG_E_ROOT.to_string(), "root".to_string()]], - "+", - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("e_root"))); + assert_address_target(&parsed.target); + assert_eq!(parsed.content, ""); } #[test] -fn reaction_from_tags_rejects_invalid_kind_and_content() { - let root = common::event_ref("root", "author", KIND_POST); - let mut tags = Vec::new(); - push_nip10_ref_tags(&mut tags, &root, "e", "p", "k", "a"); - - let err = reaction_from_tags(KIND_POST, &tags, "+").unwrap_err(); +fn reaction_from_tags_rejects_missing_legacy_and_mismatched_targets() { assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "7", - got: KIND_POST - } + reaction_from_tags( + KIND_REACTION, + &[vec!["p".to_string(), AUTHOR.to_string()]], + "+" + ), + Err(EventParseError::MissingTag("e")) )); - let err = reaction_from_tags(KIND_REACTION, &tags, " ").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("content"))); -} - -#[test] -fn reaction_roundtrip_from_tags() { - let root = common::event_ref_with_d( - "root", - "author", - KIND_POST, - "note-1", - Some(vec!["wss://relay".to_string()]), - ); - let mut tags = Vec::new(); - push_nip10_ref_tags(&mut tags, &root, "e", "p", "k", "a"); - - let reaction = reaction_from_tags(KIND_REACTION, &tags, "+").unwrap(); + assert!(matches!( + reaction_from_tags( + KIND_REACTION, + &[vec![TAG_E_ROOT.to_string(), EVENT_ID.to_string()]], + "+" + ), + Err(EventParseError::InvalidTag(TAG_E_ROOT)) + )); - assert_eq!(reaction.root.id, root.id); - assert_eq!(reaction.root.author, root.author); - assert_eq!(reaction.root.kind, root.kind); - assert_eq!(reaction.root.d_tag, root.d_tag); - assert_eq!(reaction.root.relays, root.relays); - assert_eq!(reaction.content, "+"); -} + assert!(matches!( + reaction_from_tags( + KIND_REACTION, + &[ + vec!["e".to_string(), EVENT_ID.to_string()], + vec![ + "a".to_string(), + format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE) + ] + ], + "+" + ), + Err(EventParseError::InvalidTag("e")) + )); -#[test] -fn reaction_build_tags_includes_address_tag_when_root_has_d_tag() { - let reaction = RadrootsReaction { - root: common::event_ref_with_d( - "root", - "author", - KIND_POST, - "note-1", - Some(vec!["wss://relay".to_string()]), + assert!(matches!( + reaction_from_tags( + KIND_REACTION, + &[ + vec![ + "a".to_string(), + format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE) + ], + vec!["p".to_string(), "other_author".to_string()] + ], + "+" ), - content: "+".to_string(), - }; - let tags = reaction_build_tags(&reaction).unwrap(); - assert_eq!(tags.len(), 4); - assert_eq!(tags[3][0], "a"); + Err(EventParseError::InvalidTag("p")) + )); } #[test] -fn reaction_roundtrip_from_legacy_tags() { - let root = common::event_ref("root", "author", KIND_POST); - let tags = vec![build_event_ref_tag(TAG_E_ROOT, &root)]; - - let reaction = reaction_from_tags(KIND_REACTION, &tags, "+").unwrap(); +fn reaction_from_tags_rejects_invalid_kind() { + let tags = reaction_build_tags(&RadrootsReaction { + target: event_target(), + content: "+".to_string(), + }) + .unwrap(); - assert_eq!(reaction.root.id, root.id); - assert_eq!(reaction.root.author, root.author); - assert_eq!(reaction.root.kind, root.kind); - assert_eq!(reaction.content, "+"); + assert!(matches!( + reaction_from_tags(KIND_POST, &tags, "+"), + Err(EventParseError::InvalidKind { + expected: "7", + got: KIND_POST + }) + )); } #[test] fn reaction_metadata_and_index_from_event_roundtrip() { - let root = common::event_ref_with_d( - "root", - "author", - KIND_POST, - "note-1", - Some(vec!["wss://relay".to_string()]), - ); - let mut tags = Vec::new(); - push_nip10_ref_tags(&mut tags, &root, "e", "p", "k", "a"); + let parts = to_wire_parts(&RadrootsReaction { + target: event_target(), + content: "".to_string(), + }) + .unwrap(); let metadata = data_from_event( "id".to_string(), "author".to_string(), 99, KIND_REACTION, - "+".to_string(), - tags.clone(), + parts.content.clone(), + parts.tags.clone(), ) .unwrap(); assert_eq!(metadata.id, "id"); - assert_eq!(metadata.author, "author"); - assert_eq!(metadata.published_at, 99); assert_eq!(metadata.kind, KIND_REACTION); - assert_eq!(metadata.data.content, "+"); - assert_eq!(metadata.data.root.id, root.id); + assert_event_target(&metadata.data.target); + assert_eq!(metadata.data.content, ""); let index = parsed_from_event( "id".to_string(), "author".to_string(), 99, KIND_REACTION, - "+".to_string(), - tags, + parts.content, + parts.tags, "sig".to_string(), ) .unwrap(); assert_eq!(index.event.kind, KIND_REACTION); assert_eq!(index.event.sig, "sig"); - assert_eq!(index.data.data.content, "+"); -} - -#[test] -fn reaction_metadata_and_index_propagate_parse_errors() { - let tags = vec![vec!["e".to_string(), "root".to_string()]]; - let err = data_from_event( - "id".to_string(), - "author".to_string(), - 99, - KIND_POST, - "+".to_string(), - tags.clone(), - ) - .unwrap_err(); - assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "7", - got: KIND_POST - } - )); - - let err = parsed_from_event( - "id".to_string(), - "author".to_string(), - 99, - KIND_REACTION, - " ".to_string(), - tags, - "sig".to_string(), - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("content"))); -} - -#[test] -fn reaction_build_tags_supports_root_without_d_tag() { - let reaction = RadrootsReaction { - root: common::event_ref("root", "author", KIND_POST), - content: "+".to_string(), - }; - let tags = reaction_build_tags(&reaction).unwrap(); - assert_eq!(tags.len(), 3); - assert_eq!(tags[0][0], "e"); - assert_eq!(tags[1][0], "p"); - assert_eq!(tags[2][0], "k"); + assert_event_target(&index.data.data.target); } diff --git a/crates/events_codec/tests/social_events.rs b/crates/events_codec/tests/social_events.rs @@ -0,0 +1,175 @@ +#![cfg(feature = "serde_json")] + +use radroots_events::{ + farm_crdt::RadrootsFarmCrdtDocumentKind, + farm_file::{RadrootsFarmFileDimensions, RadrootsFarmFileMetadata}, + farm_workspace::RadrootsFarmWorkspaceRef, + file_metadata::RadrootsFileMetadata, + group::{RadrootsGroupEditableMetadata, RadrootsGroupMetadata}, + kinds::{ + KIND_ARTICLE, KIND_FARM, KIND_FARM_CRDT_CHANGE, KIND_GROUP_METADATA, KIND_LISTING, + KIND_POST, KIND_PUBLIC_FILE_METADATA, is_public_social_kind, + }, + social::RadrootsSocialMediaDimensions, +}; +use radroots_events_codec::{ + article::decode::article_from_event, + error::EventParseError, + farm::decode::farm_from_event, + farm_file::{ + decode::farm_file_metadata_from_event, encode::to_wire_parts as farm_file_to_wire_parts, + }, + file_metadata::{ + decode::file_metadata_from_event, encode::to_wire_parts as public_file_to_wire_parts, + }, + group::{decode::group_metadata_from_event, encode::group_metadata_to_wire_parts}, + listing::decode::listing_from_event, + post::decode::post_from_event, +}; + +const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const OTHER_SHA256: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + +#[test] +fn social_events_keep_public_and_private_file_metadata_apis_separate() { + let public = public_file_to_wire_parts(&public_file_metadata()).unwrap(); + let decoded_public = + file_metadata_from_event(public.kind, &public.tags, &public.content).unwrap(); + assert_eq!(decoded_public.url, "https://media.example.test/public.jpg"); + assert!(matches!( + farm_file_metadata_from_event(public.kind, &public.tags, &public.content), + Err(EventParseError::MissingTag("d")) + )); + + let private = farm_file_to_wire_parts(&private_farm_file_metadata()).unwrap(); + let decoded_private = + farm_file_metadata_from_event(private.kind, &private.tags, &private.content).unwrap(); + assert_eq!(decoded_private.owner_document_id, D_TAG); + assert!(matches!( + file_metadata_from_event(private.kind, &private.tags, &private.content), + Err(EventParseError::InvalidTag("radroots:owner_document")) + )); +} + +#[test] +fn social_events_reject_private_farm_ops_semantics_in_public_codecs() { + let private_content = serde_json::json!({ + "workspace": { + "pubkey": "workspace_pubkey", + "d_tag": D_TAG + }, + "farm_group_id": "field-group", + "document_id": D_TAG, + "document_kind": "FarmTask", + "encoded_change": "abc-DEF_012" + }) + .to_string(); + let farm_tags = vec![vec!["d".to_string(), D_TAG.to_string()]]; + + assert!(matches!( + farm_from_event(KIND_FARM, &farm_tags, &private_content), + Err(EventParseError::InvalidJson("content")) + )); + assert!(matches!( + post_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"), + Err(EventParseError::InvalidKind { + expected: "1", + got: KIND_FARM_CRDT_CHANGE + }) + )); + assert!(matches!( + article_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"), + Err(EventParseError::InvalidKind { + expected: "30023", + got: KIND_FARM_CRDT_CHANGE + }) + )); + assert!(matches!( + listing_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"), + Err(EventParseError::InvalidKind { + expected: "30402 or 30403", + got: KIND_FARM_CRDT_CHANGE + }) + )); + assert!(is_public_social_kind(KIND_POST)); + assert!(is_public_social_kind(KIND_ARTICLE)); + assert!(is_public_social_kind(KIND_PUBLIC_FILE_METADATA)); + assert!(!is_public_social_kind(KIND_FARM_CRDT_CHANGE)); + assert!(!is_public_social_kind(KIND_LISTING)); +} + +#[test] +fn social_events_keep_nip29_groups_out_of_public_social_classification() { + let group = RadrootsGroupMetadata { + d_tag: "field-group".to_string(), + metadata: RadrootsGroupEditableMetadata { + name: Some("Field Group".to_string()), + about: Some("Localhost field coordination".to_string()), + picture: None, + is_private: false, + is_restricted: false, + is_closed: false, + is_hidden: false, + supported_kinds: Some(vec![KIND_FARM_CRDT_CHANGE]), + }, + }; + let parts = group_metadata_to_wire_parts(&group).unwrap(); + assert_eq!(parts.kind, KIND_GROUP_METADATA); + assert!(!is_public_social_kind(KIND_GROUP_METADATA)); + assert_eq!( + group_metadata_from_event(parts.kind, &parts.tags, &parts.content) + .unwrap() + .metadata + .supported_kinds, + Some(vec![KIND_FARM_CRDT_CHANGE]) + ); +} + +fn public_file_metadata() -> RadrootsFileMetadata { + RadrootsFileMetadata { + url: "https://media.example.test/public.jpg".to_string(), + mime_type: "image/jpeg".to_string(), + sha256: SHA256.to_string(), + original_sha256: Some(OTHER_SHA256.to_string()), + size: Some(4096), + dimensions: Some(RadrootsSocialMediaDimensions { + width: 1200, + height: 800, + }), + blurhash: None, + thumbnails: None, + summary: Some("Public field photo".to_string()), + alt: Some("Rows after harvest".to_string()), + fallback: None, + magnet: None, + content_hashes: None, + services: None, + content: Some("caption".to_string()), + } +} + +fn private_farm_file_metadata() -> RadrootsFarmFileMetadata { + RadrootsFarmFileMetadata { + d_tag: D_TAG.to_string(), + workspace: RadrootsFarmWorkspaceRef { + pubkey: "workspace_pubkey".to_string(), + d_tag: D_TAG.to_string(), + }, + farm_group_id: "field-group".to_string(), + owner_document_id: D_TAG.to_string(), + owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, + caption: Some("private caption".to_string()), + url: "https://media.example.test/private.jpg".to_string(), + mime_type: "image/jpeg".to_string(), + sha256: SHA256.to_string(), + original_sha256: Some(OTHER_SHA256.to_string()), + size_bytes: Some(4096), + dimensions: Some(RadrootsFarmFileDimensions { w: 1200, h: 800 }), + blurhash: None, + thumb: None, + image: None, + alt: Some("Private farm task attachment".to_string()), + fallbacks: Vec::new(), + } +} diff --git a/crates/events_codec/tests/tag_builders.rs b/crates/events_codec/tests/tag_builders.rs @@ -7,7 +7,6 @@ use radroots_core::{ RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_events::RadrootsNostrEventPtr; -use radroots_events::RadrootsNostrEventRef; use radroots_events::app_data::RadrootsAppData; use radroots_events::comment::RadrootsComment; use radroots_events::coop::RadrootsCoop; @@ -24,7 +23,7 @@ use radroots_events::job_feedback::RadrootsJobFeedback; use radroots_events::job_request::{RadrootsJobInput, RadrootsJobParam, RadrootsJobRequest}; use radroots_events::job_result::RadrootsJobResult; use radroots_events::kinds::{ - KIND_JOB_FEEDBACK, KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN, KIND_POST, + KIND_ARTICLE, KIND_JOB_FEEDBACK, KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN, }; use radroots_events::list::{RadrootsList, RadrootsListEntry}; use radroots_events::list_set::RadrootsListSet; @@ -44,6 +43,7 @@ use radroots_events::resource_area::{ }; use radroots_events::resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct}; use radroots_events::seal::RadrootsSeal; +use radroots_events::social::RadrootsSocialTarget; use radroots_events_codec::error::EventEncodeError; use radroots_events_codec::job::encode::JobEncodeError; use radroots_events_codec::listing::encode::listing_build_tags; @@ -60,12 +60,11 @@ fn cdn_url(path: &str) -> String { format!("{CDN_PRIMARY_HTTPS}/{path}") } -fn sample_event_ref(id: &str) -> RadrootsNostrEventRef { - RadrootsNostrEventRef { +fn sample_social_target(id: &str) -> RadrootsSocialTarget { + RadrootsSocialTarget::Event { id: id.to_string(), - author: TEST_PUBKEY_HEX.to_string(), - kind: KIND_POST, - d_tag: None, + author: Some(TEST_PUBKEY_HEX.to_string()), + event_kind: Some(KIND_ARTICLE), relays: None, } } @@ -166,14 +165,20 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() { assert!(!app_data.build_tags().unwrap().is_empty()); let comment = RadrootsComment { - root: sample_event_ref("root"), - parent: sample_event_ref("parent"), + root: sample_social_target( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + parent: sample_social_target( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ), content: "hello".to_string(), }; assert!(!comment.build_tags().unwrap().is_empty()); let reaction = RadrootsReaction { - root: sample_event_ref("root"), + target: sample_social_target( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), content: "+".to_string(), }; assert!(!reaction.build_tags().unwrap().is_empty());