diff --git a/crates/nostr-relay-builder/src/local/inner.rs b/crates/nostr-relay-builder/src/local/inner.rs index 4b8de7061..864ff725f 100644 --- a/crates/nostr-relay-builder/src/local/inner.rs +++ b/crates/nostr-relay-builder/src/local/inner.rs @@ -541,7 +541,7 @@ impl InnerLocalRelay { // Check mode if let LocalRelayBuilderMode::PublicKey(pk) = self.mode { let authored: bool = event.pubkey == pk; - let tagged: bool = event.tags.public_keys().any(|p| p == &pk); + let tagged: bool = event.tags.public_keys().any(|p| p == pk); if !authored && !tagged { return send_msg( @@ -965,7 +965,7 @@ impl InnerLocalRelay { let now = Timestamp::now(); // Add events json_msgs.extend(events.into_iter().filter_map(|event| { - if event.is_expired_at(&now) { + if event.is_expired_at(now) { return None; } Some( diff --git a/crates/nostr-relay-builder/src/local/session.rs b/crates/nostr-relay-builder/src/local/session.rs index f33df9a7c..e8efd1856 100644 --- a/crates/nostr-relay-builder/src/local/session.rs +++ b/crates/nostr-relay-builder/src/local/session.rs @@ -46,7 +46,7 @@ impl Nip42Session { match event.tags.challenge() { Some(challenge) => { // Tried to remove challenge but wasn't in the set: return false. - if !self.challenges.remove(challenge) { + if !self.challenges.remove(&challenge) { return Err(String::from("received invalid challenge")); } diff --git a/crates/nostr/CHANGELOG.md b/crates/nostr/CHANGELOG.md index c34b08ce2..1bc930180 100644 --- a/crates/nostr/CHANGELOG.md +++ b/crates/nostr/CHANGELOG.md @@ -41,6 +41,8 @@ - Remove `EventBuilder::pow` in favor of `UnsignedEvent::mine` and `UnsignedEvent::mine_async` (https://github.com/rust-nostr/nostr/pull/1334) - Replace `kinds: Option>` in `FeeSchedule` with `kinds: Option>` in `RelayInformationDocument` (https://github.com/rust-nostr/nostr/pull/1336) - Drop support for NIP-96 +- Remove TagStandard enum in favor of per-NIP tag enums (https://github.com/rust-nostr/nostr/pull/1347) +- Remove TagKind (https://github.com/rust-nostr/nostr/pull/1347) ### Changed diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index f227948f4..b62e5eb2e 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -19,6 +19,7 @@ use rand::{CryptoRng, RngCore}; use secp256k1::{Secp256k1, Signing, Verification}; use serde_json::{Value, json}; +use crate::nips::nip58::Nip58Tag; use crate::nips::nip62::VanishTarget; use crate::prelude::*; @@ -254,7 +255,7 @@ impl EventBuilder { if !self.allow_self_tagging { let public_key_hex: String = public_key.to_hex(); self.tags.retain(|t| { - if t.kind() == TagKind::p() { + if t.kind() == "p" { if let Some(content) = t.content() { if content == public_key_hex { return false; // Remove `p` tag that match author @@ -370,9 +371,13 @@ impl EventBuilder { where I: IntoIterator)>, { - let tags = iter - .into_iter() - .map(|(url, metadata)| Tag::relay_metadata(url, metadata)); + let tags = iter.into_iter().map(|(relay_url, metadata)| { + Nip65Tag::RelayMetadata { + relay_url, + metadata, + } + .to_tag() + }); Self::new(Kind::RelayList, "").tags(tags) } @@ -407,7 +412,7 @@ impl EventBuilder { content: S, reply_to: &Event, root: Option<&Event>, - relay_url: Option, + relay_hint: Option, ) -> Self where S: Into, @@ -418,35 +423,41 @@ impl EventBuilder { Some(root) => { // Check if root event is different from reply event if root.id != reply_to.id { - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { - event_id: reply_to.id, - relay_url: relay_url.clone(), - marker: Some(Marker::Reply), - public_key: Some(reply_to.pubkey), - uppercase: false, - })); + tags.push( + Nip10Tag::Event { + id: reply_to.id, + relay_hint: relay_hint.clone(), + marker: Some(Marker::Reply), + public_key: Some(reply_to.pubkey), + } + .to_tag(), + ); tags.push(Tag::public_key(reply_to.pubkey)); } // ID and author - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { - event_id: root.id, - relay_url, - marker: Some(Marker::Root), - public_key: Some(root.pubkey), - uppercase: false, - })); + tags.push( + Nip10Tag::Event { + id: root.id, + relay_hint, + marker: Some(Marker::Root), + public_key: Some(root.pubkey), + } + .to_tag(), + ); tags.push(Tag::public_key(root.pubkey)); } // No root tag, add only reply_to tags None => { - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { - event_id: reply_to.id, - relay_url, - marker: Some(Marker::Reply), - public_key: Some(reply_to.pubkey), - uppercase: false, - })); + tags.push( + Nip10Tag::Event { + id: reply_to.id, + relay_hint, + marker: Some(Marker::Reply), + public_key: Some(reply_to.pubkey), + } + .to_tag(), + ); tags.push(Tag::public_key(reply_to.pubkey)); } } @@ -481,11 +492,11 @@ impl EventBuilder { /// let event_id = EventId::from_hex("b3e392b11f5d4f28321cedd09303a748acfd0487aea5a7450b3481c60b6e4f87").unwrap(); /// let content: &str = "Lorem [ipsum][4] dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at #[3]."; /// let tags = &[ - /// Tag::identifier("lorem-ipsum".to_string()), - /// Tag::from_standardized(TagStandard::Title("Lorem Ipsum".to_string())), - /// Tag::from_standardized(TagStandard::PublishedAt(Timestamp::from(1296962229))), - /// Tag::hashtag("placeholder".to_string()), - /// Tag::event(event_id), + /// Nip01Tag::Identifier("lorem-ipsum".to_string()).to_tag(), + /// Nip23Tag::Title("Lorem Ipsum".to_string()).to_tag(), + /// Nip23Tag::PublishedAt(Timestamp::from(1296962229)).to_tag(), + /// Nip23Tag::Hashtag("placeholder".to_string()).to_tag(), + /// Nip01Tag::Event { id: event_id, relay_hint: None, public_key: None }.to_tag(), /// ]; /// let builder = EventBuilder::long_form_text_note("My first text note from rust-nostr!"); /// ``` @@ -505,12 +516,12 @@ impl EventBuilder { I: IntoIterator, { let tags = contacts.into_iter().map(|contact| { - Tag::from_standardized_without_cell(TagStandard::PublicKey { + Nip02Tag::PublicKey { public_key: contact.public_key, - relay_url: contact.relay_url, + relay_hint: contact.relay_url, alias: contact.alias, - uppercase: false, - }) + } + .to_tag() }); Self::new(Kind::ContactList, "").tags(tags) } @@ -519,19 +530,16 @@ impl EventBuilder { /// /// #[cfg(feature = "nip03")] - pub fn opentimestamps(event_id: EventId, relay_url: Option) -> Result { + pub fn opentimestamps(event_id: EventId, relay_hint: Option) -> Result { let ots: String = nostr_ots::timestamp_event(&event_id.to_hex())?; - Ok( - Self::new(Kind::OpenTimestamps, ots).tags([Tag::from_standardized_without_cell( - TagStandard::Event { - event_id, - relay_url, - marker: None, - public_key: None, - uppercase: false, - }, - )]), - ) + Ok(Self::new(Kind::OpenTimestamps, ots).tag( + Nip01Tag::Event { + id: event_id, + relay_hint, + public_key: None, + } + .to_tag(), + )) } /// Repost @@ -547,37 +555,36 @@ impl EventBuilder { if event.kind == Kind::TextNote { Self::new(Kind::Repost, content).tags([ - Tag::from_standardized_without_cell(TagStandard::Event { - event_id: event.id, - relay_url, - marker: None, - // NOTE: not add public key since it's already included as `p` tag - public_key: None, - uppercase: false, - }), - Tag::public_key(event.pubkey), + Nip18Tag::Event { + id: event.id, + relay_hint: relay_url, + } + .to_tag(), + Nip18Tag::PublicKey { + public_key: event.pubkey, + relay_hint: None, + } + .to_tag(), ]) } else { Self::new(Kind::GenericRepost, content) .tag_maybe( event .coordinate() - .map(|c| Tag::coordinate(c.into_owned(), relay_url.clone())), + .map(|c| Tag::coordinate(c, relay_url.clone())), ) .tags([ - Tag::from_standardized_without_cell(TagStandard::Event { - event_id: event.id, - relay_url, - marker: None, - // NOTE: not add public key since it's already included as `p` tag - public_key: None, - uppercase: false, - }), - Tag::public_key(event.pubkey), - Tag::from_standardized_without_cell(TagStandard::Kind { - kind: event.kind, - uppercase: false, - }), + Nip18Tag::Event { + id: event.id, + relay_hint: relay_url, + } + .to_tag(), + Nip18Tag::PublicKey { + public_key: event.pubkey, + relay_hint: None, + } + .to_tag(), + Nip18Tag::Kind(event.kind).to_tag(), ]) } } @@ -609,7 +616,7 @@ impl EventBuilder { match target { VanishTarget::AllRelays => { - builder = builder.tag(Tag::all_relays()); + builder = builder.tag(Nip62Tag::AllRelays.to_tag()); } VanishTarget::Relays(list) => { // Check if the list is empty @@ -618,7 +625,7 @@ impl EventBuilder { return Err(Error::EmptyTags); } - builder = builder.tags(list.into_iter().map(Tag::relay)); + builder = builder.tags(list.into_iter().map(Nip62Tag::Relay).map(Into::into)); } } @@ -687,15 +694,15 @@ impl EventBuilder { relay_url: Option, metadata: &Metadata, ) -> Self { - Self::new(Kind::ChannelMetadata, metadata.as_json()).tags([ - Tag::from_standardized_without_cell(TagStandard::Event { - event_id: channel_id, - relay_url, + Self::new(Kind::ChannelMetadata, metadata.as_json()).tag( + Nip10Tag::Event { + id: channel_id, + relay_hint: relay_url, marker: None, public_key: None, - uppercase: false, - }), - ]) + } + .to_tag(), + ) } /// Channel message @@ -706,15 +713,15 @@ impl EventBuilder { where S: Into, { - Self::new(Kind::ChannelMessage, content).tags([Tag::from_standardized_without_cell( - TagStandard::Event { - event_id: channel_id, - relay_url: Some(relay_url), + Self::new(Kind::ChannelMessage, content).tag( + Nip10Tag::Event { + id: channel_id, + relay_hint: Some(relay_url), marker: Some(Marker::Root), public_key: None, - uppercase: false, - }, - )]) + } + .to_tag(), + ) } /// Hide message @@ -756,8 +763,8 @@ impl EventBuilder { S: Into, { Self::new(Kind::Authentication, "").tags([ - Tag::from_standardized_without_cell(TagStandard::Challenge(challenge.into())), - Tag::from_standardized_without_cell(TagStandard::Relay(relay)), + Nip42Tag::Challenge(challenge.into()).into(), + Nip42Tag::Relay(relay).into(), ]) } @@ -804,19 +811,19 @@ impl EventBuilder { live_event_id: S, live_event_host: PublicKey, content: S, - relay_url: Option, + relay_hint: Option, ) -> Self where S: Into, { - Self::new(Kind::LiveEventMessage, content).tag(Tag::from_standardized_without_cell( - TagStandard::Coordinate { + Self::new(Kind::LiveEventMessage, content).tag( + Nip01Tag::Coordinate { coordinate: Coordinate::new(Kind::LiveEvent, live_event_host) .identifier(live_event_id), - relay_url, - uppercase: false, - }, - )) + relay_hint, + } + .to_tag(), + ) } /// Reporting @@ -878,74 +885,32 @@ impl EventBuilder { S2: Into, { let mut tags: Vec = vec![ - Tag::from_standardized_without_cell(TagStandard::Bolt11(bolt11.into())), - Tag::from_standardized_without_cell(TagStandard::Description(zap_request.as_json())), + Nip57Tag::Bolt11(bolt11.into()).to_tag(), + Nip57Tag::Description(zap_request.as_json()).to_tag(), ]; // add preimage tag if provided if let Some(pre_image_tag) = preimage { - tags.push(Tag::from_standardized_without_cell(TagStandard::Preimage( - pre_image_tag.into(), - ))) + tags.push(Nip57Tag::Preimage(pre_image_tag.into()).to_tag()) } // add e tag - if let Some(tag) = zap_request - .tags - .iter() - .find(|t| { - t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::E, - uppercase: false, - }) - }) - .cloned() - { + if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "e").cloned() { tags.push(tag); } // add a tag - if let Some(tag) = zap_request - .tags - .iter() - .find(|t| { - t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::A, - uppercase: false, - }) - }) - .cloned() - { + if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "a").cloned() { tags.push(tag); } // add p tag - if let Some(tag) = zap_request - .tags - .iter() - .find(|t| { - t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::P, - uppercase: false, - }) - }) - .cloned() - { + if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "p").cloned() { tags.push(tag); } // add P tag - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { - public_key: zap_request.pubkey, - relay_url: None, - alias: None, - uppercase: true, - }, - )); + tags.push(Nip57Tag::Sender(zap_request.pubkey).to_tag()); Self::new(Kind::ZapReceipt, "").tags(tags) } @@ -985,28 +950,24 @@ impl EventBuilder { let mut tags: Vec = Vec::new(); // Set identifier tag - tags.push(Tag::identifier(badge_id.into())); + tags.push(Nip58Tag::Identifier(badge_id.into()).to_tag()); // Set name tag if let Some(name) = name { - tags.push(Tag::from_standardized_without_cell(TagStandard::Name( - name.into(), - ))); + tags.push(Nip58Tag::Name(name.into()).to_tag()); } // Set description tag if let Some(description) = description { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Description(description.into()), - )); + tags.push(Nip58Tag::Description(description.into()).to_tag()); } // Set image tag if let Some(image) = image { let image_tag = if let Some(dimensions) = image_dimensions { - Tag::from_standardized_without_cell(TagStandard::Image(image, Some(dimensions))) + Nip58Tag::Image(image, Some(dimensions)).to_tag() } else { - Tag::from_standardized_without_cell(TagStandard::Image(image, None)) + Nip58Tag::Image(image, None).to_tag() }; tags.push(image_tag); } @@ -1014,9 +975,9 @@ impl EventBuilder { // Set thumbnail tags for (thumb, dimensions) in thumbnails.into_iter() { let thumb_tag = if let Some(dimensions) = dimensions { - Tag::from_standardized_without_cell(TagStandard::Thumb(thumb, Some(dimensions))) + Nip58Tag::Thumb(thumb, Some(dimensions)).to_tag() } else { - Tag::from_standardized_without_cell(TagStandard::Thumb(thumb, None)) + Nip58Tag::Thumb(thumb, None).to_tag() }; tags.push(thumb_tag); } @@ -1034,8 +995,8 @@ impl EventBuilder { let badge_id = badge_definition .tags .iter() - .find_map(|t| match t.as_standardized() { - Some(TagStandard::Identifier(id)) => Some(id), + .find_map(|t| match t.try_into() { + Ok(Nip01Tag::Identifier(id)) => Some(id), _ => None, }) .ok_or(Error::NIP58(nip58::Error::IdentifierTagNotFound))?; @@ -1044,14 +1005,14 @@ impl EventBuilder { let mut tags = Vec::with_capacity(1); // Add identity tag - tags.push(Tag::from_standardized_without_cell( - TagStandard::Coordinate { + tags.push( + Nip01Tag::Coordinate { coordinate: Coordinate::new(Kind::BadgeDefinition, badge_definition.pubkey) .identifier(badge_id), - relay_url: None, - uppercase: false, - }, - )); + relay_hint: None, + } + .to_tag(), + ); // Add awarded public keys tags.extend(awarded_public_keys.into_iter().map(Tag::public_key)); @@ -1078,8 +1039,8 @@ impl EventBuilder { } for award in badge_awards.iter() { - if !award.tags.iter().any(|t| match t.as_standardized() { - Some(TagStandard::PublicKey { public_key, .. }) => public_key == pubkey_awarded, + if !award.tags.iter().any(|t| match Nip01Tag::try_from(t) { + Ok(Nip01Tag::PublicKey { public_key, .. }) => public_key == *pubkey_awarded, _ => false, }) { return Err(Error::NIP58(nip58::Error::BadgeAwardsLackAwardedPublicKey)); @@ -1092,24 +1053,23 @@ impl EventBuilder { return Err(Error::NIP58(nip58::Error::InvalidKind)); } - // Add identifier `d` tag - let id_tag: Tag = Tag::identifier("profile_badges"); - let mut tags: Vec = vec![id_tag]; + let mut tags: Vec = Vec::new(); let badge_definitions_identifiers = badge_definitions.iter().filter_map(|event| { - let id: &str = event.tags.identifier()?; + let id: String = event.tags.identifier()?; Some((event, id)) }); let badge_awards_identifiers = badge_awards.iter().filter_map(|event| { let (_, relay_url) = - nip58::extract_awarded_public_key(event.tags.as_slice(), pubkey_awarded)?; - let (id, a_tag) = event.tags.iter().find_map(|t| match t.as_standardized() { - Some(TagStandard::Coordinate { coordinate, .. }) => { - Some((&coordinate.identifier, t)) - } - _ => None, - })?; + nip58::extract_awarded_public_key(event.tags.as_slice(), *pubkey_awarded)?; + let (id, a_tag) = event + .tags + .iter() + .find_map(|t| match Nip01Tag::try_from(t) { + Ok(Nip01Tag::Coordinate { coordinate, .. }) => Some((coordinate.identifier, t)), + _ => None, + })?; Some((event, id, a_tag, relay_url)) }); @@ -1124,14 +1084,12 @@ impl EventBuilder { ((_, identifier), (badge_award_event, badge_id, a_tag, relay_url)) if badge_id == identifier => { - let badge_award_event_tag: Tag = - Tag::from_standardized_without_cell(TagStandard::Event { - event_id: badge_award_event.id, - relay_url: relay_url.clone(), - marker: None, - public_key: None, - uppercase: false, - }); + let badge_award_event_tag: Tag = Nip01Tag::Event { + id: badge_award_event.id, + relay_hint: relay_url.clone(), + public_key: None, + } + .into(); tags.extend_from_slice(&[a_tag.clone(), badge_award_event_tag]); } _ => {} @@ -1181,12 +1139,7 @@ impl EventBuilder { .tags .iter() .filter_map(|t| { - if t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::I, - uppercase: false, - }) - { + if t.kind() == "i" { Some(t.clone()) } else { None @@ -1197,8 +1150,8 @@ impl EventBuilder { tags.extend_from_slice(&[ Tag::event(job_request.id), Tag::public_key(job_request.pubkey), - Tag::from_standardized_without_cell(TagStandard::Request(job_request)), - Tag::from_standardized_without_cell(TagStandard::Amount { millisats, bolt11 }), + Nip90Tag::Request(job_request).to_tag(), + Nip90Tag::Amount { millisats, bolt11 }.to_tag(), ]); Ok(Self::new(kind, payload).tags(tags)) @@ -1212,18 +1165,22 @@ impl EventBuilder { tags.push(Tag::event(data.job_request_id)); tags.push(Tag::public_key(data.customer_public_key)); - tags.push(Tag::from_standardized_without_cell( - TagStandard::DataVendingMachineStatus { + tags.push( + Nip90Tag::Status { status: data.status, extra_info: data.extra_info, - }, - )); + } + .to_tag(), + ); if let Some(millisats) = data.amount_msat { - tags.push(Tag::from_standardized_without_cell(TagStandard::Amount { - millisats, - bolt11: data.bolt11, - })); + tags.push( + Nip90Tag::Amount { + millisats, + bolt11: data.bolt11, + } + .to_tag(), + ); } Self::new(Kind::JobFeedback, data.payload.unwrap_or_default()).tags(tags) @@ -1390,7 +1347,7 @@ impl EventBuilder { where I: IntoIterator, { - Self::new(Kind::InboxRelays, "").tags(urls.into_iter().map(Tag::relay)) + Self::new(Kind::InboxRelays, "").tags(urls.into_iter().map(Nip17Tag::Relay).map(Into::into)) } /// Private Direct message rumor @@ -1495,11 +1452,8 @@ impl EventBuilder { where I: IntoIterator, { - Self::new(Kind::BlossomServerList, "").tags( - servers - .into_iter() - .map(|s| Tag::from_standardized_without_cell(TagStandard::Server(s))), - ) + Self::new(Kind::BlossomServerList, "") + .tags(servers.into_iter().map(|s| NipB7Tag::Server(s).to_tag())) } /// Communities @@ -1532,11 +1486,8 @@ impl EventBuilder { where I: IntoIterator, { - Self::new(Kind::BlockedRelays, "").tags( - relay - .into_iter() - .map(|r| Tag::from_standardized_without_cell(TagStandard::Relay(r))), - ) + Self::new(Kind::BlockedRelays, "") + .tags(relay.into_iter().map(|r| Nip51Tag::Relay(r).to_tag())) } /// Search relays @@ -1547,11 +1498,8 @@ impl EventBuilder { where I: IntoIterator, { - Self::new(Kind::SearchRelays, "").tags( - relay - .into_iter() - .map(|r| Tag::from_standardized_without_cell(TagStandard::Relay(r))), - ) + Self::new(Kind::SearchRelays, "") + .tags(relay.into_iter().map(|r| Nip51Tag::Relay(r).to_tag())) } /// Interests @@ -1597,11 +1545,8 @@ impl EventBuilder { { let tags: Vec = vec![Tag::identifier(identifier)]; Self::new(Kind::RelaySet, "").tags( - tags.into_iter().chain( - relays - .into_iter() - .map(|r| Tag::from_standardized_without_cell(TagStandard::Relay(r))), - ), + tags.into_iter() + .chain(relays.into_iter().map(|r| Nip51Tag::Relay(r).to_tag())), ) } @@ -1670,8 +1615,13 @@ impl EventBuilder { { let tags: Vec = vec![Tag::identifier(identifier)]; Self::new(Kind::EmojiSet, "").tags(tags.into_iter().chain(emojis.into_iter().map( - |(s, url)| { - Tag::from_standardized_without_cell(TagStandard::Emoji { shortcode: s, url }) + |(shortcode, image_url)| { + Nip30Tag::Emoji { + shortcode, + image_url, + emoji_set: None, + } + .to_tag() }, ))) } @@ -1687,11 +1637,12 @@ impl EventBuilder { let namespace: String = namespace.into(); let label: String = label.into(); Self::new(Kind::Label, "").tags([ - Tag::from_standardized_without_cell(TagStandard::LabelNamespace(namespace.clone())), - Tag::from_standardized_without_cell(TagStandard::Label { + Nip32Tag::LabelNamespace(namespace.clone()).to_tag(), + Nip32Tag::Label { value: label, - namespace: Some(namespace), - }), + namespace, + } + .to_tag(), ]) } @@ -1825,15 +1776,14 @@ impl EventBuilder { content = format!("{}\n{content}", nevent.to_nostr_uri()?); } - Ok( - Self::new(Kind::ChatMessage, content).tag(Tag::from_standardized_without_cell( - TagStandard::Quote { - event_id: reply_to.id, - relay_url, - public_key: Some(reply_to.pubkey), - }, - )), - ) + Ok(Self::new(Kind::ChatMessage, content).tag( + Nip18Tag::Quote { + id: reply_to.id, + relay_hint: relay_url, + public_key: Some(reply_to.pubkey), + } + .to_tag(), + )) } /// Thread @@ -1846,7 +1796,7 @@ impl EventBuilder { { let mut builder = Self::new(Kind::Thread, content); if let Some(t) = title { - builder = builder.tag(Tag::from_standardized_without_cell(TagStandard::Title(t))); + builder = builder.tag(Nip7DTag::Title(t).to_tag()); } builder } @@ -1860,17 +1810,18 @@ impl EventBuilder { S: Into, { let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Event { - event_id: reply_to.id, - relay_url, - marker: None, + Nip22Tag::Event { + id: reply_to.id, + relay_hint: relay_url, public_key: Some(reply_to.pubkey), uppercase: true, - }), - Tag::from_standardized_without_cell(TagStandard::Kind { + } + .to_tag(), + Nip22Tag::Kind { kind: Kind::Thread, uppercase: true, - }), + } + .to_tag(), ]; Self::new(Kind::Comment, content).tags(tags) @@ -1967,7 +1918,7 @@ mod tests { .tags .clone() .iter() - .any(|t| t.kind() == TagKind::Preimage); + .any(|t| t.kind() == "preimage"); assert!(has_preimage_tag); } @@ -1988,7 +1939,7 @@ mod tests { .tags .clone() .iter() - .any(|t| t.kind() == TagKind::Preimage); + .any(|t| t.kind() == "preimage"); assert!(!has_preimage_tag); } @@ -1999,10 +1950,7 @@ mod tests { let event_builder = EventBuilder::define_badge(badge_id, None, None, None, None, Vec::new()); - let has_id = - event_builder.tags.clone().iter().any(|t| { - t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)) - }); + let has_id = event_builder.tags.clone().iter().any(|t| t.kind() == "d"); assert!(has_id); assert_eq!(Kind::BadgeDefinition, event_builder.kind); @@ -2023,10 +1971,7 @@ mod tests { let event_builder = EventBuilder::define_badge(badge_id, name, description, image_url, image_size, thumbs); - let has_id = - event_builder.tags.clone().iter().any(|t| { - t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)) - }); + let has_id = event_builder.tags.clone().iter().any(|t| t.kind() == "d"); assert!(has_id); assert_eq!(Kind::BadgeDefinition, event_builder.kind); @@ -2139,12 +2084,11 @@ mod tests { r#"{{ "content":"", "id": "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "kind": 30008, + "kind": 10008, "pubkey": "{pub_key}", "sig":"fd0954de564cae9923c2d8ee9ab2bf35bc19757f8e328a978958a2fcc950eaba0754148a203adec29b7b64080d0cf5a32bebedd768ea6eb421a6b751bb4584a8", "created_at":1671739153, "tags":[ - ["d", "profile_badges"], ["a", "30009:{badge_one_pubkey}:bravery"], ["e", "{}"], ["a", "30009:{badge_two_pubkey}:honor"], @@ -2181,15 +2125,9 @@ mod tests { .sign_with_keys(&reply_keys) .unwrap(); assert_eq!(reply.tags.public_keys().count(), 1); // Root author - assert_eq!( - reply.tags.public_keys().copied().next().unwrap(), - root_event.pubkey - ); + assert_eq!(reply.tags.public_keys().next().unwrap(), root_event.pubkey); assert_eq!(reply.tags.event_ids().count(), 1); // Root event ID - assert_eq!( - reply.tags.event_ids().copied().next().unwrap(), - root_event.id - ); + assert_eq!(reply.tags.event_ids().next().unwrap(), root_event.id); // Build reply of reply let other_keys = Keys::generate(); @@ -2199,13 +2137,13 @@ mod tests { .unwrap(); assert_eq!(reply_of_reply.tags.public_keys().count(), 2); // Reply + root author - let mut pks = reply_of_reply.tags.public_keys().copied(); + let mut pks = reply_of_reply.tags.public_keys(); assert_eq!(pks.next().unwrap(), reply.pubkey); assert_eq!(pks.next().unwrap(), root_event.pubkey); assert_eq!(reply_of_reply.tags.event_ids().count(), 2); // Reply + root event IDs - let mut ids = reply_of_reply.tags.event_ids().copied(); + let mut ids = reply_of_reply.tags.event_ids(); assert_eq!(ids.next().unwrap(), reply.id); assert_eq!(ids.next().unwrap(), root_event.id); } @@ -2225,12 +2163,13 @@ mod tests { assert_eq!( repost .tags - .find_standardized(TagKind::single_letter(Alphabet::A, false)) + .iter() + .find(|t| t.kind() == "a") + .and_then(|t| Nip01Tag::try_from(t).ok()) .unwrap(), - &TagStandard::Coordinate { + Nip01Tag::Coordinate { coordinate: Coordinate::new(replaceable.kind, replaceable.pubkey), - relay_url: None, - uppercase: false + relay_hint: None, } ); } @@ -2250,13 +2189,14 @@ mod tests { assert_eq!( repost .tags - .find_standardized(TagKind::single_letter(Alphabet::A, false)) + .iter() + .find(|t| t.kind() == "a") + .and_then(|t| Nip01Tag::try_from(t).ok()) .unwrap(), - &TagStandard::Coordinate { + Nip01Tag::Coordinate { coordinate: Coordinate::new(addressable.kind, addressable.pubkey) .identifier("lorem"), - relay_url: None, - uppercase: false + relay_hint: None, } ); } @@ -2277,22 +2217,17 @@ mod tests { assert_eq!(repost.kind, Kind::Repost); assert_eq!(repost.content, note.as_json()); assert_eq!( - repost.tags[0].clone().to_standardized().unwrap(), - TagStandard::Event { - event_id: note.id, - relay_url: Some(relay_url), - marker: None, - public_key: None, - uppercase: false + Nip18Tag::try_from(&repost.tags[0]).unwrap(), + Nip18Tag::Event { + id: note.id, + relay_hint: Some(relay_url), } ); assert_eq!( - repost.tags[1].clone().to_standardized().unwrap(), - TagStandard::PublicKey { + Nip18Tag::try_from(&repost.tags[1]).unwrap(), + Nip18Tag::PublicKey { public_key: note.pubkey, - relay_url: None, - alias: None, - uppercase: false + relay_hint: None, } ); } diff --git a/crates/nostr/src/event/kind.rs b/crates/nostr/src/event/kind.rs index de3a2b8bd..687e752df 100644 --- a/crates/nostr/src/event/kind.rs +++ b/crates/nostr/src/event/kind.rs @@ -133,7 +133,8 @@ kind_variants! { NostrConnect => 24133, "Nostr Connect", "", LiveEvent => 30311, "Live Event", "", LiveEventMessage => 1311, "Live Event Message", "", - ProfileBadges => 30008, "Profile Badges", "", + ProfileBadges => 10008, "Profile Badges", "", + BadgeSet => 30008, "Badge Set", "", BadgeDefinition => 30009, "Badge Definition", "", Seal => 13, "Seal", "", GiftWrap => 1059, "Gift Wrap", "", diff --git a/crates/nostr/src/event/mod.rs b/crates/nostr/src/event/mod.rs index 8aa379689..e62813f60 100644 --- a/crates/nostr/src/event/mod.rs +++ b/crates/nostr/src/event/mod.rs @@ -28,11 +28,11 @@ pub use self::builder::EventBuilder; pub use self::error::Error; pub use self::id::EventId; pub use self::kind::Kind; -pub use self::tag::{Tag, TagKind, TagStandard, Tags}; +pub use self::tag::{Tag, Tags}; pub use self::unsigned::UnsignedEvent; #[cfg(feature = "std")] use crate::SECP256K1; -use crate::nips::nip01::CoordinateBorrow; +use crate::nips::nip01::Coordinate; use crate::nips::nip19::{self, Nip19Event, ToBech32}; use crate::nips::nip21::ToNostrUri; use crate::{JsonUtil, Metadata, PublicKey, Timestamp}; @@ -226,7 +226,7 @@ impl Event { #[cfg(feature = "std")] pub fn is_expired(&self) -> bool { let now: Timestamp = Timestamp::now(); - self.is_expired_at(&now) + self.is_expired_at(now) } /// Returns `true` if the event has an expiration tag that is expired. @@ -234,7 +234,7 @@ impl Event { /// /// #[inline] - pub fn is_expired_at(&self, now: &Timestamp) -> bool { + pub fn is_expired_at(&self, now: Timestamp) -> bool { if let Some(timestamp) = self.tags.expiration() { return timestamp < now; } @@ -244,12 +244,12 @@ impl Event { /// Get the coordinate of this event /// /// Return a coordinate only if the event kind is `replaceable` or `addressable`. - pub fn coordinate(&self) -> Option> { + pub fn coordinate(&self) -> Option { if self.kind.is_replaceable() || self.kind.is_addressable() { - return Some(CoordinateBorrow { - kind: &self.kind, - public_key: &self.pubkey, - identifier: self.tags.identifier(), + return Some(Coordinate { + kind: self.kind, + public_key: self.pubkey, + identifier: self.tags.identifier().unwrap_or_default(), }); } @@ -261,7 +261,7 @@ impl Event { /// #[inline] pub fn is_protected(&self) -> bool { - self.tags.find_standardized(TagKind::Protected).is_some() + self.tags.iter().any(|t| t.is_protected()) } } @@ -505,13 +505,13 @@ mod tests { let event = Event::from_json(json).unwrap(); assert_eq!( event.coordinate(), - Some(CoordinateBorrow { - kind: &Kind::Metadata, - public_key: &PublicKey::from_hex( + Some(Coordinate { + kind: Kind::Metadata, + public_key: PublicKey::from_hex( "2f35aaff0c870f0510a8bed198e1f8c35e95c996148f2d0c0fb1825b05b8dd35" ) .unwrap(), - identifier: None, + identifier: String::new(), }) ); } diff --git a/crates/nostr/src/event/tag/codec.rs b/crates/nostr/src/event/tag/codec.rs new file mode 100644 index 000000000..68f476b1e --- /dev/null +++ b/crates/nostr/src/event/tag/codec.rs @@ -0,0 +1,100 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +use core::fmt; + +use super::Tag; + +/// Tag codec error +#[derive(Debug, PartialEq, Eq)] +pub enum TagCodecError { + /// Missing value + Missing(&'static str), + /// Invalid value + Invalid(&'static str), + /// Unknown tag + Unknown, +} + +impl core::error::Error for TagCodecError {} + +impl fmt::Display for TagCodecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing(value) => write!(f, "missing {value}"), + Self::Invalid(value) => write!(f, "{value}"), + Self::Unknown => write!(f, "unknown tag"), + } + } +} + +impl TagCodecError { + /// Missing tag kind + #[inline] + pub fn missing_tag_kind() -> Self { + Self::Missing("tag kind") + } +} + +/// Conversion contract between a typed tag representation and the generic raw [`Tag`]. +/// +/// Types implementing this trait are expected to: +/// - parse themselves from a raw tag representation +/// - convert themselves back into a raw [`Tag`] +/// +/// This trait is intended for NIP-specific tag enums and, where useful, for individual +/// tag structs that can be used independently of the enclosing enum. +pub trait TagCodec: Sized { + /// Error returned when parsing a raw tag into the typed representation fails. + type Error: core::error::Error; + + /// Parse a typed tag from a raw tag representation. + fn parse(tag: I) -> Result + where + I: IntoIterator, + S: AsRef; + + /// Convert this typed tag into the generic raw [`Tag`] representation. + fn to_tag(&self) -> Tag; +} + +/// Implement the standard conversions for a [`TagCodec`] type. +#[macro_export] +macro_rules! impl_tag_codec_conversions { + ($ty:ty) => { + impl From<&$ty> for Tag { + #[inline] + fn from(value: &$ty) -> Self { + value.to_tag() + } + } + + impl From<$ty> for Tag { + #[inline] + fn from(value: $ty) -> Self { + value.to_tag() + } + } + + impl TryFrom<&Tag> for $ty { + type Error = <$ty as TagCodec>::Error; + + #[inline] + fn try_from(tag: &Tag) -> Result { + <$ty as TagCodec>::parse(tag.as_slice()) + } + } + + impl TryFrom for $ty { + type Error = <$ty as TagCodec>::Error; + + #[inline] + fn try_from(tag: Tag) -> Result { + <$ty as TagCodec>::parse(tag.as_slice()) + } + } + }; +} + +pub use impl_tag_codec_conversions; diff --git a/crates/nostr/src/event/tag/cow.rs b/crates/nostr/src/event/tag/cow.rs index a12281c02..986674b2d 100644 --- a/crates/nostr/src/event/tag/cow.rs +++ b/crates/nostr/src/event/tag/cow.rs @@ -59,7 +59,7 @@ impl<'a> CowTag<'a> { /// Into owned tag pub fn into_owned(self) -> Tag { let buf: Vec = self.buf.into_iter().map(|t| t.into_owned()).collect(); - Tag::new_with_empty_cell(buf) + Tag::new(buf) } /// Get inner value diff --git a/crates/nostr/src/event/tag/error.rs b/crates/nostr/src/event/tag/error.rs index 7de9a495c..eab2225af 100644 --- a/crates/nostr/src/event/tag/error.rs +++ b/crates/nostr/src/event/tag/error.rs @@ -9,7 +9,7 @@ use hashes::hex::HexToArrayError; #[cfg(feature = "nip98")] use crate::nips::nip98; -use crate::nips::{nip01, nip10, nip39, nip53, nip65, nip88}; +use crate::nips::{nip01, nip10, nip39, nip53, nip88}; use crate::types::image; use crate::types::url::{Error as RelayUrlError, ParseError}; use crate::{key, secp256k1}; @@ -38,8 +38,6 @@ pub enum Error { NIP39(nip39::Error), /// NIP53 error NIP53(nip53::Error), - /// NIP65 error - NIP65(nip65::Error), /// NIP88 error NIP88(nip88::Error), /// NIP98 error @@ -72,7 +70,6 @@ impl fmt::Display for Error { Self::NIP10(e) => e.fmt(f), Self::NIP39(e) => e.fmt(f), Self::NIP53(e) => e.fmt(f), - Self::NIP65(e) => e.fmt(f), Self::NIP88(e) => e.fmt(f), #[cfg(feature = "nip98")] Self::NIP98(e) => e.fmt(f), @@ -145,12 +142,6 @@ impl From for Error { } } -impl From for Error { - fn from(e: nip65::Error) -> Self { - Self::NIP65(e) - } -} - impl From for Error { fn from(e: nip88::Error) -> Self { Self::NIP88(e) diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs deleted file mode 100644 index d0a77b5c1..000000000 --- a/crates/nostr/src/event/tag/kind.rs +++ /dev/null @@ -1,577 +0,0 @@ -// Copyright (c) 2022-2023 Yuki Kishimoto -// Copyright (c) 2023-2025 Rust Nostr Developers -// Distributed under the MIT software license - -//! Tag kind - -use alloc::borrow::Cow; -use core::cmp::Ordering; -use core::fmt; -use core::hash::{Hash, Hasher}; -use core::str::FromStr; - -use crate::{Alphabet, SingleLetterTag}; - -/// Tag kind -#[derive(Debug, Clone)] -pub enum TagKind<'a> { - /// AES 256 GCM - Aes256Gcm, - /// Human-readable plaintext summary of what that event is about - /// - /// - Alt, - /// Amount - Amount, - /// Anonymous - Anon, - /// Blurhash - Blurhash, - /// Bolt11 invoice - Bolt11, - /// Challenge - Challenge, - /// Client - /// - /// - Client, - /// Clone - Clone, - /// Commit - /// - /// - Commit, - /// HEAD - /// - /// - Head, - /// Branch name - /// - /// - BranchName, - /// Merge base - /// - /// - MergeBase, - /// Content warning - ContentWarning, - /// Current participants - CurrentParticipants, - /// Required dependency - /// - /// - Dependency, - /// Description - Description, - /// Size of the file in pixels - Dim, - /// Emoji - Emoji, - /// Encrypted - Encrypted, - /// Ends - Ends, - /// Expiration - /// - /// - Expiration, - /// File extension - /// - /// - Extension, - /// File - File, - /// Image - Image, - /// License of the shared content - /// - /// - License, - /// Lnurl - Lnurl, - /// Magnet - Magnet, - /// Maintainers - Maintainers, - /// HTTP Method Request - Method, - /// MLS Protocol Version - MlsProtocolVersion, - /// MLS Cipher Suite - MlsCiphersuite, - /// MLS Extensions - MlsExtensions, - /// Name - Name, - /// Nonce - /// - /// - Nonce, - /// Option - Option, - /// Payload - Payload, - /// Poll type - /// - /// - PollType, - /// Preimage - Preimage, - /// Protected event - /// - /// - Protected, - /// Proxy - Proxy, - /// PublishedAt - PublishedAt, - /// Recording - Recording, - /// Relay - Relay, - /// Relays - Relays, - /// Reference to the origin repository - /// - /// - Repository, - /// Request - Request, - /// Response - Response, - /// Runtime or environment specification - /// - /// - Runtime, - /// Server - Server, - /// Size of the file in bytes - Size, - /// Starts - Starts, - /// Status - Status, - /// Streaming - Streaming, - /// Subject - Subject, - /// Summary - Summary, - /// Title - Title, - /// Thumbnail - Thumb, - /// Total participants - TotalParticipants, - /// Tracker - Tracker, - /// Url - Url, - /// Web - Web, - /// Word - Word, - /// Single letter - SingleLetter(SingleLetterTag), - /// Custom - Custom(Cow<'a, str>), -} - -impl PartialEq for TagKind<'_> { - fn eq(&self, other: &TagKind<'_>) -> bool { - self.as_str() == other.as_str() - } -} - -impl Eq for TagKind<'_> {} - -impl PartialOrd for TagKind<'_> { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for TagKind<'_> { - fn cmp(&self, other: &Self) -> Ordering { - self.as_str().cmp(other.as_str()) - } -} - -impl Hash for TagKind<'_> { - fn hash(&self, state: &mut H) - where - H: Hasher, - { - self.as_str().hash(state); - } -} - -impl<'a> TagKind<'a> { - /// Construct a single letter tag - #[inline] - pub const fn single_letter(character: Alphabet, uppercase: bool) -> Self { - Self::SingleLetter(SingleLetterTag { - character, - uppercase, - }) - } - - /// Construct `a` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A))`. - #[inline] - pub const fn a() -> Self { - Self::single_letter(Alphabet::A, false) - } - - /// Construct `d` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D))`. - #[inline] - pub const fn d() -> Self { - Self::single_letter(Alphabet::D, false) - } - - /// Construct `e` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E))`. - #[inline] - pub const fn e() -> Self { - Self::single_letter(Alphabet::E, false) - } - - /// Construct `h` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::H))`. - #[inline] - pub const fn h() -> Self { - Self::single_letter(Alphabet::H, false) - } - - /// Construct `i` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::I))`. - #[inline] - pub const fn i() -> Self { - Self::single_letter(Alphabet::I, false) - } - - /// Construct `k` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K))`. - #[inline] - pub const fn k() -> Self { - Self::single_letter(Alphabet::K, false) - } - - /// Construct `p` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P))`. - #[inline] - pub const fn p() -> Self { - Self::single_letter(Alphabet::P, false) - } - - /// Construct `t` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::T))`. - #[inline] - pub const fn t() -> Self { - Self::single_letter(Alphabet::T, false) - } - - /// Construct `u` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U))`. - #[inline] - pub const fn u() -> Self { - Self::single_letter(Alphabet::U, false) - } - - /// Construct `q` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Q))`. - #[inline] - pub const fn q() -> Self { - Self::single_letter(Alphabet::Q, false) - } - - /// Construct `x` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::X))`. - #[inline] - pub const fn x() -> Self { - Self::single_letter(Alphabet::X, false) - } - - /// Construct [`TagKind::Custom`] - /// - /// Shorthand for `TagKind::Custom(Cow::from(...))`. - #[inline] - pub fn custom(kind: T) -> Self - where - T: Into>, - { - Self::Custom(kind.into()) - } - - /// Convert to `&str` - pub fn as_str(&self) -> &str { - match self { - Self::Aes256Gcm => "aes-256-gcm", - Self::Alt => "alt", - Self::Amount => "amount", - Self::Anon => "anon", - Self::Blurhash => "blurhash", - Self::Bolt11 => "bolt11", - Self::BranchName => "branch-name", - Self::Challenge => "challenge", - Self::Client => "client", - Self::Clone => "clone", - Self::Commit => "commit", - Self::ContentWarning => "content-warning", - Self::CurrentParticipants => "current_participants", - Self::Dependency => "dep", - Self::Description => "description", - Self::Dim => "dim", - Self::Emoji => "emoji", - Self::Encrypted => "encrypted", - Self::Ends => "ends", - Self::Expiration => "expiration", - Self::Extension => "extension", - Self::File => "file", - Self::Head => "HEAD", - Self::Image => "image", - Self::License => "license", - Self::Lnurl => "lnurl", - Self::Magnet => "magnet", - Self::Maintainers => "maintainers", - Self::MergeBase => "merge-base", - Self::Method => "method", - Self::MlsProtocolVersion => "mls_protocol_version", - Self::MlsCiphersuite => "mls_ciphersuite", - Self::MlsExtensions => "mls_extensions", - Self::Name => "name", - Self::Nonce => "nonce", - Self::Option => "option", - Self::Payload => "payload", - Self::PollType => "polltype", - Self::Preimage => "preimage", - Self::Protected => "-", - Self::Proxy => "proxy", - Self::PublishedAt => "published_at", - Self::Recording => "recording", - Self::Relay => "relay", - Self::Relays => "relays", - Self::Repository => "repo", - Self::Request => "request", - Self::Response => "response", - Self::Runtime => "runtime", - Self::Server => "server", - Self::Size => "size", - Self::Starts => "starts", - Self::Status => "status", - Self::Streaming => "streaming", - Self::Subject => "subject", - Self::Summary => "summary", - Self::Title => "title", - Self::Thumb => "thumb", - Self::TotalParticipants => "total_participants", - Self::Tracker => "tracker", - Self::Url => "url", - Self::Web => "web", - Self::Word => "word", - Self::SingleLetter(s) => s.as_str(), - Self::Custom(tag) => tag.as_ref(), - } - } -} - -impl fmt::Display for TagKind<'_> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -impl<'a> From<&'a str> for TagKind<'a> { - fn from(kind: &'a str) -> Self { - match kind { - "-" => Self::Protected, - "aes-256-gcm" => Self::Aes256Gcm, - "alt" => Self::Alt, - "amount" => Self::Amount, - "anon" => Self::Anon, - "blurhash" => Self::Blurhash, - "bolt11" => Self::Bolt11, - "branch-name" => Self::BranchName, - "challenge" => Self::Challenge, - "client" => Self::Client, - "clone" => Self::Clone, - "commit" => Self::Commit, - "content-warning" => Self::ContentWarning, - "current_participants" => Self::CurrentParticipants, - "dep" => Self::Dependency, - "description" => Self::Description, - "dim" => Self::Dim, - "emoji" => Self::Emoji, - "encrypted" => Self::Encrypted, - "ends" => Self::Ends, - "expiration" => Self::Expiration, - "extension" => Self::Extension, - "file" => Self::File, - "image" => Self::Image, - "license" => Self::License, - "lnurl" => Self::Lnurl, - "magnet" => Self::Magnet, - "maintainers" => Self::Maintainers, - "merge-base" => Self::MergeBase, - "method" => Self::Method, - "mls_protocol_version" => Self::MlsProtocolVersion, - "mls_ciphersuite" => Self::MlsCiphersuite, - "mls_extensions" => Self::MlsExtensions, - "name" => Self::Name, - "nonce" => Self::Nonce, - "option" => Self::Option, - "payload" => Self::Payload, - "polltype" => Self::PollType, - "preimage" => Self::Preimage, - "proxy" => Self::Proxy, - "published_at" => Self::PublishedAt, - "recording" => Self::Recording, - "relay" => Self::Relay, - "relays" => Self::Relays, - "repo" => Self::Repository, - "request" => Self::Request, - "response" => Self::Response, - "runtime" => Self::Runtime, - "HEAD" => Self::Head, - "server" => Self::Server, - "size" => Self::Size, - "starts" => Self::Starts, - "status" => Self::Status, - "streaming" => Self::Streaming, - "subject" => Self::Subject, - "summary" => Self::Summary, - "title" => Self::Title, - "thumb" => Self::Thumb, - "total_participants" => Self::TotalParticipants, - "tracker" => Self::Tracker, - "url" => Self::Url, - "web" => Self::Web, - "word" => Self::Word, - k => match SingleLetterTag::from_str(k) { - Ok(s) => Self::SingleLetter(s), - Err(..) => Self::Custom(Cow::Borrowed(k)), - }, - } - } -} - -#[cfg(test)] -mod tests { - use alloc::string::String; - - use super::*; - - #[test] - fn test_custom_tag_kind_constructor() { - let owned = TagKind::custom(String::from("owned")); - match owned { - TagKind::Custom(Cow::Owned(val)) => assert_eq!(val, "owned"), - _ => panic!("Unexpected tag kind"), - }; - - let borrowed = TagKind::custom("borrowed"); - match borrowed { - TagKind::Custom(Cow::Borrowed(val)) => assert_eq!(val, "borrowed"), - _ => panic!("Unexpected tag kind"), - }; - } - - #[test] - fn test_de_serialization() { - assert_eq!(TagKind::from("-"), TagKind::Protected); - assert_eq!(TagKind::Protected.as_str(), "-"); - - assert_eq!(TagKind::from("aes-256-gcm"), TagKind::Aes256Gcm); - assert_eq!(TagKind::Aes256Gcm.as_str(), "aes-256-gcm"); - - assert_eq!(TagKind::from("alt"), TagKind::Alt); - assert_eq!(TagKind::Alt.as_str(), "alt"); - - assert_eq!(TagKind::from("amount"), TagKind::Amount); - assert_eq!(TagKind::Amount.as_str(), "amount"); - - assert_eq!(TagKind::from("anon"), TagKind::Anon); - assert_eq!(TagKind::Anon.as_str(), "anon"); - - assert_eq!(TagKind::from("clone"), TagKind::Clone); - assert_eq!(TagKind::Clone.as_str(), "clone"); - - assert_eq!(TagKind::from("dep"), TagKind::Dependency); - assert_eq!(TagKind::Dependency.as_str(), "dep"); - - assert_eq!(TagKind::from("expiration"), TagKind::Expiration); - assert_eq!(TagKind::Expiration.as_str(), "expiration"); - - assert_eq!(TagKind::from("extension"), TagKind::Extension); - assert_eq!(TagKind::Extension.as_str(), "extension"); - - assert_eq!(TagKind::from("file"), TagKind::File); - assert_eq!(TagKind::File.as_str(), "file"); - - assert_eq!(TagKind::from("HEAD"), TagKind::Head); - assert_eq!(TagKind::Head.as_str(), "HEAD"); - - assert_eq!(TagKind::from("license"), TagKind::License); - assert_eq!(TagKind::License.as_str(), "license"); - - assert_eq!(TagKind::from("maintainers"), TagKind::Maintainers); - assert_eq!(TagKind::Maintainers.as_str(), "maintainers"); - - assert_eq!(TagKind::from("option"), TagKind::Option); - assert_eq!(TagKind::Option.as_str(), "option"); - - assert_eq!(TagKind::from("polltype"), TagKind::PollType); - assert_eq!(TagKind::PollType.as_str(), "polltype"); - - assert_eq!(TagKind::from("repo"), TagKind::Repository); - assert_eq!(TagKind::Repository.as_str(), "repo"); - - assert_eq!(TagKind::from("response"), TagKind::Response); - assert_eq!(TagKind::Response.as_str(), "response"); - - assert_eq!(TagKind::from("runtime"), TagKind::Runtime); - assert_eq!(TagKind::Runtime.as_str(), "runtime"); - - assert_eq!(TagKind::from("tracker"), TagKind::Tracker); - assert_eq!(TagKind::Tracker.as_str(), "tracker"); - - assert_eq!(TagKind::from("web"), TagKind::Web); - assert_eq!(TagKind::Web.as_str(), "web"); - - assert_eq!(TagKind::from("a"), TagKind::a()); - assert_eq!(TagKind::a().as_str(), "a"); - - assert_eq!(TagKind::from("e"), TagKind::e()); - assert_eq!(TagKind::e().as_str(), "e"); - - assert_eq!(TagKind::from("p"), TagKind::p()); - assert_eq!(TagKind::p().as_str(), "p"); - } - - #[test] - fn test_eq() { - assert_eq!(TagKind::Custom(Cow::from("-")), TagKind::Protected); - assert_eq!(TagKind::Custom(Cow::from("p")), TagKind::p()); - assert_eq!( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), - TagKind::p() - ); - assert_eq!(TagKind::Custom(Cow::from("e")), TagKind::e()); - assert_eq!( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)), - TagKind::e() - ); - } -} diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index a9cceaed1..2dea5362b 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -25,8 +25,10 @@ use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::{Error, Tag}; -use crate::nips::nip01::Coordinate; -use crate::{EventId, PublicKey, SingleLetterTag, TagKind, TagStandard, Timestamp}; +use crate::nips::nip01::{Coordinate, Nip01Tag}; +use crate::nips::nip40::Nip40Tag; +use crate::nips::nip42::Nip42Tag; +use crate::{EventId, PublicKey, SingleLetterTag, Timestamp}; /// Tags Indexes pub type TagsIndexes = BTreeMap>; @@ -268,7 +270,7 @@ impl Tags { /// # Policy /// /// - Two tags are considered duplicates if: - /// 1) They have the same [`TagKind`] + /// 1) They have the same tag kind /// 2) They contain the same content (if applicable) /// /// - Among duplicates, the longest tag is retained; shorter ones are discarded. @@ -307,15 +309,15 @@ impl Tags { // Construct the dedup map #[cfg(feature = "std")] - let mut map: HashMap<(TagKind, Option<&str>), DedupVal> = + let mut map: HashMap<(&str, Option<&str>), DedupVal> = HashMap::with_capacity(self.list.len()); #[cfg(not(feature = "std"))] - let mut map: BTreeMap<(TagKind, Option<&str>), DedupVal> = BTreeMap::new(); + let mut map: BTreeMap<(&str, Option<&str>), DedupVal> = BTreeMap::new(); // Figure out which tags to keep for (idx, tag) in self.list.iter().enumerate() { // Construct dedup key - let key: (TagKind, Option<&str>) = (tag.kind(), tag.content()); + let key: (&str, Option<&str>) = (tag.kind(), tag.content()); // Check if key exists or not match map.entry(key) { @@ -375,33 +377,6 @@ impl Tags { self.list.iter() } - /// Get first tag that match [`TagKind`]. - #[inline] - pub fn find(&self, kind: TagKind) -> Option<&Tag> { - self.list.iter().find(|t| t.kind() == kind) - } - - /// Get the first tag that match [`TagKind`] and that is standardized. - #[inline] - pub fn find_standardized(&self, kind: TagKind) -> Option<&TagStandard> { - self.find(kind).and_then(|t| t.as_standardized()) - } - - /// Filter tags that match [`TagKind`]. - #[inline] - pub fn filter<'a>(&'a self, kind: TagKind<'a>) -> impl Iterator { - self.list.iter().filter(move |t| t.kind() == kind) - } - - /// Get the first tag that match [`TagKind`] and that is standardized. - #[inline] - pub fn filter_standardized<'a>( - &'a self, - kind: TagKind<'a>, - ) -> impl Iterator { - self.filter(kind).filter_map(|t| t.as_standardized()) - } - /// Get as slice of tags #[inline] pub fn as_slice(&self) -> &[Tag] { @@ -416,9 +391,11 @@ impl Tags { /// Extract identifier (`d` tag), if exists. #[inline] - pub fn identifier(&self) -> Option<&str> { - match self.find_standardized(TagKind::d())? { - TagStandard::Identifier(identifier) => Some(identifier), + pub fn identifier(&self) -> Option { + let tag: &Tag = self.iter().find(|t| t.kind() == "d")?; + + match Nip01Tag::try_from(tag) { + Ok(Nip01Tag::Identifier(identifier)) => Some(identifier), _ => None, } } @@ -426,71 +403,75 @@ impl Tags { /// Get [`Timestamp`] expiration, if exists. /// /// - pub fn expiration(&self) -> Option<&Timestamp> { - match self.find_standardized(TagKind::Expiration)? { - TagStandard::Expiration(timestamp) => Some(timestamp), + pub fn expiration(&self) -> Option { + let tag: &Tag = self.iter().find(|t| t.kind() == "expiration")?; + + match Nip40Tag::try_from(tag) { + Ok(Nip40Tag::Expiration(expiration)) => Some(expiration), _ => None, } } /// Extract NIP42 challenge, if exists. #[inline] - pub fn challenge(&self) -> Option<&str> { - match self.find_standardized(TagKind::Challenge)? { - TagStandard::Challenge(challenge) => Some(challenge), + pub fn challenge(&self) -> Option { + let tag: &Tag = self.iter().find(|t| t.kind() == "challenge")?; + + match Nip42Tag::try_from(tag) { + Ok(Nip42Tag::Challenge(challenge)) => Some(challenge), _ => None, } } /// Extract public keys from `p` tags. - /// - /// This method extract only [`TagStandard::PublicKey`], [`TagStandard::PublicKeyReport`] and [`TagStandard::PublicKeyLiveEvent`] variants. #[inline] - pub fn public_keys(&self) -> impl Iterator { - self.filter_standardized(TagKind::p()) - .filter_map(|t| match t { - TagStandard::PublicKey { public_key, .. } => Some(public_key), - TagStandard::PublicKeyReport(public_key, ..) => Some(public_key), - TagStandard::PublicKeyLiveEvent { public_key, .. } => Some(public_key), - _ => None, - }) + pub fn public_keys(&self) -> impl Iterator + '_ { + self.iter().filter_map(|t| { + if t.kind() != "p" { + return None; + } + + let content = t.content()?; + PublicKey::from_hex(content).ok() + }) } /// Extract event IDs from `e` tags. - /// - /// This method extract only [`TagStandard::Event`] and [`TagStandard::EventReport`] variants. #[inline] - pub fn event_ids(&self) -> impl Iterator { - self.filter_standardized(TagKind::e()) - .filter_map(|t| match t { - TagStandard::Event { event_id, .. } => Some(event_id), - TagStandard::EventReport(event_id, ..) => Some(event_id), - _ => None, - }) + pub fn event_ids(&self) -> impl Iterator + '_ { + self.iter().filter_map(|t| { + if t.kind() != "e" { + return None; + } + + let content = t.content()?; + EventId::from_hex(content).ok() + }) } /// Extract coordinates from `a` tags. - /// - /// This method extract only [`TagStandard::Coordinate`] variant. #[inline] - pub fn coordinates(&self) -> impl Iterator { - self.filter_standardized(TagKind::a()) - .filter_map(|t| match t { - TagStandard::Coordinate { coordinate, .. } => Some(coordinate), - _ => None, - }) + pub fn coordinates(&self) -> impl Iterator + '_ { + self.iter().filter_map(|t| { + if t.kind() != "a" { + return None; + } + + let content = t.content()?; + Coordinate::from_kpi_format(content).ok() + }) } /// Extract hashtags from `t` tags. - /// - /// This method extract only [`TagStandard::Hashtag`] variant. #[inline] - pub fn hashtags(&self) -> impl Iterator { - self.filter_standardized(TagKind::t()) - .filter_map(|t| match t { - TagStandard::Hashtag(hashtag) => Some(hashtag.as_ref()), - _ => None, - }) + pub fn hashtags(&self) -> impl Iterator + '_ { + self.iter().filter_map(|t| { + if t.kind() != "t" { + return None; + } + + t.content() + }) } fn build_indexes(&self) -> TagsIndexes { @@ -573,6 +554,7 @@ impl<'de> Deserialize<'de> for Tags { #[cfg(test)] mod tests { use super::*; + use crate::event::tag::TagCodec; use crate::{Event, JsonUtil, RelayUrl}; #[test] @@ -588,14 +570,14 @@ mod tests { .filter(|t| t.content() == Some("3")) .collect(); assert_eq!(tags.len(), 1); - assert_eq!(tags.identifier(), Some("3")); + assert_eq!(tags.identifier(), Some(String::from("3"))); } #[test] fn test_extract_d_tag() { let json = r#"{"id":"3dfdbb371de782f51812dc4809ea1104d80e143cec1091a4be07f518ef09e3d7","pubkey":"b8aef32a5421205c1f89ad09e2d93873df68a8611b247f62af005655eadc0efb","created_at":1728728536,"kind":30000,"sig":"0395c41fd95d52b534eaa29c82cd9437130cf63e67117b1587914375fdfb878137287a1d15653161f91ea919afb06358784217409a9ff0323261f683b2936829","content":"older_param_replaceable","tags":[["d","1"]]}"#; let event = Event::from_json(json).unwrap(); - assert_eq!(event.tags.identifier(), Some("1")); + assert_eq!(event.tags.identifier(), Some(String::from("1"))); } #[test] @@ -614,26 +596,22 @@ mod tests { EventId::from_hex("2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45") .unwrap(); - let long_p_tag_1 = Tag::from_standardized_without_cell(TagStandard::PublicKey { + let long_p_tag_1 = Nip01Tag::PublicKey { public_key: pubkey1, - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - uppercase: false, - alias: None, - }); + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), + }; - let long_e_tag_2 = Tag::from_standardized_without_cell(TagStandard::Event { - event_id: event2, - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: None, + let long_e_tag_2 = Nip01Tag::Event { + id: event2, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), public_key: None, - uppercase: false, - }); + }; let empty_list: Vec = Vec::new(); let list = vec![ Tag::protected(), - Tag::custom(TagKind::p(), empty_list.clone()), // Non standard p tag + Tag::custom("p", empty_list.clone()), // Non standard p tag Tag::public_key(pubkey1), Tag::public_key(pubkey2), Tag::event(event1), @@ -641,10 +619,10 @@ mod tests { Tag::identifier("test"), Tag::alt("testing deduplication"), Tag::alt("test"), - long_e_tag_2.clone(), + long_e_tag_2.to_tag(), Tag::event(event2), Tag::protected(), - long_p_tag_1.clone(), + long_p_tag_1.to_tag(), Tag::public_key(pubkey2), Tag::identifier("test"), ]; @@ -654,11 +632,11 @@ mod tests { let expected = vec![ Tag::protected(), - Tag::custom(TagKind::p(), empty_list), // Non standard p tag - long_p_tag_1, + Tag::custom("p", empty_list), // Non standard p tag + long_p_tag_1.to_tag(), Tag::public_key(pubkey2), Tag::event(event1), - long_e_tag_2, + long_e_tag_2.to_tag(), Tag::identifier("test"), Tag::alt("testing deduplication"), Tag::alt("test"), @@ -707,12 +685,11 @@ mod benches { for pk in pubkeys.into_iter() { // Push long p tag - let long_p_tag = Tag::from_standardized_without_cell(TagStandard::PublicKey { + let long_p_tag = Nip01Tag::PublicKey { public_key: pk, - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - uppercase: false, - alias: None, - }); + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), + } + .to_tag(); tags.push(long_p_tag) } diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 741ef82a5..04d460abb 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -4,45 +4,40 @@ //! Tag -use alloc::string::{String, ToString}; +use alloc::string::String; +use alloc::vec; use alloc::vec::{IntoIter, Vec}; -#[cfg(not(feature = "std"))] -use core::cell::OnceCell; use core::cmp::Ordering; use core::fmt; use core::hash::{Hash, Hasher}; use core::ops::{Index, IndexMut}; -#[cfg(feature = "std")] -use std::sync::OnceLock as OnceCell; +use core::str::FromStr; use serde::de::Error as DeserializerError; use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +mod codec; pub mod cow; mod error; -pub mod kind; pub mod list; -pub mod standard; +pub use self::codec::*; pub use self::cow::CowTag; pub use self::error::Error; -pub use self::kind::TagKind; pub use self::list::Tags; -pub use self::standard::TagStandard; use super::id::EventId; -use crate::nips::nip01::Coordinate; -use crate::nips::nip10::Marker; -use crate::nips::nip56::Report; -use crate::nips::nip65::RelayMetadata; -use crate::types::Url; -use crate::{ImageDimensions, PublicKey, RelayUrl, SingleLetterTag, Timestamp}; +use crate::nips::nip01::{Coordinate, Nip01Tag}; +use crate::nips::nip13::Nip13Tag; +use crate::nips::nip31::Nip31Tag; +use crate::nips::nip40::Nip40Tag; +use crate::nips::nip70::Nip70Tag; +use crate::{PublicKey, RelayUrl, SingleLetterTag, Timestamp}; /// Tag #[derive(Clone)] pub struct Tag { buf: Vec, - standardized: OnceCell>, } impl fmt::Debug for Tag { @@ -93,26 +88,12 @@ impl IndexMut for Tag { impl Tag { #[inline] - fn new(buf: Vec, standardized: Option) -> Self { - Self { - buf, - standardized: OnceCell::from(standardized), - } - } - - #[inline] - fn new_with_empty_cell(buf: Vec) -> Self { - Self { - buf, - standardized: OnceCell::new(), - } - } + pub(crate) fn new(buf: Vec) -> Self { + // The tag must not be empty! + assert!(!buf.is_empty()); - #[inline] - fn erase_standardized(&mut self) { - if self.standardized.get().is_some() { - self.standardized = OnceCell::new(); - } + // Construct + Self { buf } } /// Parse tag @@ -132,27 +113,14 @@ impl Tag { } // Construct without an empty cell - Ok(Self::new_with_empty_cell(tag)) - } - - /// Construct from standardized tag - #[inline] - pub fn from_standardized(standardized: TagStandard) -> Self { - Self::new(standardized.clone().to_vec(), Some(standardized)) - } - - /// Construct from standardized tag without initialize cell (avoid a clone) - #[inline] - pub fn from_standardized_without_cell(standardized: TagStandard) -> Self { - Self::new_with_empty_cell(standardized.to_vec()) + Ok(Self::new(tag)) } /// Get tag kind #[inline] - pub fn kind(&self) -> TagKind<'_> { + pub fn kind(&self) -> &str { // SAFETY: `buf` must not be empty, checked during parsing. - let key: &str = &self.buf[0]; - TagKind::from(key) + &self.buf[0] } /// Return the **first** tag value (index `1`), if exists. @@ -161,33 +129,15 @@ impl Tag { self.buf.get(1).map(|s| s.as_str()) } - /// Get [SingleLetterTag] + /// Get [`SingleLetterTag`] #[inline] pub fn single_letter_tag(&self) -> Option { - match self.kind() { - TagKind::SingleLetter(s) => Some(s), - _ => None, - } - } - - /// Get reference of standardized tag - #[inline] - pub fn as_standardized(&self) -> Option<&TagStandard> { - self.standardized - .get_or_init(|| TagStandard::parse(self.as_slice()).ok()) - .as_ref() - } - - /// Consume tag and get standardized tag - #[inline] - pub fn to_standardized(self) -> Option { - match self.standardized.into_inner() { - Some(inner) => inner, - None => TagStandard::parse(&self.buf).ok(), - } + SingleLetterTag::from_str(self.kind()).ok() } /// Get tag len + /// + /// This will never return zero. #[inline] #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { @@ -197,16 +147,11 @@ impl Tag { /// Appends a value to the back of the [`Tag`]. /// /// Check [`Vec::push`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. + #[inline] pub fn push(&mut self, value: S) where S: Into, { - // Erase indexes - self.erase_standardized(); - - // Append self.buf.push(value.into()); } @@ -214,17 +159,13 @@ impl Tag { /// If the [`Tag`] has only **one** value, returns `None`, since it can't be empty. /// /// Check [`Vec::pop`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. + #[inline] pub fn pop(&mut self) -> Option { // The tag must have at least one value! if self.buf.len() <= 1 { return None; } - // Erase indexes - self.erase_standardized(); - // Pop last item self.buf.pop() } @@ -239,8 +180,6 @@ impl Tag { /// Returns `false` if `index > len`. /// /// Check [`Vec::insert`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. pub fn insert(&mut self, index: usize, value: S) -> bool where S: Into, @@ -257,9 +196,6 @@ impl Tag { return false; } - // Erase indexes - self.erase_standardized(); - // Insert at position self.buf.insert(index, value); @@ -270,17 +206,12 @@ impl Tag { /// Extends the collection. /// /// Check [`Vec::extend`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. + #[inline] pub fn extend(&mut self, iter: I) where I: IntoIterator, S: Into, { - // Erase standardized tag - self.erase_standardized(); - - // Extend list self.buf.extend(iter.into_iter().map(|v| v.into())); } @@ -300,8 +231,13 @@ impl Tag { /// /// #[inline] - pub fn event(event_id: EventId) -> Self { - Self::from_standardized_without_cell(TagStandard::event(event_id)) + pub fn event(id: EventId) -> Self { + Nip01Tag::Event { + id, + relay_hint: None, + public_key: None, + } + .to_tag() } /// Compose `["p", ""]` tag @@ -309,7 +245,11 @@ impl Tag { /// #[inline] pub fn public_key(public_key: PublicKey) -> Self { - Self::from_standardized_without_cell(TagStandard::public_key(public_key)) + Nip01Tag::PublicKey { + public_key, + relay_hint: None, + } + .to_tag() } /// Compose `["d", ""]` tag @@ -320,7 +260,7 @@ impl Tag { where T: Into, { - Self::from_standardized_without_cell(TagStandard::Identifier(identifier.into())) + Nip01Tag::Identifier(identifier.into()).to_tag() } /// Compose `["a", "", ""]` tag @@ -328,11 +268,11 @@ impl Tag { /// #[inline] pub fn coordinate(coordinate: Coordinate, relay_url: Option) -> Self { - Self::from_standardized_without_cell(TagStandard::Coordinate { + Nip01Tag::Coordinate { coordinate, - relay_url, - uppercase: false, - }) + relay_hint: relay_url, + } + .to_tag() } /// Compose `["nonce", "", ""]` tag @@ -340,20 +280,7 @@ impl Tag { /// #[inline] pub fn pow(nonce: u128, difficulty: u8) -> Self { - Self::from_standardized_without_cell(TagStandard::POW { nonce, difficulty }) - } - - /// Construct `["client", ""]` tag - /// - /// - pub fn client(name: S) -> Self - where - S: Into, - { - Self::from_standardized_without_cell(TagStandard::Client { - name: name.into(), - address: None, - }) + Nip13Tag::Nonce { nonce, difficulty }.to_tag() } /// Compose `["expiration", ""]` tag @@ -361,76 +288,7 @@ impl Tag { /// #[inline] pub fn expiration(timestamp: Timestamp) -> Self { - Self::from_standardized_without_cell(TagStandard::Expiration(timestamp)) - } - - /// Compose `["e", "", ""]` tag - /// - /// - #[inline] - pub fn event_report(event_id: EventId, report: Report) -> Self { - Self::from_standardized_without_cell(TagStandard::EventReport(event_id, report)) - } - - /// Compose `["p", "", ""]` tag - /// - /// - #[inline] - pub fn public_key_report(public_key: PublicKey, report: Report) -> Self { - Self::from_standardized_without_cell(TagStandard::PublicKeyReport(public_key, report)) - } - - /// Compose `["r", "", ""]` tag - /// - /// - #[inline] - pub fn relay_metadata(relay_url: RelayUrl, metadata: Option) -> Self { - Self::from_standardized_without_cell(TagStandard::RelayMetadata { - relay_url, - metadata, - }) - } - - /// Relay url - /// - /// JSON: `["relay", ""]` - #[inline] - pub fn relay(url: RelayUrl) -> Self { - Self::from_standardized_without_cell(TagStandard::Relay(url)) - } - - /// Relay URLs - /// - /// JSON: `["relays", "", ""]` - #[inline] - pub fn relays(urls: I) -> Self - where - I: IntoIterator, - { - Self::from_standardized_without_cell(TagStandard::Relays(urls.into_iter().collect())) - } - - /// All relays - /// - /// JSON: `["relay", "ALL_RELAYS"]` - /// - /// - #[inline] - pub fn all_relays() -> Self { - Self::from_standardized_without_cell(TagStandard::AllRelays) - } - - /// Repository head - /// - /// JSON: `["HEAD", "ref: refs/heads/"]` - /// - /// - #[inline] - pub fn head(branch_name: S) -> Self - where - S: Into, - { - Self::from_standardized_without_cell(TagStandard::GitHead(branch_name.into())) + Nip40Tag::Expiration(timestamp).to_tag() } /// Compose `["t", ""]` tag @@ -441,40 +299,7 @@ impl Tag { where T: AsRef, { - Self::from_standardized_without_cell(TagStandard::Hashtag(hashtag.as_ref().to_lowercase())) - } - - /// Compose `["r", ""]` tag - #[inline] - pub fn reference(reference: T) -> Self - where - T: Into, - { - Self::from_standardized_without_cell(TagStandard::Reference(reference.into())) - } - - /// Compose `["title", ""]` tag - #[inline] - pub fn title<T>(title: T) -> Self - where - T: Into<String>, - { - Self::from_standardized_without_cell(TagStandard::Title(title.into())) - } - - /// Compose image tag - #[inline] - pub fn image(url: Url, dimensions: Option<ImageDimensions>) -> Self { - Self::from_standardized_without_cell(TagStandard::Image(url, dimensions)) - } - - /// Compose `["description", "<description>"]` tag - #[inline] - pub fn description<T>(description: T) -> Self - where - T: Into<String>, - { - Self::from_standardized_without_cell(TagStandard::Description(description.into())) + Self::new(vec![String::from("t"), hashtag.as_ref().to_lowercase()]) } /// Protected event @@ -482,7 +307,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/70.md> #[inline] pub fn protected() -> Self { - Self::from_standardized_without_cell(TagStandard::Protected) + Nip70Tag::Protected.to_tag() } /// A short human-readable plaintext summary of what that event is about @@ -495,46 +320,24 @@ impl Tag { where T: Into<String>, { - Self::from_standardized_without_cell(TagStandard::Alt(summary.into())) + Nip31Tag::Alt(summary.into()).to_tag() } /// Compose custom tag /// /// JSON: `["<kind>", "<value-1>", "<value-2>", ...]` - pub fn custom<I, S>(kind: TagKind, values: I) -> Self + pub fn custom<K, I, S>(kind: K, values: I) -> Self where + K: Into<String>, I: IntoIterator<Item = S>, S: Into<String>, { // Compose tag let mut buf: Vec<String> = Vec::with_capacity(1); - buf.push(kind.to_string()); + buf.push(kind.into()); buf.extend(values.into_iter().map(|v| v.into())); - // NOT USE `Self::new`! - Self::new_with_empty_cell(buf) - } - - /// Check if is a standard event tag with `root` marker - pub fn is_root(&self) -> bool { - matches!( - self.as_standardized(), - Some(TagStandard::Event { - marker: Some(Marker::Root), - .. - }) - ) - } - - /// Check if is a standard event tag with `reply` marker - pub fn is_reply(&self) -> bool { - matches!( - self.as_standardized(), - Some(TagStandard::Event { - marker: Some(Marker::Reply), - .. - }) - ) + Self::new(buf) } /// Check if it's a protected event tag @@ -542,7 +345,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/70.md> #[inline] pub fn is_protected(&self) -> bool { - matches!(self.as_standardized(), Some(TagStandard::Protected)) + self.buf == ["-"] } } @@ -580,12 +383,6 @@ impl<'de> Deserialize<'de> for Tag { } } -impl From<TagStandard> for Tag { - fn from(standard: TagStandard) -> Self { - Self::from_standardized_without_cell(standard) - } -} - #[cfg(test)] mod tests { use core::str::FromStr; @@ -593,7 +390,7 @@ mod tests { use secp256k1::schnorr::Signature; use super::*; - use crate::{Alphabet, Event, JsonUtil, Kind, Timestamp}; + use crate::{Event, JsonUtil, Kind, Timestamp}; #[test] fn test_parse_empty_tag() { @@ -603,21 +400,6 @@ mod tests { ); } - #[test] - fn test_tag_match_standardized() { - let tag: Tag = Tag::parse(["d", "bravery"]).unwrap(); - assert_eq!( - tag.as_standardized(), - Some(&TagStandard::Identifier(String::from("bravery"))) - ); - - let tag: Tag = Tag::parse(["d", "test"]).unwrap(); - assert_eq!( - tag.to_standardized(), - Some(TagStandard::Identifier(String::from("test"))) - ); - } - #[test] fn test_extract_tag_content() { let t: Tag = Tag::parse(["aaaaaa", "bbbbbb"]).unwrap(); @@ -737,19 +519,12 @@ mod tests { fn test_tag_custom() { assert_eq!( vec!["r", "wss://atlas.nostr.land", ""], - Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), - ["wss://atlas.nostr.land", ""] - ) - .to_vec() + Tag::custom("r", ["wss://atlas.nostr.land", ""]).to_vec() ); assert_eq!( Tag::parse(["r", "wss://atlas.nostr.land", ""]).unwrap(), - Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), - ["wss://atlas.nostr.land", ""] - ) + Tag::custom("r", ["wss://atlas.nostr.land", ""]) ); assert_eq!( @@ -759,7 +534,7 @@ mod tests { "" ], Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), + "r", [ "3dbee968d1ddcdf07521e246e405e1fbb549080f1f4ef4e42526c4528f124220", "" @@ -776,32 +551,22 @@ mod tests { ]) .unwrap(), Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), + "r", [ "3dbee968d1ddcdf07521e246e405e1fbb549080f1f4ef4e42526c4528f124220", "" ] ) ); - - assert_eq!( - vec!["client", "rust-nostr"], - Tag::custom(TagKind::Client, ["rust-nostr"]).to_vec() - ); - - assert_eq!( - Tag::parse(["client", "nostr-sdk"]).unwrap(), - Tag::custom(TagKind::Client, ["nostr-sdk"]) - ); } #[test] fn test_hashtag() { assert_eq!( Tag::parse(["t", "Nostr"]).unwrap(), - Tag::custom(TagKind::t(), ["Nostr"]) + Tag::custom("t", ["Nostr"]) ); - assert_eq!(Tag::hashtag("Nostr"), Tag::custom(TagKind::t(), ["nostr"])); + assert_eq!(Tag::hashtag("Nostr"), Tag::custom("t", ["nostr"])); } } diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs deleted file mode 100644 index 7d00c4213..000000000 --- a/crates/nostr/src/event/tag/standard.rs +++ /dev/null @@ -1,3010 +0,0 @@ -// Copyright (c) 2022-2023 Yuki Kishimoto -// Copyright (c) 2023-2025 Rust Nostr Developers -// Distributed under the MIT software license - -//! Standardized tags - -use alloc::borrow::Cow; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use core::str::FromStr; - -use hashes::sha1::Hash as Sha1Hash; -use hashes::sha256::Hash as Sha256Hash; -use secp256k1::schnorr::Signature; - -use super::{Error, TagKind}; -use crate::event::id::EventId; -use crate::nips::nip01::Coordinate; -use crate::nips::nip10::Marker; -use crate::nips::nip34::EUC; -use crate::nips::nip39::Identity; -use crate::nips::nip48::Protocol; -use crate::nips::nip53::{LiveEventMarker, LiveEventStatus}; -use crate::nips::nip56::Report; -use crate::nips::nip65::RelayMetadata; -use crate::nips::nip73::{ExternalContentId, Nip73Kind}; -use crate::nips::nip88::{self, PollOption, PollType}; -use crate::nips::nip90::DataVendingMachineStatus; -#[cfg(feature = "nip98")] -use crate::nips::nip98::HttpMethod; -use crate::types::{RelayUrl, Url}; -use crate::{ - Alphabet, Event, ImageDimensions, JsonUtil, Kind, PublicKey, SingleLetterTag, Timestamp, -}; - -const ALL_RELAYS: &str = "ALL_RELAYS"; -const GIT_REFS_HEADS: &str = "ref: refs/heads/"; - -/// Standardized tag -#[allow(deprecated)] -#[allow(missing_docs)] -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TagStandard { - /// Event - /// - /// <https://github.com/nostr-protocol/nips/blob/master/01.md> and <https://github.com/nostr-protocol/nips/blob/master/10.md> - Event { - event_id: EventId, - relay_url: Option<RelayUrl>, - marker: Option<Marker>, - /// Should be the public key of the author of the referenced event - public_key: Option<PublicKey>, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - /// Quote - /// - /// <https://github.com/nostr-protocol/nips/blob/master/18.md> - Quote { - event_id: EventId, - relay_url: Option<RelayUrl>, - /// Should be the public key of the author of the referenced event - public_key: Option<PublicKey>, - }, - /// Quote address - /// - /// <https://github.com/nostr-protocol/nips/blob/master/22.md> - QuoteAddress { - coordinate: Coordinate, - relay_url: Option<RelayUrl>, - }, - /// Report event - /// - /// <https://github.com/nostr-protocol/nips/blob/master/56.md> - EventReport(EventId, Report), - /// Git head ([`TagKind::Head`] tag) - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitHead(String), - /// Git clone ([`TagKind::Clone`] tag) - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitClone(Vec<Url>), - /// Git commit - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitCommit(Sha1Hash), - /// Git earliest unique commit ID - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitEarliestUniqueCommitId(Sha1Hash), - /// Git repo maintainers - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitMaintainers(Vec<PublicKey>), - /// Git merge base commit - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitMergeBase(Sha1Hash), - /// Git branch name - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitBranchName(String), - /// Public Key - /// - /// <https://github.com/nostr-protocol/nips/blob/master/01.md> - PublicKey { - public_key: PublicKey, - relay_url: Option<RelayUrl>, - alias: Option<String>, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - /// Report public key - /// - /// <https://github.com/nostr-protocol/nips/blob/master/56.md> - PublicKeyReport(PublicKey, Report), - PublicKeyLiveEvent { - public_key: PublicKey, - relay_url: Option<RelayUrl>, - marker: LiveEventMarker, - proof: Option<Signature>, - }, - Reference(String), - /// Relay Metadata - /// - /// <https://github.com/nostr-protocol/nips/blob/master/65.md> - RelayMetadata { - relay_url: RelayUrl, - metadata: Option<RelayMetadata>, - }, - Hashtag(String), - Geohash(String), - Identifier(String), - /// External Content ID - /// - /// <https://github.com/nostr-protocol/nips/blob/master/73.md> - ExternalContent { - content: ExternalContentId, - /// Optional URL hint to redirect people to a website if the client isn't opinionated about how to interpret the id. - hint: Option<Url>, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - /// External Identity - /// - /// <https://github.com/nostr-protocol/nips/blob/master/39.md> - ExternalIdentity(Identity), - Coordinate { - coordinate: Coordinate, - relay_url: Option<RelayUrl>, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - Kind { - kind: Kind, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - Nip73Kind { - kind: Nip73Kind, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - Relay(RelayUrl), - Relays(Vec<RelayUrl>), - /// All relays tag - /// - /// <https://github.com/nostr-protocol/nips/blob/master/62.md> - AllRelays, - /// Poll end timestamp - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollEndsAt(Timestamp), - /// Poll Option - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollOption(PollOption), - /// Poll response - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollResponse(String), - /// Poll Type - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollType(PollType), - /// Proof of Work - /// - /// <https://github.com/nostr-protocol/nips/blob/master/13.md> - POW { - nonce: u128, - difficulty: u8, - }, - /// Client - /// - /// <https://github.com/nostr-protocol/nips/blob/master/89.md> - Client { - /// Client name - name: String, - /// Client address and optional hint - address: Option<(Coordinate, Option<RelayUrl>)>, - }, - ContentWarning { - reason: Option<String>, - }, - Expiration(Timestamp), - Subject(String), - Challenge(String), - Title(String), - Image(Url, Option<ImageDimensions>), - Thumb(Url, Option<ImageDimensions>), - Summary(String), - Description(String), - Bolt11(String), - Preimage(String), - Amount { - millisats: u64, - bolt11: Option<String>, - }, - Lnurl(String), - Name(String), - PublishedAt(Timestamp), - Url(Url), - MimeType(String), - Aes256Gcm { - key: String, - iv: String, - }, - Server(Url), - Sha256(Sha256Hash), - Size(usize), - Dim(ImageDimensions), - Magnet(String), - Blurhash(String), - Streaming(Url), - Recording(Url), - Starts(Timestamp), - Ends(Timestamp), - LiveEventStatus(LiveEventStatus), - CurrentParticipants(u64), - TotalParticipants(u64), - AbsoluteURL(Url), - #[cfg(feature = "nip98")] - Method(HttpMethod), - Payload(Sha256Hash), - Anon { - msg: Option<String>, - }, - Proxy { - id: String, - protocol: Protocol, - }, - Emoji { - /// Name given for the emoji, which MUST consist of only alphanumeric characters and underscores - shortcode: String, - /// URL to the corresponding image file of the emoji - url: Url, - }, - Encrypted, - Request(Event), - DataVendingMachineStatus { - status: DataVendingMachineStatus, - extra_info: Option<String>, - }, - /// Label namespace - /// - /// <https://github.com/nostr-protocol/nips/blob/master/32.md> - LabelNamespace(String), - /// Label - /// - /// <https://github.com/nostr-protocol/nips/blob/master/32.md> - Label { - /// Label value - value: String, - /// Label namespace - namespace: Option<String>, - }, - /// Required dependency - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Dependency(String), - /// File extension - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Extension(String), - /// License of the shared content - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - License(String), - /// Runtime or environment specification - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Runtime(String), - /// Reference to the origin repository - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Repository(String), - /// Protected event - /// - /// <https://github.com/nostr-protocol/nips/blob/master/70.md> - Protected, - /// A short human-readable plaintext summary of what that event is about - /// - /// <https://github.com/nostr-protocol/nips/blob/master/31.md> - Alt(String), - /// List of web URLs - Web(Vec<Url>), - Word(String), -} - -impl TagStandard { - // TODO: require the event kind for parsing a tag? - /// Parse tag from slice of string - #[inline] - pub fn parse<S>(tag: &[S]) -> Result<Self, Error> - where - S: AsRef<str>, - { - let tag_kind: TagKind = match tag.first() { - Some(kind) => TagKind::from(kind.as_ref()), - None => return Err(Error::KindNotFound), - }; - - Self::internal_parse(tag_kind, tag) - } - - fn internal_parse<S>(tag_kind: TagKind, tag: &[S]) -> Result<Self, Error> - where - S: AsRef<str>, - { - match tag_kind { - TagKind::SingleLetter(single_letter) => match single_letter { - // Parse `a` tag - SingleLetterTag { - character: Alphabet::A, - uppercase, - } => { - return parse_a_tag(tag, uppercase); - } - // Parse `e` tag - SingleLetterTag { - character: Alphabet::E, - uppercase, - } => { - return parse_e_tag(tag, uppercase); - } - // Parse `i` tag - SingleLetterTag { - character: Alphabet::I, - uppercase, - } => { - return parse_i_tag(tag, uppercase); - } - // Parse `l` tag - SingleLetterTag { - character: Alphabet::L, - uppercase, - } => { - return parse_l_tag(tag, uppercase); - } - // Parse `p` tag - SingleLetterTag { - character: Alphabet::P, - uppercase, - } => { - return parse_p_tag(tag, uppercase); - } - // Parse `r` tag - SingleLetterTag { - character: Alphabet::R, - uppercase, - } => { - return parse_r_tag(tag, uppercase); - } - // Parse `q` tag - SingleLetterTag { - character: Alphabet::Q, - uppercase: false, - } => { - return parse_q_tag(tag); - } - // Parse `t` tag - SingleLetterTag { - character: Alphabet::T, - uppercase: false, - } => { - return parse_t_tag(tag); - } - // Parse `k` tag - SingleLetterTag { - character: Alphabet::K, - uppercase, - } => { - return parse_k_tag(tag, uppercase); - } - _ => (), // Covered later - }, - TagKind::Anon => { - return Ok(Self::Anon { - msg: extract_optional_string(tag, 1).map(|s| s.to_string()), - }); - } - TagKind::Client => return parse_client_tag(tag), - TagKind::Clone => { - let urls: Vec<Url> = extract_urls(tag)?; - return Ok(Self::GitClone(urls)); - } - TagKind::ContentWarning => { - return Ok(Self::ContentWarning { - reason: extract_optional_string(tag, 1).map(|s| s.to_string()), - }); - } - TagKind::Encrypted => return Ok(Self::Encrypted), - TagKind::Maintainers => { - let public_keys: Vec<PublicKey> = extract_public_keys(tag)?; - return Ok(Self::GitMaintainers(public_keys)); - } - TagKind::Protected => return Ok(Self::Protected), - TagKind::Relays => { - let urls: Vec<RelayUrl> = extract_relay_urls(tag)?; - return Ok(Self::Relays(urls)); - } - TagKind::Web => { - let urls: Vec<Url> = extract_urls(tag)?; - return Ok(Self::Web(urls)); - } - _ => (), // Covered later - }; - - let tag_len: usize = tag.len(); - - if tag_len == 2 { - let tag_1: &str = tag[1].as_ref(); - - return match tag_kind { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::G, - uppercase: false, - }) => Ok(Self::Geohash(tag_1.to_string())), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::D, - uppercase: false, - }) => Ok(Self::Identifier(tag_1.to_string())), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::M, - uppercase: false, - }) => Ok(Self::MimeType(tag_1.to_string())), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::X, - uppercase: false, - }) => Ok(Self::Sha256(Sha256Hash::from_str(tag_1)?)), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::U, - uppercase: false, - }) => Ok(Self::AbsoluteURL(Url::parse(tag_1)?)), - TagKind::Dependency => Ok(Self::Dependency(tag_1.to_string())), - TagKind::Relay => { - if tag_1 == ALL_RELAYS { - Ok(Self::AllRelays) - } else { - Ok(Self::Relay(RelayUrl::parse(tag_1)?)) - } - } - TagKind::Expiration => Ok(Self::Expiration(Timestamp::from_str(tag_1)?)), - TagKind::Extension => Ok(Self::Extension(tag_1.to_string())), - TagKind::License => Ok(Self::License(tag_1.to_string())), - // TODO: depending on the event kind, handle the tag in the right way. - TagKind::Response => Ok(Self::PollResponse(tag_1.to_string())), - TagKind::PollType => Ok(Self::PollType(PollType::from_str(tag_1)?)), - TagKind::Runtime => Ok(Self::Runtime(tag_1.to_string())), - TagKind::Repository => Ok(Self::Repository(tag_1.to_string())), - TagKind::Subject => Ok(Self::Subject(tag_1.to_string())), - TagKind::Challenge => Ok(Self::Challenge(tag_1.to_string())), - TagKind::Head => Ok(Self::GitHead(tag_1.to_string())), - TagKind::Commit => Ok(Self::GitCommit(Sha1Hash::from_str(tag_1)?)), - TagKind::MergeBase => Ok(Self::GitMergeBase(Sha1Hash::from_str(tag_1)?)), - TagKind::BranchName => Ok(Self::GitBranchName(tag_1.to_string())), - TagKind::Title => Ok(Self::Title(tag_1.to_string())), - TagKind::Image => Ok(Self::Image(Url::parse(tag_1)?, None)), - TagKind::Thumb => Ok(Self::Thumb(Url::parse(tag_1)?, None)), - TagKind::Server => Ok(Self::Server(Url::parse(tag_1)?)), - TagKind::Summary => Ok(Self::Summary(tag_1.to_string())), - TagKind::PublishedAt => Ok(Self::PublishedAt(Timestamp::from_str(tag_1)?)), - TagKind::Description => Ok(Self::Description(tag_1.to_string())), - TagKind::Bolt11 => Ok(Self::Bolt11(tag_1.to_string())), - TagKind::Preimage => Ok(Self::Preimage(tag_1.to_string())), - TagKind::Amount => Ok(Self::Amount { - millisats: tag_1.parse()?, - bolt11: None, - }), - TagKind::Lnurl => Ok(Self::Lnurl(tag_1.to_string())), - TagKind::Name => Ok(Self::Name(tag_1.to_string())), - TagKind::Url => Ok(Self::Url(Url::parse(tag_1)?)), - TagKind::Magnet => Ok(Self::Magnet(tag_1.to_string())), - TagKind::Blurhash => Ok(Self::Blurhash(tag_1.to_string())), - TagKind::Streaming => Ok(Self::Streaming(Url::parse(tag_1)?)), - TagKind::Recording => Ok(Self::Recording(Url::parse(tag_1)?)), - TagKind::Starts => Ok(Self::Starts(Timestamp::from_str(tag_1)?)), - TagKind::Ends => Ok(Self::Ends(Timestamp::from_str(tag_1)?)), - // TODO: at the moment is not possible to use the nip88::ENDS_AT_TAG_KIND const here. Use it when will be possible. - TagKind::Custom(Cow::Borrowed(nip88::ENDS_AT_TAG_KIND_STR)) => { - Ok(Self::PollEndsAt(Timestamp::from_str(tag_1)?)) - } - TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { - Ok(status) => Ok(Self::DataVendingMachineStatus { - status, - extra_info: None, - }), - Err(_) => Ok(Self::LiveEventStatus(LiveEventStatus::from(tag_1))), /* TODO: check if unknown status error? */ - }, - TagKind::CurrentParticipants => Ok(Self::CurrentParticipants(tag_1.parse()?)), - TagKind::TotalParticipants => Ok(Self::TotalParticipants(tag_1.parse()?)), - #[cfg(feature = "nip98")] - TagKind::Method => Ok(Self::Method(HttpMethod::from_str(tag_1)?)), - TagKind::Payload => Ok(Self::Payload(Sha256Hash::from_str(tag_1)?)), - TagKind::Request => Ok(Self::Request(Event::from_json(tag_1)?)), - TagKind::Word => Ok(Self::Word(tag_1.to_string())), - TagKind::Alt => Ok(Self::Alt(tag_1.to_string())), - TagKind::Dim => Ok(Self::Dim(ImageDimensions::from_str(tag_1)?)), - _ => Err(Error::UnknownStandardizedTag), - }; - } - - if tag_len == 3 { - let tag_1: &str = tag[1].as_ref(); - let tag_2: &str = tag[2].as_ref(); - - return match tag_kind { - TagKind::Option => Ok(Self::PollOption(PollOption { - id: tag_1.to_string(), - text: tag_2.to_string(), - })), - TagKind::Nonce => Ok(Self::POW { - nonce: tag_1.parse()?, - difficulty: tag_2.parse()?, - }), - TagKind::Image => Ok(Self::Image( - Url::parse(tag_1)?, - Some(ImageDimensions::from_str(tag_2)?), - )), - TagKind::Thumb => Ok(Self::Thumb( - Url::parse(tag_1)?, - Some(ImageDimensions::from_str(tag_2)?), - )), - TagKind::Aes256Gcm => Ok(Self::Aes256Gcm { - key: tag_1.to_string(), - iv: tag_2.to_string(), - }), - TagKind::Proxy => Ok(Self::Proxy { - id: tag_1.to_string(), - protocol: Protocol::from(tag_2), - }), - TagKind::Emoji => Ok(Self::Emoji { - shortcode: tag_1.to_string(), - url: Url::parse(tag_2)?, - }), - TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { - Ok(status) => Ok(Self::DataVendingMachineStatus { - status, - extra_info: Some(tag_2.to_string()), - }), - Err(_) => Err(Error::UnknownStandardizedTag), - }, - _ => Err(Error::UnknownStandardizedTag), - }; - } - - Err(Error::UnknownStandardizedTag) - } - - /// Compose `TagStandard::Event` without `relay_url` and `marker` - /// - /// JSON: `["e", "event-id"]` - #[inline] - pub fn event(event_id: EventId) -> Self { - Self::Event { - event_id, - relay_url: None, - marker: None, - public_key: None, - uppercase: false, - } - } - - /// Compose `TagStandard::PublicKey` without `relay_url` and `alias` - /// - /// JSON: `["p", "<public-key>"]` - #[inline] - pub fn public_key(public_key: PublicKey) -> Self { - Self::PublicKey { - public_key, - relay_url: None, - alias: None, - uppercase: false, - } - } - - /// Check if tag is an event `reply` - #[inline] - pub fn is_reply(&self) -> bool { - matches!( - self, - Self::Event { - marker: Some(Marker::Reply), - .. - } - ) - } - - /// Get tag kind - pub fn kind(&self) -> TagKind<'_> { - match self { - Self::Event { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::E, - uppercase: *uppercase, - }), - Self::Quote { .. } | Self::QuoteAddress { .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::Q, - uppercase: false, - }) - } - Self::EventReport(..) => TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)), - Self::GitHead(..) => TagKind::Head, - Self::GitClone(..) => TagKind::Clone, - Self::GitCommit(..) => TagKind::Commit, - Self::GitEarliestUniqueCommitId(..) => { - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)) - } - Self::GitMaintainers(..) => TagKind::Maintainers, - Self::GitMergeBase(..) => TagKind::MergeBase, - Self::GitBranchName(..) => TagKind::BranchName, - Self::PublicKey { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::P, - uppercase: *uppercase, - }), - Self::PublicKeyReport(..) | Self::PublicKeyLiveEvent { .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::P, - uppercase: false, - }) - } - Self::Reference(..) | Self::RelayMetadata { .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::R, - uppercase: false, - }) - } - Self::Hashtag(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::T, - uppercase: false, - }), - Self::Geohash(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::G, - uppercase: false, - }), - Self::Identifier(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::D, - uppercase: false, - }), - Self::ExternalContent { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::I, - uppercase: *uppercase, - }), - Self::ExternalIdentity(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::I, - uppercase: false, - }), - Self::Coordinate { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::A, - uppercase: *uppercase, - }), - Self::Kind { uppercase, .. } | Self::Nip73Kind { uppercase, .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::K, - uppercase: *uppercase, - }) - } - Self::Relay(..) | Self::AllRelays => TagKind::Relay, - Self::PollEndsAt(..) => nip88::ENDS_AT_TAG_KIND, - Self::PollOption { .. } => TagKind::Option, - Self::PollResponse(..) => TagKind::Response, - Self::PollType(..) => TagKind::PollType, - Self::POW { .. } => TagKind::Nonce, - Self::Client { .. } => TagKind::Client, - Self::ContentWarning { .. } => TagKind::ContentWarning, - Self::Expiration(..) => TagKind::Expiration, - Self::Subject(..) => TagKind::Subject, - Self::Challenge(..) => TagKind::Challenge, - Self::Title(..) => TagKind::Title, - Self::Image(..) => TagKind::Image, - Self::Thumb(..) => TagKind::Thumb, - Self::Summary(..) => TagKind::Summary, - Self::PublishedAt(..) => TagKind::PublishedAt, - Self::Description(..) => TagKind::Description, - Self::Bolt11(..) => TagKind::Bolt11, - Self::Preimage(..) => TagKind::Preimage, - Self::Relays(..) => TagKind::Relays, - Self::Amount { .. } => TagKind::Amount, - Self::Name(..) => TagKind::Name, - Self::Dependency(..) => TagKind::Dependency, - Self::Extension(..) => TagKind::Extension, - Self::License(..) => TagKind::License, - Self::Runtime(..) => TagKind::Runtime, - Self::Repository(..) => TagKind::Repository, - Self::Lnurl(..) => TagKind::Lnurl, - Self::Url(..) => TagKind::Url, - Self::MimeType(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::M, - uppercase: false, - }), - Self::Aes256Gcm { .. } => TagKind::Aes256Gcm, - Self::Server(..) => TagKind::Server, - Self::Sha256(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::X, - uppercase: false, - }), - Self::Size(..) => TagKind::Size, - Self::Dim(..) => TagKind::Dim, - Self::Magnet(..) => TagKind::Magnet, - Self::Blurhash(..) => TagKind::Blurhash, - Self::Streaming(..) => TagKind::Streaming, - Self::Recording(..) => TagKind::Recording, - Self::Starts(..) => TagKind::Starts, - Self::Ends(..) => TagKind::Ends, - Self::LiveEventStatus(..) | Self::DataVendingMachineStatus { .. } => TagKind::Status, - Self::CurrentParticipants(..) => TagKind::CurrentParticipants, - Self::TotalParticipants(..) => TagKind::TotalParticipants, - Self::AbsoluteURL(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::U, - uppercase: false, - }), - #[cfg(feature = "nip98")] - Self::Method(..) => TagKind::Method, - Self::Payload(..) => TagKind::Payload, - Self::Anon { .. } => TagKind::Anon, - Self::Proxy { .. } => TagKind::Proxy, - Self::Emoji { .. } => TagKind::Emoji, - Self::Encrypted => TagKind::Encrypted, - Self::Request(..) => TagKind::Request, - Self::Word(..) => TagKind::Word, - Self::LabelNamespace(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::L, - uppercase: true, - }), - Self::Label { .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::L, - uppercase: false, - }), - Self::Protected => TagKind::Protected, - Self::Alt(..) => TagKind::Alt, - Self::Web(..) => TagKind::Web, - } - } - - /// Consume tag and return string vector - #[inline] - pub fn to_vec(self) -> Vec<String> { - self.into() - } -} - -impl From<TagStandard> for Vec<String> { - fn from(standard: TagStandard) -> Self { - let tag_kind: String = standard.kind().to_string(); - let tag: Vec<String> = match standard { - TagStandard::Event { - event_id, - relay_url, - marker, - public_key, - .. - } => { - // ["e", <event-id>, <relay-url>, <marker>, <pubkey>] - // <relay-url>, <marker> and <pubkey> are optional - // <relay-url>, if empty, may be set to "" (if there are additional fields later) - // <marker> is optional and if present is one of "reply", "root", or "mention" (so not an empty string) - - let mut tag: Vec<String> = vec![tag_kind, event_id.to_hex()]; - - // Check if <relay-url> exists or if there are additional fields after - match (relay_url, marker.is_some() || public_key.is_some()) { - (Some(relay_url), ..) => tag.push(relay_url.to_string()), - (None, true) => tag.push(String::new()), - (None, false) => {} - } - - if let Some(marker) = marker { - tag.push(marker.to_string()); - } - - if let Some(public_key) = public_key { - tag.push(public_key.to_string()); - } - - tag - } - TagStandard::Quote { - event_id, - relay_url, - public_key, - } => { - let mut tag = vec![tag_kind, event_id.to_hex()]; - if let Some(relay_url) = relay_url { - tag.push(relay_url.to_string()); - } - if let Some(public_key) = public_key { - // If <relay-url> is `None`, push an empty string - tag.resize_with(3, String::new); - tag.push(public_key.to_hex()); - } - tag - } - TagStandard::QuoteAddress { - coordinate, - relay_url, - } => { - let mut tag = vec![tag_kind, coordinate.to_string()]; - if let Some(relay_url) = relay_url { - tag.push(relay_url.to_string()); - } - tag - } - TagStandard::PublicKey { - public_key, - relay_url, - alias, - .. - } => { - let mut tag = vec![tag_kind, public_key.to_string()]; - if let Some(relay_url) = relay_url { - tag.push(relay_url.to_string()); - } - if let Some(alias) = alias { - tag.resize_with(3, String::new); - tag.push(alias); - } - tag - } - TagStandard::EventReport(id, report) => { - vec![tag_kind, id.to_hex(), report.to_string()] - } - TagStandard::GitHead(branch) => { - vec![tag_kind, format!("{GIT_REFS_HEADS}{branch}")] - } - TagStandard::GitClone(urls) => { - let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); - tag.push(tag_kind); - tag.extend(urls.into_iter().map(|url| url.to_string())); - tag - } - TagStandard::GitCommit(hash) => { - vec![tag_kind, hash.to_string()] - } - TagStandard::GitEarliestUniqueCommitId(commit) => { - vec![tag_kind, commit.to_string(), EUC.to_string()] - } - TagStandard::GitMaintainers(public_keys) => { - let mut tag: Vec<String> = Vec::with_capacity(1 + public_keys.len()); - tag.push(tag_kind); - tag.extend(public_keys.into_iter().map(|val| val.to_string())); - tag - } - TagStandard::GitMergeBase(hash) => { - vec![tag_kind, hash.to_string()] - } - TagStandard::GitBranchName(name) => { - vec![tag_kind, name] - } - TagStandard::PublicKeyReport(pk, report) => { - vec![tag_kind, pk.to_string(), report.to_string()] - } - TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker, - proof, - } => { - let mut tag = vec![ - tag_kind, - public_key.to_string(), - relay_url.map(|u| u.to_string()).unwrap_or_default(), - marker.to_string(), - ]; - if let Some(proof) = proof { - tag.push(proof.to_string()); - } - tag - } - TagStandard::Reference(r) => vec![tag_kind, r], - TagStandard::RelayMetadata { - relay_url, - metadata, - } => { - let mut tag = vec![tag_kind, relay_url.to_string()]; - if let Some(metadata) = metadata { - tag.push(metadata.to_string()); - } - tag - } - TagStandard::Hashtag(t) => vec![tag_kind, t], - TagStandard::Geohash(g) => vec![tag_kind, g], - TagStandard::Identifier(d) => vec![tag_kind, d], - TagStandard::Coordinate { - coordinate, - relay_url, - .. - } => { - let mut vec = vec![tag_kind, coordinate.to_string()]; - if let Some(relay) = relay_url { - vec.push(relay.to_string()); - } - vec - } - TagStandard::ExternalContent { content, hint, .. } => { - let mut tag = vec![tag_kind, content.to_string()]; - - if let Some(hint) = hint { - tag.push(hint.to_string()); - } - - tag - } - TagStandard::ExternalIdentity(identity) => { - vec![tag_kind, identity.tag_platform_identity(), identity.proof] - } - TagStandard::Kind { kind, .. } => vec![tag_kind, kind.to_string()], - TagStandard::Nip73Kind { kind, .. } => vec![tag_kind, kind.to_string()], - TagStandard::Relay(url) => vec![tag_kind, url.to_string()], - TagStandard::AllRelays => vec![tag_kind, ALL_RELAYS.to_string()], - TagStandard::PollEndsAt(ends_at) => vec![tag_kind, ends_at.to_string()], - TagStandard::PollOption(opt) => vec![tag_kind, opt.id, opt.text], - TagStandard::PollResponse(response) => vec![tag_kind, response], - TagStandard::PollType(t) => vec![tag_kind, t.to_string()], - TagStandard::POW { nonce, difficulty } => { - vec![tag_kind, nonce.to_string(), difficulty.to_string()] - } - TagStandard::Client { name, address } => { - let mut tag: Vec<String> = vec![tag_kind, name]; - - match address { - Some((coordinate, Some(hint))) => { - tag.reserve_exact(2); - tag.push(coordinate.to_string()); - tag.push(hint.to_string()); - } - Some((coordinate, None)) => { - tag.push(coordinate.to_string()); - } - _ => {} - } - - tag - } - TagStandard::ContentWarning { reason } => { - let mut tag = vec![tag_kind]; - if let Some(reason) = reason { - tag.push(reason); - } - tag - } - TagStandard::Expiration(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } - TagStandard::Subject(sub) => vec![tag_kind, sub], - TagStandard::Challenge(challenge) => vec![tag_kind, challenge], - TagStandard::Title(title) => vec![tag_kind, title], - TagStandard::Image(image, dimensions) => { - let mut tag = vec![tag_kind, image.to_string()]; - if let Some(dim) = dimensions { - tag.push(dim.to_string()); - } - tag - } - TagStandard::Thumb(thumb, dimensions) => { - let mut tag = vec![tag_kind, thumb.to_string()]; - if let Some(dim) = dimensions { - tag.push(dim.to_string()); - } - tag - } - TagStandard::Summary(summary) => vec![tag_kind, summary], - TagStandard::PublishedAt(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } - TagStandard::Description(description) => { - vec![tag_kind, description] - } - TagStandard::Bolt11(bolt11) => { - vec![tag_kind, bolt11] - } - TagStandard::Preimage(preimage) => { - vec![tag_kind, preimage] - } - TagStandard::Relays(relays) => vec![tag_kind] - .into_iter() - .chain(relays.iter().map(|relay| relay.to_string())) - .collect::<Vec<_>>(), - TagStandard::Amount { millisats, bolt11 } => { - let mut tag = vec![tag_kind, millisats.to_string()]; - if let Some(bolt11) = bolt11 { - tag.push(bolt11); - } - tag - } - TagStandard::Name(name) => vec![tag_kind, name], - TagStandard::Dependency(dep) => vec![tag_kind, dep], - TagStandard::Extension(ext) => vec![tag_kind, ext], - TagStandard::License(license) => vec![tag_kind, license], - TagStandard::Runtime(runtime) => vec![tag_kind, runtime], - TagStandard::Repository(repo) => vec![tag_kind, repo], - TagStandard::Lnurl(lnurl) => vec![tag_kind, lnurl], - TagStandard::Url(url) => vec![tag_kind, url.to_string()], - TagStandard::MimeType(mime) => vec![tag_kind, mime], - TagStandard::Aes256Gcm { key, iv } => vec![tag_kind, key, iv], - TagStandard::Server(url) => vec![tag_kind, url.to_string()], - TagStandard::Sha256(hash) => vec![tag_kind, hash.to_string()], - TagStandard::Size(bytes) => vec![tag_kind, bytes.to_string()], - TagStandard::Dim(dim) => vec![tag_kind, dim.to_string()], - TagStandard::Magnet(uri) => vec![tag_kind, uri], - TagStandard::Blurhash(data) => vec![tag_kind, data], - TagStandard::Streaming(url) => vec![tag_kind, url.to_string()], - TagStandard::Recording(url) => vec![tag_kind, url.to_string()], - TagStandard::Starts(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } - TagStandard::Ends(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } - TagStandard::LiveEventStatus(s) => { - vec![tag_kind, s.to_string()] - } - TagStandard::CurrentParticipants(num) => { - vec![tag_kind, num.to_string()] - } - TagStandard::TotalParticipants(num) => { - vec![tag_kind, num.to_string()] - } - TagStandard::AbsoluteURL(url) => { - vec![tag_kind, url.to_string()] - } - #[cfg(feature = "nip98")] - TagStandard::Method(method) => { - vec![tag_kind, method.to_string()] - } - TagStandard::Payload(p) => vec![tag_kind, p.to_string()], - TagStandard::Anon { msg } => { - let mut tag = vec![tag_kind]; - if let Some(msg) = msg { - tag.push(msg); - } - tag - } - TagStandard::Proxy { id, protocol } => { - vec![tag_kind, id, protocol.to_string()] - } - TagStandard::Emoji { shortcode, url } => { - vec![tag_kind, shortcode, url.to_string()] - } - TagStandard::Encrypted => vec![tag_kind], - TagStandard::Request(event) => vec![tag_kind, event.as_json()], - TagStandard::DataVendingMachineStatus { status, extra_info } => { - let mut tag = vec![tag_kind, status.to_string()]; - if let Some(extra_info) = extra_info { - tag.push(extra_info); - } - tag - } - TagStandard::Word(word) => vec![tag_kind, word], - TagStandard::LabelNamespace(n) => vec![tag_kind, n], - TagStandard::Label { value, namespace } => { - let mut tag: Vec<String> = vec![tag_kind, value]; - if let Some(namespace) = namespace { - tag.push(namespace); - } - tag - } - TagStandard::Protected => vec![tag_kind], - TagStandard::Alt(summary) => vec![tag_kind, summary], - TagStandard::Web(urls) => { - let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); - tag.push(tag_kind); - tag.extend(urls.into_iter().map(|url| url.to_string())); - tag - } - }; - - // Tag can't be empty, require at least 1 value - assert!(!tag.is_empty(), "Empty tag"); - - tag - } -} - -fn parse_a_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() >= 2 { - Ok(TagStandard::Coordinate { - coordinate: Coordinate::from_str(tag[1].as_ref())?, - relay_url: match tag.get(2).map(|u| u.as_ref()) { - Some(url) if !url.is_empty() => Some(RelayUrl::parse(url)?), - _ => None, - }, - uppercase, - }) - } else { - Err(Error::UnknownStandardizedTag) - } -} - -fn parse_e_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - let event_id: EventId = EventId::from_hex(tag[1].as_ref())?; - - // Try getting indexes 2, 3 and 4 and make sure they are not empty. - // If these are empty, they are handled as None. - let tag_2: Option<&str> = tag.get(2).map(|r| r.as_ref()).filter(|r| !r.is_empty()); - // "mention" is a removed marker from NIP-10 - let tag_3: Option<&str> = tag - .get(3) - .map(|r| r.as_ref()) - .filter(|r| !r.is_empty() && *r != "mention"); - let tag_4: Option<&str> = tag.get(4).map(|r| r.as_ref()).filter(|r| !r.is_empty()); - - // Check if it's a report - if let Some(tag_2) = tag_2 { - if let Ok(report) = Report::from_str(tag_2) { - if uppercase { - // Uppercase report, invalid! - return Err(Error::UnknownStandardizedTag); - } - - return Ok(TagStandard::EventReport(event_id, report)); - } - } - - // Parse 2nd arg - let relay_url: Option<RelayUrl> = match tag_2 { - Some(url) => Some(RelayUrl::parse(url)?), - None => None, - }; - - // Check if 3rd arg is a marker or a public key - let (marker, public_key) = match (tag_3, tag_4) { - (Some(marker), Some(public_key)) => { - // Parse marker and public key - // NOTE: we already checked above if the strings are empty - let marker: Marker = Marker::from_str(marker)?; - let public_key: PublicKey = PublicKey::from_hex(public_key)?; - - (Some(marker), Some(public_key)) - } - (Some(marker), None) => { - // NOTE: we already checked above if the strings are empty - // Try parse 3rd arg as a marked (NIP-10) - match Marker::from_str(marker) { - Ok(marker) => (Some(marker), None), - // It's not a marker, try to parse it as a public key (NIP-01) - Err(..) => { - let public_key: PublicKey = PublicKey::from_hex(marker)?; - (None, Some(public_key)) - } - } - } - (None, Some(public_key)) => { - // NOTE: we already checked above if the string is empty - let public_key = PublicKey::from_hex(public_key)?; - (None, Some(public_key)) - } - (None, None) => (None, None), - }; - - Ok(TagStandard::Event { - event_id, - relay_url, - marker, - public_key, - uppercase, - }) -} - -fn parse_i_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // External Content ID (NIP73) has min 2 values - // External Identity (NI39) has min 3 values - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - let tag_1: &str = tag[1].as_ref(); - let tag_2: Option<&str> = tag.get(2).map(|t| t.as_ref()); - - // Check if External Identity (NIP39) - if !uppercase { - if let Some(tag_2) = tag_2 { - if let Ok(identity) = Identity::new(tag_1, tag_2) { - return Ok(TagStandard::ExternalIdentity(identity)); - } - } - } - - // Check if External Content ID (NIP73) - if let Ok(content) = ExternalContentId::from_str(tag_1) { - return Ok(TagStandard::ExternalContent { - content, - hint: match tag_2 { - Some(url) => Some(Url::parse(url)?), - None => None, - }, - uppercase, - }); - } - - Err(Error::UnknownStandardizedTag) -} - -fn parse_l_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // ["L", "<namespace>"] - if uppercase && tag.len() == 2 { - let tag_1: &str = tag[1].as_ref(); - return Ok(TagStandard::LabelNamespace(tag_1.to_string())); - } - - // ["l", "<label>"] or ["l", "<label>", "<namespace>"] - if !uppercase && tag.len() >= 2 { - let tag_1: &str = tag[1].as_ref(); - let tag_2: Option<&str> = tag.get(2).map(|t| t.as_ref()); - - return Ok(TagStandard::Label { - value: tag_1.to_string(), - namespace: tag_2.map(|n| n.to_string()), - }); - } - - Err(Error::UnknownStandardizedTag) -} - -fn parse_p_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() >= 2 { - let public_key: PublicKey = PublicKey::from_hex(tag[1].as_ref())?; - - if tag.len() >= 5 && !uppercase { - let tag_2: &str = tag[2].as_ref(); - let tag_3: &str = tag[3].as_ref(); - let tag_4: &str = tag[4].as_ref(); - - return Ok(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url: if !tag_2.is_empty() { - Some(RelayUrl::parse(tag_2)?) - } else { - None - }, - marker: LiveEventMarker::from_str(tag_3)?, - proof: Signature::from_str(tag_4).ok(), - }); - } - - if tag.len() >= 4 && !uppercase { - let tag_2: &str = tag[2].as_ref(); - let tag_3: &str = tag[3].as_ref(); - - let relay_url: Option<RelayUrl> = if !tag_2.is_empty() { - Some(RelayUrl::parse(tag_2)?) - } else { - None - }; - - return match LiveEventMarker::from_str(tag_3) { - Ok(marker) => Ok(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker, - proof: None, - }), - Err(_) => Ok(TagStandard::PublicKey { - public_key, - relay_url, - alias: (!tag_3.is_empty()).then_some(tag_3.to_string()), - uppercase, - }), - }; - } - - if tag.len() >= 3 && !uppercase { - let tag_2: &str = tag[2].as_ref(); - - return if tag_2.is_empty() { - Ok(TagStandard::PublicKey { - public_key, - relay_url: None, - alias: None, - uppercase, - }) - } else { - match Report::from_str(tag_2) { - Ok(report) => Ok(TagStandard::PublicKeyReport(public_key, report)), - Err(_) => Ok(TagStandard::PublicKey { - public_key, - relay_url: Some(RelayUrl::parse(tag_2)?), - alias: None, - uppercase, - }), - } - }; - } - - Ok(TagStandard::PublicKey { - public_key, - relay_url: None, - alias: None, - uppercase, - }) - } else { - Err(Error::UnknownStandardizedTag) - } -} - -fn parse_r_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() >= 3 && !uppercase { - let tag_1: &str = tag[1].as_ref(); - let tag_2: &str = tag[2].as_ref(); - - return if tag_1.starts_with("ws://") || tag_1.starts_with("wss://") { - Ok(TagStandard::RelayMetadata { - relay_url: RelayUrl::parse(tag_1)?, - metadata: Some(RelayMetadata::from_str(tag_2)?), - }) - } else if tag_2 == EUC { - // ["r", "<commit-id>", "euc"] - let commit: Sha1Hash = Sha1Hash::from_str(tag_1)?; - Ok(TagStandard::GitEarliestUniqueCommitId(commit)) - } else { - Err(Error::UnknownStandardizedTag) - }; - } - - if tag.len() >= 2 && !uppercase { - let tag_1: &str = tag[1].as_ref(); - - return if tag_1.starts_with("ws://") || tag_1.starts_with("wss://") { - Ok(TagStandard::RelayMetadata { - relay_url: RelayUrl::parse(tag_1)?, - metadata: None, - }) - } else { - Ok(TagStandard::Reference(tag_1.to_string())) - }; - } - - Err(Error::UnknownStandardizedTag) -} - -fn parse_q_tag<S>(tag: &[S]) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - let tag_1 = tag[1].as_ref(); - let tag_2: Option<&str> = tag.get(2).map(|r| r.as_ref()); - - let relay_url: Option<RelayUrl> = match tag_2 { - Some(url) if !url.is_empty() => Some(RelayUrl::parse(url)?), - _ => None, - }; - - match EventId::from_hex(tag_1) { - Ok(event_id) => { - let tag_3: Option<&str> = tag.get(3).map(|r| r.as_ref()); - - let public_key: Option<PublicKey> = match tag_3 { - Some(public_key) => Some(PublicKey::from_hex(public_key)?), - None => None, - }; - - Ok(TagStandard::Quote { - event_id, - relay_url, - public_key, - }) - } - Err(_) => Ok(TagStandard::QuoteAddress { - coordinate: Coordinate::from_str(tag_1)?, - relay_url, - }), - } -} - -fn parse_t_tag<S>(tag: &[S]) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // ["t", "<hashtag>"] - - let hashtag: &str = tag.get(1).ok_or(Error::UnknownStandardizedTag)?.as_ref(); - - // Not all languages have distinct uppercase and lowercase letters. - // `char::is_uppercase` and `char::is_lowercase` will return `false` for those languages. - // So, to verify that a hashtag is invalid (non-lowercase), - // check if there is at least one uppercase char. - if hashtag.chars().any(char::is_uppercase) { - return Err(Error::UnknownStandardizedTag); - } - - Ok(TagStandard::Hashtag(hashtag.to_string())) -} - -fn parse_k_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // ["k", "<kind>"] - - let kind: &str = tag.get(1).ok_or(Error::UnknownStandardizedTag)?.as_ref(); - - if let Ok(kind_number) = u16::from_str(kind) { - Ok(TagStandard::Kind { - kind: Kind::from_u16(kind_number), - uppercase, - }) - } else { - Ok(TagStandard::Nip73Kind { - kind: Nip73Kind::from_str(kind).map_err(|_| Error::UnknownStandardizedTag)?, - uppercase, - }) - } -} - -fn parse_client_tag<S>(tag: &[S]) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // Possible cases: - // - ["client", "My Client"] - // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>"] - // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>", "wss://relay1"] - - // Require at least 2 values - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - // The client name - let tag_1: &str = tag[1].as_ref(); - - // Optionally, the coordinate and the relay hint - let tag_2: Option<&str> = tag.get(2).map(|t| t.as_ref()); - let tag_3: Option<&str> = tag.get(3).map(|t| t.as_ref()); - - // Since the address is optional, - // don't return an error if the coordinate or relay hint parsing fails. - let address: Option<(Coordinate, Option<RelayUrl>)> = match tag_2 { - // Try to parse the coordinate - Some(coordinate) => match Coordinate::parse(coordinate) { - // Coordinate parsing success - Ok(coordinate) => { - let relay_url: Option<RelayUrl> = tag_3.and_then(|url| RelayUrl::parse(url).ok()); - Some((coordinate, relay_url)) - } - // Failed to parse the coordinate - Err(..) => None, - }, - // Nothing to parse - None => None, - }; - - // Construct tag - Ok(TagStandard::Client { - name: tag_1.to_string(), - address, - }) -} - -#[inline] -fn extract_optional_string<S>(tag: &[S], index: usize) -> Option<&str> -where - S: AsRef<str>, -{ - match tag.get(index).map(|t| t.as_ref()) { - Some(t) => (!t.is_empty()).then_some(t), - None => None, - } -} - -fn extract_urls<S>(tag: &[S]) -> Result<Vec<Url>, Error> -where - S: AsRef<str>, -{ - // Skip index 0 because is the tag kind - let mut list: Vec<Url> = Vec::with_capacity(tag.len().saturating_sub(1)); - for url in tag.iter().skip(1) { - list.push(Url::parse(url.as_ref())?); - } - Ok(list) -} - -fn extract_relay_urls<S>(tag: &[S]) -> Result<Vec<RelayUrl>, Error> -where - S: AsRef<str>, -{ - // Skip index 0 because is the tag kind - let mut list: Vec<RelayUrl> = Vec::with_capacity(tag.len().saturating_sub(1)); - for url in tag.iter().skip(1) { - list.push(RelayUrl::parse(url.as_ref())?); - } - Ok(list) -} - -fn extract_public_keys<S>(tag: &[S]) -> Result<Vec<PublicKey>, Error> -where - S: AsRef<str>, -{ - // Skip index 0 because is the tag kind - let mut list: Vec<PublicKey> = Vec::with_capacity(tag.len().saturating_sub(1)); - for pkey in tag.iter().skip(1) { - list.push(PublicKey::parse(pkey.as_ref())?); - } - Ok(list) -} - -#[cfg(test)] -mod tests { - use alloc::borrow::ToOwned; - - use super::*; - use crate::nips::nip39::ExternalIdentity; - - // Issue: https://gitworkshop.dev/yukikishimoto.com/nostr/issues/note15xl8ae8dnmt26adfw6ec8gshxxs242vrvsa3v36ctwq2x9gglkustlxlwa - #[test] - fn tag_e_tag_with_blank_values() { - let hex = "a3ce0a22c5c25e5a41a17004d38ed2aa8f815dda918c92400c6b611c41acbc78"; - let id = EventId::from_hex(hex).unwrap(); - - let result = TagStandard::parse(&["e", hex, "", "", ""]).unwrap(); - assert_eq!( - result, - TagStandard::Event { - event_id: id, - relay_url: None, - marker: None, - public_key: None, - uppercase: false - } - ) - } - - #[test] - fn test_tag_standard_is_reply() { - let tag = TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()); - assert!(!tag.is_reply()); - - let tag = TagStandard::Event { - event_id: EventId::from_hex( - "2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45", - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: None, - uppercase: false, - }; - assert!(tag.is_reply()); - - let tag = TagStandard::Event { - event_id: EventId::from_hex( - "2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45", - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Root), - public_key: None, - uppercase: false, - }; - assert!(!tag.is_reply()); - } - - #[test] - fn test_nip73_kind() { - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::Url, - uppercase: true, - }; - assert_eq!(tag.to_vec(), vec!["K", "web"]); - - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::PodcastEpisode, - uppercase: false, - }; - assert_eq!(tag.to_vec(), vec!["k", "podcast:item:guid"]); - - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::BlockchainTransaction("monero".to_owned()), - uppercase: false, - }; - assert_eq!(tag.to_vec(), vec!["k", "monero:tx"]); - - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::BlockchainAddress("monero".to_owned()), - uppercase: true, - }; - assert_eq!(tag.to_vec(), vec!["K", "monero:address"]); - - assert_eq!( - TagStandard::parse(&["k", "isbn"]).unwrap(), - TagStandard::Nip73Kind { - kind: Nip73Kind::Book, - uppercase: false - } - ); - assert_eq!( - TagStandard::parse(&["K", "monero:address"]).unwrap(), - TagStandard::Nip73Kind { - kind: Nip73Kind::BlockchainAddress("monero".to_owned()), - uppercase: true - } - ); - } - - #[test] - fn test_kind() { - let tag = TagStandard::Kind { - kind: Kind::Comment, - uppercase: false, - }; - assert_eq!(tag.to_vec(), vec!["k", "1111"]); - - assert_eq!( - TagStandard::parse(&["K", "1617"]).unwrap(), - TagStandard::Kind { - kind: Kind::GitPatch, - uppercase: true - } - ); - } - - #[test] - fn test_tag_standard_serialization() { - assert_eq!(vec!["-"], TagStandard::Protected.to_vec()); - - assert_eq!( - vec!["alt", "something"], - TagStandard::Alt(String::from("something")).to_vec() - ); - - assert_eq!( - vec!["content-warning"], - TagStandard::ContentWarning { reason: None }.to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ], - TagStandard::public_key( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ) - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ], - TagStandard::event( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap() - ) - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ], - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: None, - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ], - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - public_key: None, - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ], - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: Some( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - ], - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: None, - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - "wss://relay.damus.io" - ], - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - } - .to_vec() - ); - - assert_eq!( - vec!["expiration", "1600000000"], - TagStandard::Expiration(Timestamp::from(1600000000)).to_vec() - ); - - assert_eq!( - vec!["content-warning", "reason"], - TagStandard::ContentWarning { - reason: Some(String::from("reason")) - } - .to_vec() - ); - - assert_eq!( - vec!["subject", "textnote with subject"], - TagStandard::Subject(String::from("textnote with subject")).to_vec() - ); - - assert_eq!( - vec!["d", "test"], - TagStandard::Identifier(String::from("test")).to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io" - ], - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - alias: None, - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - ], - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: None, - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ], - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: None, - public_key: None, - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "spam" - ], - TagStandard::PublicKeyReport( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - Report::Spam - ) - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "nudity" - ], - TagStandard::EventReport( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - Report::Nudity, - ) - .to_vec() - ); - - assert_eq!( - vec!["nonce", "1", "20"], - TagStandard::POW { - nonce: 1, - difficulty: 20 - } - .to_vec() - ); - - assert_eq!( - vec!["endsAt", "1600000000"], - TagStandard::PollEndsAt(Timestamp::from(1600000000)).to_vec() - ); - - assert_eq!( - vec!["option", "qj518h583", "Yay"], - TagStandard::PollOption(PollOption { - id: String::from("qj518h583"), - text: String::from("Yay"), - }) - .to_vec() - ); - - assert_eq!( - vec!["response", "qj518h583"], - TagStandard::PollResponse(String::from("qj518h583")).to_vec() - ); - - assert_eq!( - vec!["polltype", "singlechoice"], - TagStandard::PollType(PollType::SingleChoice).to_vec() - ); - - assert_eq!( - vec!["polltype", "multiplechoice"], - TagStandard::PollType(PollType::MultipleChoice).to_vec() - ); - - assert_eq!( - vec!["client", "voyage"], - TagStandard::Client { - name: String::from("voyage"), - address: None - } - .to_vec() - ); - - assert_eq!( - vec!["dep", "nostr"], - TagStandard::Dependency(String::from("nostr")).to_vec() - ); - - assert_eq!( - vec!["extension", "rs"], - TagStandard::Extension(String::from("rs")).to_vec() - ); - - assert_eq!( - vec!["license", "MIT"], - TagStandard::License(String::from("MIT")).to_vec() - ); - - assert_eq!( - vec!["runtime", "rustc 1.70.0"], - TagStandard::Runtime(String::from("rustc 1.70.0")).to_vec() - ); - - assert_eq!( - vec!["repo", "https://github.com/rust-nostr/nostr"], - TagStandard::Repository(String::from("https://github.com/rust-nostr/nostr")).to_vec() - ); - - assert_eq!( - vec!["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum"], - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - }.to_vec() - ); - - assert_eq!( - vec!["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum"], - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - }.to_vec() - ); - - assert_eq!( - vec!["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", "wss://relay.damus.io"], - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), Some(RelayUrl::parse("wss://relay.damus.io").unwrap()))) - }.to_vec() - ); - - assert_eq!( - vec![ - "a", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum" - ], - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: None, - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "a", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", - "wss://relay.nostr.org" - ], - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: Some(RelayUrl::parse("wss://relay.nostr.org").unwrap()), - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io", - "Speaker", - ], - TagStandard::PublicKeyLiveEvent { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: LiveEventMarker::Speaker, - proof: None - } - .to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "", - "Participant", - ], - TagStandard::PublicKeyLiveEvent { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: None, - marker: LiveEventMarker::Participant, - proof: None - } - .to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io", - "alias", - ], - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - alias: Some(String::from("alias")), - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "reply" - ], - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: None, - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "0000000000000000000000000000000000000000000000000000000000000001", - "", - "root", - "0000000000000000000000000000000000000000000000000000000000000001", - ], - TagStandard::Event { - event_id: EventId::from_hex( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Root), - public_key: Some( - PublicKey::parse( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap() - ), - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "0000000000000000000000000000000000000000000000000000000000000001", - "", - "0000000000000000000000000000000000000000000000000000000000000001", - ], - TagStandard::Event { - event_id: EventId::from_hex( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: Some( - PublicKey::parse( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap() - ), - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec!["relay", "wss://relay.damus.io"], - TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()).to_vec() - ); - - assert_eq!(vec!["relay", "ALL_RELAYS"], TagStandard::AllRelays.to_vec()); - - assert_eq!( - vec!["lnurl", "lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp"], - TagStandard::Lnurl(String::from("lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp")).to_vec(), - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io", - "Host", - "a5d9290ef9659083c490b303eb7ee41356d8778ff19f2f91776c8dc4443388a64ffcf336e61af4c25c05ac3ae952d1ced889ed655b67790891222aaa15b99fdd" - ], - TagStandard::PublicKeyLiveEvent { - public_key: PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ).unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: LiveEventMarker::Host, - proof: Some(Signature::from_str("a5d9290ef9659083c490b303eb7ee41356d8778ff19f2f91776c8dc4443388a64ffcf336e61af4c25c05ac3ae952d1ced889ed655b67790891222aaa15b99fdd").unwrap()) - }.to_vec() - ); - - assert_eq!( - vec!["L", "#t"], - TagStandard::LabelNamespace("#t".to_string()).to_vec() - ); - - assert_eq!( - vec!["l", "IT-MI"], - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: None - } - .to_vec() - ); - - assert_eq!( - vec!["l", "IT-MI", "ISO-3166-2"], - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: Some("ISO-3166-2".to_string()) - } - .to_vec() - ); - - assert_eq!( - vec!["r", "wss://atlas.nostr.land/"], - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land/").unwrap(), - metadata: None - } - .to_vec() - ); - - assert_eq!( - vec!["r", "wss://atlas.nostr.land/", "read"], - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land/").unwrap(), - metadata: Some(RelayMetadata::Read) - } - .to_vec() - ); - - assert_eq!( - vec!["r", "wss://atlas.nostr.land", "write"], - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land").unwrap(), - metadata: Some(RelayMetadata::Write) - } - .to_vec() - ); - - assert_eq!( - vec!["r", "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", "euc"], - TagStandard::GitEarliestUniqueCommitId( - Sha1Hash::from_str("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2").unwrap() - ) - .to_vec() - ); - - assert_eq!( - vec!["clone", "https://github.com/rust-nostr/nostr.git",], - TagStandard::GitClone(vec![ - Url::parse("https://github.com/rust-nostr/nostr.git").unwrap() - ]) - .to_vec() - ); - - assert_eq!( - vec![ - "maintainers", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ], - TagStandard::GitMaintainers(vec![ - PublicKey::from_hex( - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" - ) - .unwrap(), - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - ]) - .to_vec() - ); - - assert_eq!( - vec![ - "web", - "https://rust-nostr.org/", - "https://github.com/rust-nostr", - ], - TagStandard::Web(vec![ - Url::parse("https://rust-nostr.org").unwrap(), - Url::parse("https://github.com/rust-nostr").unwrap(), - ]) - .to_vec() - ); - } - - #[test] - fn test_tag_standard_parsing() { - assert_eq!(TagStandard::parse(&["-"]).unwrap(), TagStandard::Protected); - - assert_eq!( - TagStandard::parse(&["alt", "something"]).unwrap(), - TagStandard::Alt(String::from("something")) - ); - - assert_eq!( - TagStandard::parse(&["content-warning"]).unwrap(), - TagStandard::ContentWarning { reason: None } - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::public_key( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ]) - .unwrap(), - TagStandard::event( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap() - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "E", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ]), - Ok(TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: None, - uppercase: true - }) - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ]) - .unwrap(), - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: None, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ]) - .unwrap(), - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - public_key: None, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: Some( - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - ]).unwrap(), - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: None, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - "wss://relay.damus.io" - ]).unwrap(), - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - } - ); - - assert_eq!( - TagStandard::parse(&["expiration", "1600000000"]).unwrap(), - TagStandard::Expiration(Timestamp::from(1600000000)) - ); - - assert_eq!( - TagStandard::parse(&["content-warning", "reason"]).unwrap(), - TagStandard::ContentWarning { - reason: Some(String::from("reason")) - } - ); - - assert_eq!( - TagStandard::parse(&["subject", "textnote with subject"]).unwrap(), - TagStandard::Subject(String::from("textnote with subject")) - ); - - assert_eq!( - TagStandard::parse(&["d", "test"]).unwrap(), - TagStandard::Identifier(String::from("test")) - ); - - assert_eq!( - TagStandard::parse(&["r", "https://example.com"]).unwrap(), - TagStandard::Reference(String::from("https://example.com")) - ); - - assert_eq!( - TagStandard::parse(&["i", "isbn:9780765382030"]).unwrap(), - TagStandard::ExternalContent { - content: ExternalContentId::Book(String::from("9780765382030")), - hint: None, - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "i", - "podcast:guid:c90e609a-df1e-596a-bd5e-57bcc8aad6cc", - "https://podcastindex.org/podcast/c90e609a-df1e-596a-bd5e-57bcc8aad6cc" - ]) - .unwrap(), - TagStandard::ExternalContent { - content: ExternalContentId::PodcastFeed(String::from( - "c90e609a-df1e-596a-bd5e-57bcc8aad6cc" - )), - hint: Some( - Url::parse( - "https://podcastindex.org/podcast/c90e609a-df1e-596a-bd5e-57bcc8aad6cc" - ) - .unwrap() - ), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&["i", "github:12345678", "abcdefghijklmnop"]).unwrap(), - TagStandard::ExternalIdentity(Identity { - platform: ExternalIdentity::GitHub, - ident: "12345678".to_string(), - proof: "abcdefghijklmnop".to_string() - }) - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io" - ]) - .unwrap(), - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - alias: None, - uppercase: false - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: None, - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: None, - public_key: None, - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "impersonation" - ]) - .unwrap(), - TagStandard::PublicKeyReport( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - Report::Impersonation - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "other" - ]) - .unwrap(), - TagStandard::PublicKeyReport( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - Report::Other - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "profanity" - ]) - .unwrap(), - TagStandard::EventReport( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - Report::Profanity - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "malware" - ]) - .unwrap(), - TagStandard::EventReport( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - Report::Malware - ) - ); - - assert_eq!( - TagStandard::parse(&["nonce", "1", "20"]).unwrap(), - TagStandard::POW { - nonce: 1, - difficulty: 20 - } - ); - - assert_eq!( - TagStandard::parse(&["endsAt", "1600000000"]).unwrap(), - TagStandard::PollEndsAt(Timestamp::from(1600000000)) - ); - - assert_eq!( - TagStandard::parse(&["option", "qj518h583", "Yay"]).unwrap(), - TagStandard::PollOption(PollOption { - id: String::from("qj518h583"), - text: String::from("Yay"), - }) - ); - - assert_eq!( - TagStandard::parse(&["response", "qj518h583"]).unwrap(), - TagStandard::PollResponse(String::from("qj518h583")) - ); - - assert_eq!( - TagStandard::parse(&["polltype", "singlechoice"]).unwrap(), - TagStandard::PollType(PollType::SingleChoice) - ); - - assert_eq!( - TagStandard::parse(&["polltype", "multiplechoice"]).unwrap(), - TagStandard::PollType(PollType::MultipleChoice) - ); - - assert_eq!( - TagStandard::parse(&["client", "voyage"]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: None - } - ); - - assert_eq!( - TagStandard::parse(&["dep", "nostr"]).unwrap(), - TagStandard::Dependency(String::from("nostr")) - ); - - assert_eq!( - TagStandard::parse(&["extension", "rs"]).unwrap(), - TagStandard::Extension(String::from("rs")) - ); - - assert_eq!( - TagStandard::parse(&["license", "MIT"]).unwrap(), - TagStandard::License(String::from("MIT")) - ); - - assert_eq!( - TagStandard::parse(&["runtime", "rustc 1.70.0"]).unwrap(), - TagStandard::Runtime(String::from("rustc 1.70.0")) - ); - - assert_eq!( - TagStandard::parse(&["repo", "https://github.com/rust-nostr/nostr"]).unwrap(), - TagStandard::Repository(String::from("https://github.com/rust-nostr/nostr")) - ); - - assert_eq!( - TagStandard::parse(&["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum"]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - } - ); - - assert_eq!( - TagStandard::parse(&["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", ""]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - } - ); - - assert_eq!( - TagStandard::parse(&["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", "wss://relay.damus.io"]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), Some(RelayUrl::parse("wss://relay.damus.io").unwrap()))) - } - ); - - assert_eq!( - TagStandard::parse(&[ - "a", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", - "wss://relay.nostr.org" - ]) - .unwrap(), - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: Some(RelayUrl::parse("wss://relay.nostr.org").unwrap()), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "A", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", - "wss://relay.nostr.org" - ]) - .unwrap(), - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: Some(RelayUrl::parse("wss://relay.nostr.org").unwrap()), - uppercase: true, - } - ); - - assert_eq!( - TagStandard::parse(&["r", "wss://atlas.nostr.land/"]).unwrap(), - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land/").unwrap(), - metadata: None - } - ); - - assert_eq!( - TagStandard::parse(&["r", "wss://atlas.nostr.land", "read"]).unwrap(), - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land").unwrap(), - metadata: Some(RelayMetadata::Read) - } - ); - - assert_eq!( - TagStandard::parse(&["r", "wss://atlas.nostr.land", "write"]).unwrap(), - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land").unwrap(), - metadata: Some(RelayMetadata::Write) - } - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io/", - "alias", - ]) - .unwrap(), - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), - alias: Some(String::from("alias")), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "reply" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: None, - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "reply", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: Some( - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: Some( - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&["relay", "wss://relay.damus.io"]).unwrap(), - TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) - ); - - assert_eq!( - TagStandard::parse(&["relay", "ALL_RELAYS"]).unwrap(), - TagStandard::AllRelays - ); - - assert_eq!( - TagStandard::parse(&[ - "relays", - "wss://relay.damus.io/", - "wss://nostr-relay.wlvs.space/", - "wss://nostr.fmt.wiz.biz/" - ]) - .unwrap(), - TagStandard::Relays(vec![ - RelayUrl::parse("wss://relay.damus.io/").unwrap(), - RelayUrl::parse("wss://nostr-relay.wlvs.space/").unwrap(), - RelayUrl::parse("wss://nostr.fmt.wiz.biz").unwrap(), - ]) - ); - - assert_eq!( - TagStandard::parse(&[ - "bolt11", - "lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0"]).unwrap(), - TagStandard::Bolt11("lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0".to_string()) - ); - - assert_eq!( - TagStandard::parse(&[ - "preimage", - "5d006d2cf1e73c7148e7519a4c68adc81642ce0e25a432b2434c99f97344c15f" - ]) - .unwrap(), - TagStandard::Preimage( - "5d006d2cf1e73c7148e7519a4c68adc81642ce0e25a432b2434c99f97344c15f".to_string() - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "description", - "{\"pubkey\":\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\",\"content\":\"\",\"id\":\"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d\",\"created_at\":1674164539,\"sig\":\"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d\",\"kind\":9734,\"tags\":[[\"e\",\"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"relays\",\"wss://relay.damus.io\",\"wss://nostr-relay.wlvs.space\",\"wss://nostr.fmt.wiz.biz\",\"wss://relay.nostr.bg\",\"wss://nostr.oxtr.dev\",\"wss://nostr.v0l.io\",\"wss://brb.io\",\"wss://nostr.bitcoiner.social\",\"ws://monad.jb55.com:8080\",\"wss://relay.snort.social\"]]}" - ]).unwrap(), - TagStandard::Description("{\"pubkey\":\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\",\"content\":\"\",\"id\":\"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d\",\"created_at\":1674164539,\"sig\":\"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d\",\"kind\":9734,\"tags\":[[\"e\",\"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"relays\",\"wss://relay.damus.io\",\"wss://nostr-relay.wlvs.space\",\"wss://nostr.fmt.wiz.biz\",\"wss://relay.nostr.bg\",\"wss://nostr.oxtr.dev\",\"wss://nostr.v0l.io\",\"wss://brb.io\",\"wss://nostr.bitcoiner.social\",\"ws://monad.jb55.com:8080\",\"wss://relay.snort.social\"]]}".to_string()) - ); - - assert_eq!( - TagStandard::parse(&["amount", "10000"]).unwrap(), - TagStandard::Amount { - millisats: 10_000, - bolt11: None - } - ); - - assert_eq!( - TagStandard::parse(&["L", "#t"]).unwrap(), - TagStandard::LabelNamespace("#t".to_string()) - ); - - assert_eq!( - TagStandard::parse(&["l", "IT-MI"]).unwrap(), - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: None - } - ); - - assert_eq!( - TagStandard::parse(&["l", "IT-MI", "ISO-3166-2"]).unwrap(), - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: Some("ISO-3166-2".to_string()) - } - ); - - assert_eq!( - TagStandard::parse(&["r", "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", "euc"]).unwrap(), - TagStandard::GitEarliestUniqueCommitId( - Sha1Hash::from_str("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2").unwrap() - ) - ); - - assert_eq!( - TagStandard::parse(&["clone", "https://github.com/rust-nostr/nostr.git"]).unwrap(), - TagStandard::GitClone(vec![ - Url::parse("https://github.com/rust-nostr/nostr.git").unwrap() - ]) - ); - - assert_eq!( - TagStandard::parse(&[ - "maintainers", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::GitMaintainers(vec![ - PublicKey::from_hex( - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" - ) - .unwrap(), - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - ]) - ); - - assert_eq!( - TagStandard::parse(&[ - "web", - "https://rust-nostr.org/", - "https://github.com/rust-nostr", - ]) - .unwrap(), - TagStandard::Web(vec![ - Url::parse("https://rust-nostr.org").unwrap(), - Url::parse("https://github.com/rust-nostr").unwrap(), - ]) - ); - - assert_eq!( - TagStandard::parse(&["t", "Nostr"]), - Err(Error::UnknownStandardizedTag) - ); - assert!(TagStandard::parse(&["t", "nostr"]).is_ok()); - assert!(TagStandard::parse(&["t", "سلام"]).is_ok()); - } - - #[test] - fn e_tag_with_mention_marker() { - let hex = "19bb195b83fd26db217b6feebb444de4808d90eb4375c31c75ba5bb5c5c10cfc"; - let id = EventId::from_hex(hex).unwrap(); - - let result = TagStandard::parse(&["e", hex, "", "mention"]); - - assert_eq!( - result, - Ok(TagStandard::Event { - event_id: id, - relay_url: None, - marker: None, - public_key: None, - uppercase: false - }) - ); - } -} diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs index bec23da9d..cf2f4a4df 100644 --- a/crates/nostr/src/lib.rs +++ b/crates/nostr/src/lib.rs @@ -53,7 +53,7 @@ pub mod types; pub mod util; #[doc(hidden)] -pub use self::event::tag::{Tag, TagKind, TagStandard, Tags}; +pub use self::event::tag::{Tag, Tags}; #[doc(hidden)] pub use self::event::{Event, EventBuilder, EventId, Kind, UnsignedEvent}; #[doc(hidden)] diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index ada200591..e56124be2 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -18,14 +18,20 @@ pub mod nip11; pub mod nip13; pub mod nip15; pub mod nip17; +pub mod nip18; pub mod nip19; pub mod nip21; pub mod nip22; +pub mod nip23; pub mod nip25; +pub mod nip30; +pub mod nip31; +pub mod nip32; pub mod nip34; pub mod nip35; pub mod nip38; pub mod nip39; +pub mod nip40; pub mod nip42; pub mod nip44; #[cfg(all(feature = "std", feature = "nip46"))] @@ -47,11 +53,16 @@ pub mod nip59; pub mod nip60; pub mod nip62; pub mod nip65; +pub mod nip70; pub mod nip73; +pub mod nip7d; pub mod nip88; +pub mod nip89; pub mod nip90; pub mod nip94; #[cfg(feature = "nip98")] pub mod nip98; pub mod nipb0; +pub mod nipb7; pub mod nipc0; +mod util; diff --git a/crates/nostr/src/nips/nip01.rs b/crates/nostr/src/nips/nip01/mod.rs similarity index 95% rename from crates/nostr/src/nips/nip01.rs rename to crates/nostr/src/nips/nip01/mod.rs index 321cbaf35..2893eb38b 100644 --- a/crates/nostr/src/nips/nip01.rs +++ b/crates/nostr/src/nips/nip01/mod.rs @@ -2,7 +2,7 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP01: Basic protocol flow description +//! NIP-01: Basic protocol flow description //! //! <https://github.com/nostr-protocol/nips/blob/master/01.md> @@ -17,18 +17,28 @@ use serde::ser::{SerializeMap, Serializer}; use serde::{Deserialize, Serialize}; use serde_json::Value; +mod tags; + +pub use self::tags::*; use super::nip19::{self, FromBech32, Nip19Coordinate, ToBech32}; use super::nip21::{FromNostrUri, ToNostrUri}; -use crate::types::Url; -use crate::{Filter, JsonUtil, Kind, PublicKey, Tag, key}; +use crate::event::tag::TagCodecError; +use crate::types::url::{self, Url}; +use crate::{Filter, JsonUtil, Kind, PublicKey, Tag, event, key}; -/// Raw Event error +/// NIP-01 error #[derive(Debug, PartialEq)] pub enum Error { /// Keys error Keys(key::Error), + /// Event error + Event(event::Error), + /// Url error + Url(url::Error), /// Parse Int error ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), /// Invalid coordinate InvalidCoordinate, } @@ -39,7 +49,10 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidCoordinate => f.write_str("Invalid coordinate"), } } @@ -51,12 +64,30 @@ impl From<key::Error> for Error { } } +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + impl From<ParseIntError> for Error { fn from(e: ParseIntError) -> Self { Self::ParseInt(e) } } +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Coordinate for event (`a` tag) #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Coordinate { diff --git a/crates/nostr/src/nips/nip01/tags.rs b/crates/nostr/src/nips/nip01/tags.rs new file mode 100644 index 000000000..1d3ea80c7 --- /dev/null +++ b/crates/nostr/src/nips/nip01/tags.rs @@ -0,0 +1,404 @@ +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +use super::super::util::{ + take_and_parse_optional_public_key, take_and_parse_optional_relay_url, take_coordinate, + take_event_id, take_public_key, take_string, +}; +use super::{Coordinate, Error}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventId, PublicKey, RelayUrl}; + +/// Standardized NIP-01 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/01.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip01Tag { + /// `a` tag + Coordinate { + /// Coordinate + coordinate: Coordinate, + /// Relay hint (optional but recommended) + relay_hint: Option<RelayUrl>, + }, + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Relay hint (optional but recommended) + relay_hint: Option<RelayUrl>, + /// Public key hint + public_key: Option<PublicKey>, + }, + /// `d` tag + Identifier(String), + /// `p` tag + PublicKey { + /// Public key + public_key: PublicKey, + /// Relay hint (optional but recommended) + relay_hint: Option<RelayUrl>, + }, +} + +impl TagCodec for Nip01Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "a" => { + let (coordinate, relay_hint) = parse_a_tag(iter)?; + Ok(Self::Coordinate { + coordinate, + relay_hint, + }) + } + "e" => { + let (id, relay_hint, public_key) = parse_e_tag(iter)?; + Ok(Self::Event { + id, + relay_hint, + public_key, + }) + } + "d" => Ok(Self::Identifier(take_string(&mut iter, "identifier")?)), + "p" => { + let (public_key, relay_hint) = parse_p_tag(iter)?; + Ok(Self::PublicKey { + public_key, + relay_hint, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Coordinate { + coordinate, + relay_hint, + } => serialize_a_tag(coordinate, relay_hint.as_ref()), + Self::Event { + id, + relay_hint, + public_key, + } => serialize_e_tag(id, relay_hint.as_ref(), public_key.as_ref()), + Self::Identifier(identifier) => { + Tag::new(vec![String::from("d"), identifier.to_string()]) + } + Self::PublicKey { + public_key, + relay_hint, + } => serialize_p_tag(public_key, relay_hint.as_ref()), + } + } +} + +impl_tag_codec_conversions!(Nip01Tag); + +pub(in super::super) fn parse_a_tag<T, S>( + mut iter: T, +) -> Result<(Coordinate, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let coordinate: Coordinate = take_coordinate::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((coordinate, relay_hint)) +} + +pub(in super::super) fn serialize_a_tag( + coordinate: &Coordinate, + relay_hint: Option<&RelayUrl>, +) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("a")); + tag.push(coordinate.to_string()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) +} + +pub(in super::super) fn parse_e_tag<T, S>( + mut iter: T, +) -> Result<(EventId, Option<RelayUrl>, Option<PublicKey>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let public_key: Option<PublicKey> = take_and_parse_optional_public_key(&mut iter)?; + + Ok((id, relay_hint, public_key)) +} + +pub(in super::super) fn serialize_e_tag( + id: &EventId, + relay_hint: Option<&RelayUrl>, + public_key: Option<&PublicKey>, +) -> Tag { + let mut tag: Vec<String> = + Vec::with_capacity(2 + relay_hint.is_some() as usize + public_key.is_some() as usize); + + tag.push(String::from("e")); + tag.push(id.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } else if public_key.is_some() { + tag.push(String::new()); + } + + if let Some(public_key) = public_key { + tag.push(public_key.to_string()); + } + + Tag::new(tag) +} + +pub(in super::super) fn parse_p_tag<T, S>( + mut iter: T, +) -> Result<(PublicKey, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((public_key, relay_hint)) +} + +pub(in super::super) fn serialize_p_tag( + public_key: &PublicKey, + relay_hint: Option<&RelayUrl>, +) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("p")); + tag.push(public_key.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{event, key}; + + #[test] + fn test_standardized_a_tag() { + let raw = "30617:00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9:n34"; + let coordinate = Coordinate::from_kpi_format(raw).unwrap(); + + // Simple + let tag = vec!["a", raw]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Coordinate { + coordinate: coordinate.clone(), + relay_hint: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["a", raw, "wss://relay.damus.io/"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Coordinate { + coordinate, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid coordinate + let tag = vec!["a", "hello"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::InvalidCoordinate); + + // Missing coordinate + let tag = vec!["a"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("coordinate"))); + } + + #[test] + fn test_standardized_e_tag() { + let raw = "a3ce0a22c5c25e5a41a17004d38ed2aa8f815dda918c92400c6b611c41acbc78"; + let id = EventId::from_hex(raw).unwrap(); + + // Simple + let tag = vec!["e", raw]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: None, + public_key: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["e", raw, "wss://relay.damus.io/"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + public_key: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint and public key + let tag = vec![ + "e", + raw, + "wss://relay.damus.io/", + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9", + ]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + public_key: Some( + PublicKey::from_hex( + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9" + ) + .unwrap() + ) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With public key and no relay hint + let tag = vec![ + "e", + raw, + "", + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9", + ]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: None, + public_key: Some( + PublicKey::from_hex( + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9" + ) + .unwrap() + ) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid ID + let tag = vec!["e", "hello"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::Event(event::Error::Hex(_)))); + + // Missing ID + let tag = vec!["e"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("event ID"))); + + // Issue: https://gitworkshop.dev/yukikishimoto.com/nostr/issues/note15xl8ae8dnmt26adfw6ec8gshxxs242vrvsa3v36ctwq2x9gglkustlxlwa + let result = Nip01Tag::parse(["e", raw, "", "", ""]).unwrap(); + assert_eq!( + result, + Nip01Tag::Event { + id, + relay_hint: None, + public_key: None, + } + ) + } + + #[test] + fn test_standardized_d_tag() { + let tag = vec!["d", "raw"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip01Tag::Identifier(String::from("raw"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Missing identifier + let tag = vec!["d"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("identifier"))); + } + + #[test] + fn test_standardized_p_tag() { + let raw = "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9"; + let public_key = PublicKey::from_hex(raw).unwrap(); + + // Simple + let tag = vec!["p", raw]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::PublicKey { + public_key, + relay_hint: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["p", raw, "wss://relay.damus.io/"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::PublicKey { + public_key, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid public key + let tag = vec!["p", "hello"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::Keys(key::Error::Hex(_)))); + + // Missing public key + let tag = vec!["p"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("public key"))); + } +} diff --git a/crates/nostr/src/nips/nip02.rs b/crates/nostr/src/nips/nip02.rs index 68504f742..194f1d035 100644 --- a/crates/nostr/src/nips/nip02.rs +++ b/crates/nostr/src/nips/nip02.rs @@ -2,14 +2,59 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP02: Follow List +//! NIP-02: Follow List //! //! <https://github.com/nostr-protocol/nips/blob/master/02.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; -use crate::key::PublicKey; -use crate::types::RelayUrl; +use super::util::{take_and_parse_optional_relay_url, take_optional_string, take_public_key}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::key::{self, PublicKey}; +use crate::types::url::{self, RelayUrl}; + +/// NIP-02 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Keys error + Keys(key::Error), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Keys(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} /// Contact #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -33,3 +78,172 @@ impl Contact { } } } + +impl From<Contact> for Nip02Tag { + fn from(contact: Contact) -> Self { + Self::PublicKey { + public_key: contact.public_key, + relay_hint: contact.relay_url, + alias: contact.alias, + } + } +} + +/// Standardized NIP-02 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/02.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip02Tag { + /// Contact public key + /// + /// `["p", <32-bytes hex key>, <main relay URL>, <petname>]` + PublicKey { + /// Public key + public_key: PublicKey, + /// Recommended relay URL + relay_hint: Option<RelayUrl>, + /// Alias + alias: Option<String>, + }, +} + +impl TagCodec for Nip02Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "p" => { + let (public_key, relay_hint, alias) = parse_p_tag(iter)?; + Ok(Self::PublicKey { + public_key, + relay_hint, + alias, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + let Self::PublicKey { + public_key, + relay_hint, + alias, + } = self; + + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("p")); + tag.push(public_key.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } else if alias.is_some() { + tag.push(String::new()); + } + + if let Some(alias) = alias { + tag.push(alias.to_string()); + } + + Tag::new(tag) + } +} + +impl_tag_codec_conversions!(Nip02Tag); + +fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Option<RelayUrl>, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let alias: Option<String> = take_optional_string(&mut iter); + + Ok((public_key, relay_hint, alias)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_standardized_p_tag() { + let raw = "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9"; + let public_key = PublicKey::from_hex(raw).unwrap(); + + // Simple + let tag = vec!["p", raw]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: None, + alias: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["p", raw, "wss://relay.damus.io/"]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + alias: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint and alias + let tag = vec!["p", raw, "wss://relay.damus.io/", "alice"]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + alias: Some(String::from("alice")) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With alias and no relay hint + let tag = vec!["p", raw, "", "alice"]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: None, + alias: Some(String::from("alice")) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid public key + let tag = vec!["p", "hello"]; + let err = Nip02Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::Keys(key::Error::Hex(_)))); + + // Missing public key + let tag = vec!["p"]; + let err = Nip02Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("public key"))); + } +} diff --git a/crates/nostr/src/nips/nip10.rs b/crates/nostr/src/nips/nip10.rs index c4f931b0f..943f1c760 100644 --- a/crates/nostr/src/nips/nip10.rs +++ b/crates/nostr/src/nips/nip10.rs @@ -2,16 +2,36 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP10: Conventions for clients' use of `e` and `p` tags in text events +//! NIP-10: Conventions for clients' use of `e` and `p` tags in text events //! //! <https://github.com/nostr-protocol/nips/blob/master/10.md> +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use core::fmt; use core::str::FromStr; +use super::util::{ + take_and_parse_optional_public_key, take_and_parse_optional_relay_url, take_event_id, +}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::event::{self}; +use crate::types::url; +use crate::{EventId, PublicKey, RelayUrl, key}; + +const EVENT: &str = "e"; + /// NIP10 error -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq)] pub enum Error { + /// Keys error + Keys(key::Error), + /// Event error + Event(event::Error), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), /// Invalid marker InvalidMarker, } @@ -21,11 +41,39 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidMarker => f.write_str("invalid marker"), } } } +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Marker /// /// <https://github.com/nostr-protocol/nips/blob/master/10.md> @@ -57,3 +105,273 @@ impl FromStr for Marker { } } } + +/// Standardized NIP-10 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/10.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip10Tag { + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Event marker + marker: Option<Marker>, + /// Public key hint + public_key: Option<PublicKey>, + }, +} + +impl Nip10Tag { + /// Check if this tag is marked as a root reference. + #[inline] + pub fn is_root(&self) -> bool { + matches!( + self, + Self::Event { + marker: Some(Marker::Root), + .. + } + ) + } + + /// Check if this tag is marked as a reply reference. + #[inline] + pub fn is_reply(&self) -> bool { + matches!( + self, + Self::Event { + marker: Some(Marker::Reply), + .. + } + ) + } +} + +impl TagCodec for Nip10Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + EVENT => { + let (id, relay_hint, marker, public_key) = parse_e_tag(iter)?; + Ok(Self::Event { + id, + relay_hint, + marker, + public_key, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Event { + id, + relay_hint, + marker, + public_key, + } => { + // ["e", <event-id>, <relay-url>, <marker>, <pubkey>] + // <relay-url>, <marker> and <pubkey> are optional + // <relay-url>, if empty, may be set to "" (if there are additional fields later) + // <marker> is optional and if present is one of "reply" or "root" (so not an empty string) + + let mut tag: Vec<String> = Vec::with_capacity( + 2 + relay_hint.is_some() as usize + + marker.is_some() as usize + + public_key.is_some() as usize, + ); + + tag.push(String::from(EVENT)); + tag.push(id.to_hex()); + + // Check if relay hint exists or if there are additional fields after + match (relay_hint, marker.is_some() || public_key.is_some()) { + (Some(relay_hint), ..) => tag.push(relay_hint.to_string()), + (None, true) => tag.push(String::new()), + (None, false) => {} + } + + match (marker, public_key.is_some()) { + (Some(marker), _) => tag.push(marker.to_string()), + (None, true) => tag.push(String::new()), + (None, false) => {} + } + + if let Some(public_key) = public_key { + tag.push(public_key.to_hex()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip10Tag); + +#[allow(clippy::type_complexity)] +fn parse_e_tag<T, S>( + mut iter: T, +) -> Result<(EventId, Option<RelayUrl>, Option<Marker>, Option<PublicKey>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + let marker: Option<Marker> = match iter.next() { + Some(marker) => { + let marker: &str = marker.as_ref(); + + if !marker.is_empty() && marker != "mention" { + Some(Marker::from_str(marker)?) + } else { + None + } + } + _ => None, + }; + + let public_key: Option<PublicKey> = take_and_parse_optional_public_key(&mut iter)?; + + Ok((id, relay_hint, marker, public_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::PublicKey; + + #[test] + fn test_parse_empty_tag() { + let tag: Vec<String> = Vec::new(); + let err = Nip10Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::missing_tag_kind())); + } + + #[test] + fn test_non_existing_tag() { + let tag = vec!["p"]; + let err = Nip10Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Unknown)); + } + + #[test] + fn test_standardized_e_tag() { + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let public_key = + PublicKey::from_hex("aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4") + .unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + relay_hint.to_string(), + String::from("root"), + public_key.to_hex(), + ]; + let parsed = Nip10Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip10Tag::Event { + id: EventId::all_zeros(), + relay_hint: Some(relay_hint), + marker: Some(Marker::Root), + public_key: Some(public_key), + } + ); + assert!(parsed.is_root()); + assert!(!parsed.is_reply()); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_positional_e_tag() { + let tag = vec![String::from("e"), EventId::all_zeros().to_hex()]; + let parsed = Nip10Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip10Tag::Event { + id: EventId::all_zeros(), + relay_hint: None, + marker: None, + public_key: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_e_tag_with_empty_marker() { + let public_key = + PublicKey::from_hex("aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4") + .unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + String::from("wss://relay.example.com"), + String::new(), + public_key.to_hex(), + ]; + let parsed = Nip10Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip10Tag::Event { + id: EventId::all_zeros(), + relay_hint: Some(RelayUrl::parse("wss://relay.example.com").unwrap()), + marker: None, + public_key: Some(public_key), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_e_tag_without_marker_is_invalid() { + let public_key = + PublicKey::from_hex("aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4") + .unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + String::from("wss://relay.example.com"), + public_key.to_hex(), + ]; + let err = Nip10Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::InvalidMarker); + } + + #[test] + fn test_e_tag_with_mention_marker() { + let hex = "19bb195b83fd26db217b6feebb444de4808d90eb4375c31c75ba5bb5c5c10cfc"; + let id = EventId::from_hex(hex).unwrap(); + + let result = Nip10Tag::parse(["e", hex, "", "mention"]); + + assert_eq!( + result, + Ok(Nip10Tag::Event { + id, + relay_hint: None, + marker: None, + public_key: None, + }) + ); + } +} diff --git a/crates/nostr/src/nips/nip13/mod.rs b/crates/nostr/src/nips/nip13/mod.rs index 3097508eb..0631d43d7 100644 --- a/crates/nostr/src/nips/nip13/mod.rs +++ b/crates/nostr/src/nips/nip13/mod.rs @@ -7,11 +7,13 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/13.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::any::Any; +use core::fmt; use core::fmt::Debug; -use core::num::NonZeroU8; +use core::num::{NonZeroU8, ParseIntError}; #[cfg(feature = "std")] mod blocking_wrapper; @@ -22,9 +24,104 @@ mod single_thread; #[cfg(feature = "pow-multi-thread")] pub use self::multi_thread::*; pub use self::single_thread::*; +use super::util::take_and_parse_from_str; use crate::UnsignedEvent; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; use crate::util::BoxedFuture; +const NONCE: &str = "nonce"; + +/// NIP-13 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => fmt::Display::fmt(e, f), + Self::Codec(e) => fmt::Display::fmt(e, f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-13 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/13.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip13Tag { + /// `nonce` tag + Nonce { + /// Nonce + nonce: u128, + /// Target difficulty + difficulty: u8, + }, +} + +impl TagCodec for Nip13Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + NONCE => { + let (nonce, difficulty) = parse_nonce_tag(iter)?; + Ok(Self::Nonce { nonce, difficulty }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Nonce { nonce, difficulty } => Tag::new(vec![ + String::from(NONCE), + nonce.to_string(), + difficulty.to_string(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip13Tag); + +fn parse_nonce_tag<T, S>(mut iter: T) -> Result<(u128, u8), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let nonce: u128 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "nonce")?; + let difficulty: u8 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "difficulty")?; + + Ok((nonce, difficulty)) +} + /// Gets the number of leading zero bits. Result is between 0 and 255. #[inline] pub fn get_leading_zero_bits<T>(h: T) -> u8 @@ -111,8 +208,30 @@ pub mod tests { use hashes::sha256::Hash as Sha256Hash; use super::*; + use crate::Tag; #[cfg(feature = "std")] - use crate::{EventBuilder, PublicKey, Tag, TagKind}; + use crate::{EventBuilder, PublicKey}; + + #[test] + fn test_parse_nonce_tag() { + let tag = vec!["nonce", "776797", "20"]; + let parsed = Nip13Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip13Tag::Nonce { + nonce: 776797, + difficulty: 20 + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_nonce_tag_missing_difficulty() { + let tag = vec!["nonce", "776797"]; + let err = Nip13Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("difficulty"))); + } #[test] fn check_get_leading_zeroes() { @@ -516,7 +635,7 @@ pub mod tests { .mine(&TestAdapter, NonZeroU8::new(2).unwrap()) .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; @@ -561,7 +680,7 @@ pub mod tests { .await .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; diff --git a/crates/nostr/src/nips/nip13/multi_thread.rs b/crates/nostr/src/nips/nip13/multi_thread.rs index 134d859ce..9bd4f77cb 100644 --- a/crates/nostr/src/nips/nip13/multi_thread.rs +++ b/crates/nostr/src/nips/nip13/multi_thread.rs @@ -132,7 +132,7 @@ pub mod tests { use std::time::Duration; use super::*; - use crate::event::{EventBuilder, TagKind}; + use crate::event::EventBuilder; use crate::key::PublicKey; use crate::nips::nip13::get_leading_zero_bits; @@ -145,7 +145,7 @@ pub mod tests { .mine(&MultiThreadPow, NonZeroU8::new(2).unwrap()) .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; diff --git a/crates/nostr/src/nips/nip13/single_thread.rs b/crates/nostr/src/nips/nip13/single_thread.rs index 020993889..1d4c7824f 100644 --- a/crates/nostr/src/nips/nip13/single_thread.rs +++ b/crates/nostr/src/nips/nip13/single_thread.rs @@ -101,7 +101,7 @@ pub mod tests { .mine(&SingleThreadPow, NonZeroU8::new(2).unwrap()) .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; diff --git a/crates/nostr/src/nips/nip17.rs b/crates/nostr/src/nips/nip17.rs index 5b260f0da..2daaf242f 100644 --- a/crates/nostr/src/nips/nip17.rs +++ b/crates/nostr/src/nips/nip17.rs @@ -2,34 +2,144 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP17: Private Direct Message +//! NIP-17: Private Direct Message //! //! <https://github.com/nostr-protocol/nips/blob/master/17.md> -use crate::{Event, RelayUrl, TagStandard}; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; -/// Extracts the relay list +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::take_relay_url; +use crate::types::url; +use crate::{Event, RelayUrl}; + +const RELAY: &str = "relay"; + +/// NIP-17 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-17 tags /// -/// This function doesn't verify if the event kind is [`Kind::InboxRelays`](crate::Kind::InboxRelays)! -pub fn extract_relay_list(event: &Event) -> impl Iterator<Item = &RelayUrl> { - event.tags.iter().filter_map(|tag| { - if let Some(TagStandard::Relay(url)) = tag.as_standardized() { - Some(url) - } else { - None +/// <https://github.com/nostr-protocol/nips/blob/master/17.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip17Tag { + /// Relay + /// + /// `["relay", <relay URL>]` + Relay(RelayUrl), +} + +impl TagCodec for Nip17Tag { + type Error = Error; + + /// Parse NIP-17 standardized tag + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + RELAY => { + let url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(url)) + } + _ => Err(TagCodecError::Unknown.into()), } - }) + } + + fn to_tag(&self) -> Tag { + let Self::Relay(url) = self; + let tag: Vec<String> = vec![String::from(RELAY), url.to_string()]; + Tag::new(tag) + } } +impl_tag_codec_conversions!(Nip17Tag); + /// Extracts the relay list /// /// This function doesn't verify if the event kind is [`Kind::InboxRelays`](crate::Kind::InboxRelays)! -pub fn extract_owned_relay_list(event: Event) -> impl Iterator<Item = RelayUrl> { - event.tags.into_iter().filter_map(|tag| { - if let Some(TagStandard::Relay(url)) = tag.to_standardized() { - Some(url) - } else { - None - } - }) +pub fn extract_relay_list(event: &Event) -> impl Iterator<Item = RelayUrl> + '_ { + event + .tags + .iter() + .filter_map(|tag| match Nip17Tag::parse(tag.as_slice()) { + Ok(Nip17Tag::Relay(url)) => Some(url), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty_tag() { + let tag: Vec<String> = Vec::new(); + let err = Nip17Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::missing_tag_kind())); + } + + #[test] + fn test_non_existing_tag() { + let tag = vec!["p"]; + let err = Nip17Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Unknown)); + } + + #[test] + fn test_standardized_relay_tag() { + let relay = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["relay".to_string(), relay.to_string()]; + + let parsed = Nip17Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip17Tag::Relay(relay.clone())); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_missing_relay_url() { + let tag = vec!["relay"]; + let err = Nip17Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("relay URL"))); + } } diff --git a/crates/nostr/src/nips/nip18.rs b/crates/nostr/src/nips/nip18.rs new file mode 100644 index 000000000..6ff645a4b --- /dev/null +++ b/crates/nostr/src/nips/nip18.rs @@ -0,0 +1,356 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-18: Reposts +//! +//! <https://github.com/nostr-protocol/nips/blob/master/18.md> + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; + +use super::nip01::{self, Coordinate}; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_public_key, take_and_parse_optional_relay_url, + take_event_id, take_public_key, +}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::event::{self}; +use crate::types::url; +use crate::{EventId, Kind, PublicKey, RelayUrl, key}; + +const EVENT: &str = "e"; +const KIND: &str = "k"; +const PUBLIC_KEY: &str = "p"; +const QUOTE: &str = "q"; + +/// NIP-18 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Event error + Event(event::Error), + /// Keys error + Keys(key::Error), + /// NIP-01 error + Nip01(nip01::Error), + /// Relay URL error + RelayUrl(url::Error), + /// Parse int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Event(e) => e.fmt(f), + Self::Keys(e) => e.fmt(f), + Self::Nip01(e) => e.fmt(f), + Self::RelayUrl(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::RelayUrl(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-18 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/18.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip18Tag { + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + }, + /// `k` tag + Kind(Kind), + /// `p` tag + PublicKey { + /// Public key + public_key: PublicKey, + /// Relay hint + relay_hint: Option<RelayUrl>, + }, + /// `q` tag with event ID + Quote { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Public key hint + public_key: Option<PublicKey>, + }, + /// `q` tag with event coordinate + QuoteAddress { + /// Event coordinate + coordinate: Coordinate, + /// Relay hint + relay_hint: Option<RelayUrl>, + }, +} + +impl TagCodec for Nip18Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + EVENT => { + let (id, relay_hint) = parse_e_tag(iter)?; + Ok(Self::Event { id, relay_hint }) + } + KIND => { + let kind: Kind = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "kind")?; + Ok(Self::Kind(kind)) + } + PUBLIC_KEY => { + let (public_key, relay_hint) = parse_p_tag(iter)?; + Ok(Self::PublicKey { + public_key, + relay_hint, + }) + } + QUOTE => parse_q_tag(iter), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Event { id, relay_hint } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + tag.push(String::from(EVENT)); + tag.push(id.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + Self::Kind(kind) => Tag::new(vec![String::from(KIND), kind.as_u16().to_string()]), + Self::PublicKey { + public_key, + relay_hint, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + tag.push(String::from(PUBLIC_KEY)); + tag.push(public_key.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + Self::Quote { + id, + relay_hint, + public_key, + } => { + let mut tag: Vec<String> = Vec::with_capacity( + 2 + relay_hint.is_some() as usize + public_key.is_some() as usize, + ); + tag.push(String::from(QUOTE)); + tag.push(id.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } else if public_key.is_some() { + tag.push(String::new()); + } + + if let Some(public_key) = public_key { + tag.push(public_key.to_hex()); + } + + Tag::new(tag) + } + Self::QuoteAddress { + coordinate, + relay_hint, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + tag.push(String::from(QUOTE)); + tag.push(coordinate.to_string()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip18Tag); + +fn parse_e_tag<T, S>(mut iter: T) -> Result<(EventId, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((id, relay_hint)) +} + +fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((public_key, relay_hint)) +} + +fn parse_q_tag<T, S>(mut iter: T) -> Result<Nip18Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let value: S = iter.next().ok_or(TagCodecError::Missing("event ID"))?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + match EventId::from_hex(value.as_ref()) { + Ok(id) => { + let public_key: Option<PublicKey> = take_and_parse_optional_public_key(&mut iter)?; + + Ok(Nip18Tag::Quote { + id, + relay_hint, + public_key, + }) + } + Err(_) => Ok(Nip18Tag::QuoteAddress { + coordinate: Coordinate::from_kpi_format(value.as_ref())?, + relay_hint, + }), + } +} + +#[cfg(all(test, feature = "std", feature = "os-rng"))] +mod tests { + use super::*; + use crate::prelude::*; + + #[test] + fn test_standardized_event_tag() { + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + relay_hint.to_string(), + ]; + let parsed = Nip18Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip18Tag::Event { + id: EventId::all_zeros(), + relay_hint: Some(relay_hint), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_quote_tag() { + let keys = Keys::generate(); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("q"), + EventId::all_zeros().to_hex(), + relay_hint.to_string(), + keys.public_key().to_string(), + ]; + let parsed = Nip18Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip18Tag::Quote { + id: EventId::all_zeros(), + relay_hint: Some(relay_hint), + public_key: Some(keys.public_key()), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_quote_address_tag() { + let keys = Keys::generate(); + let coordinate = + Coordinate::new(Kind::LongFormTextNote, keys.public_key()).identifier("article"); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("q"), + coordinate.to_string(), + relay_hint.to_string(), + ]; + let parsed = Nip18Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip18Tag::QuoteAddress { + coordinate, + relay_hint: Some(relay_hint), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip22.rs b/crates/nostr/src/nips/nip22.rs index 64cd5d499..d586dc7bc 100644 --- a/crates/nostr/src/nips/nip22.rs +++ b/crates/nostr/src/nips/nip22.rs @@ -7,11 +7,227 @@ //! <https://github.com/nostr-protocol/nips/blob/master/22.md> use alloc::borrow::Cow; +use alloc::string::{String, ToString}; use alloc::vec::Vec; +use core::fmt; +use core::str::FromStr; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::nip01::{self, Coordinate}; +use crate::nips::nip73::{self, ExternalContentId, Nip73Kind}; +use crate::types::url; +use crate::{Event, EventId, Kind, PublicKey, RelayUrl, Url, event, key}; + +/// NIP-22 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Event error + Event(event::Error), + /// Keys error + Keys(key::Error), + /// NIP-01 error + Nip01(nip01::Error), + /// NIP-73 error + Nip73(nip73::Error), + /// Relay URL error + RelayUrl(url::Error), + /// URL error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Event(e) => e.fmt(f), + Self::Keys(e) => e.fmt(f), + Self::Nip01(e) => e.fmt(f), + Self::Nip73(e) => e.fmt(f), + Self::RelayUrl(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<nip73::Error> for Error { + fn from(e: nip73::Error) -> Self { + Self::Nip73(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::RelayUrl(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-22 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/22.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip22Tag { + /// `a` and `A` tags + Coordinate { + /// Coordinate + coordinate: Coordinate, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Uppercase variant + uppercase: bool, + }, + /// `e` and `E` tags + Event { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Public key hint + public_key: Option<PublicKey>, + /// Uppercase variant + uppercase: bool, + }, + /// `i` and `I` tags + ExternalContent { + /// External content + content: ExternalContentId, + /// Optional URL hint + hint: Option<Url>, + /// Uppercase variant + uppercase: bool, + }, + /// Numeric `k` and `K` tags + Kind { + /// Event kind + kind: Kind, + /// Uppercase variant + uppercase: bool, + }, + /// NIP-73 `k` and `K` tags + Nip73Kind { + /// NIP-73 kind + kind: Nip73Kind, + /// Uppercase variant + uppercase: bool, + }, + /// `p` and `P` tags + PublicKey { + /// Public key + public_key: PublicKey, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Uppercase variant + uppercase: bool, + }, +} -use crate::nips::nip01::Coordinate; -use crate::nips::nip73::ExternalContentId; -use crate::{Alphabet, Event, EventId, Kind, PublicKey, RelayUrl, Tag, TagKind, TagStandard, Url}; +impl TagCodec for Nip22Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "a" => parse_a_tag(iter, false), + "A" => parse_a_tag(iter, true), + "e" => parse_e_tag(iter, false), + "E" => parse_e_tag(iter, true), + "i" => parse_i_tag(iter, false), + "I" => parse_i_tag(iter, true), + "k" => parse_k_tag(iter, false), + "K" => parse_k_tag(iter, true), + "p" => parse_p_tag(iter, false), + "P" => parse_p_tag(iter, true), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Coordinate { + coordinate, + relay_hint, + uppercase, + } => maybe_uppercase( + nip01::serialize_a_tag(coordinate, relay_hint.as_ref()), + *uppercase, + ), + Self::Event { + id, + relay_hint, + public_key, + uppercase, + } => maybe_uppercase( + nip01::serialize_e_tag(id, relay_hint.as_ref(), public_key.as_ref()), + *uppercase, + ), + Self::ExternalContent { + content, + hint, + uppercase, + } => maybe_uppercase(nip73::serialize_i_tag(content, hint.as_ref()), *uppercase), + Self::Kind { kind, uppercase } => Tag::new(vec![ + if *uppercase { + String::from("K") + } else { + String::from("k") + }, + kind.to_string(), + ]), + Self::Nip73Kind { kind, uppercase } => { + maybe_uppercase(nip73::serialize_k_tag(kind), *uppercase) + } + Self::PublicKey { + public_key, + relay_hint, + uppercase, + } => maybe_uppercase( + nip01::serialize_p_tag(public_key, relay_hint.as_ref()), + *uppercase, + ), + } + } +} + +impl_tag_codec_conversions!(Nip22Tag); /// Comment target #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -128,30 +344,35 @@ impl<'a> CommentTarget<'a> { tags.reserve_exact( 1 + usize::from(pubkey_hint.is_some()) + usize::from(kind.is_some()), ); - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { - event_id: *id, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - marker: None, - public_key: pubkey_hint.as_ref().copied(), - uppercase: is_root, - })); + tags.push( + Nip22Tag::Event { + id: *id, + relay_hint: relay_hint.clone().map(|r| r.into_owned()), + public_key: pubkey_hint.as_ref().copied(), + uppercase: is_root, + } + .to_tag(), + ); if let Some(pubkey) = pubkey_hint { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { + tags.push( + Nip22Tag::PublicKey { public_key: *pubkey, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - alias: None, + relay_hint: relay_hint.clone().map(|r| r.into_owned()), uppercase: is_root, - }, - )); + } + .to_tag(), + ); } if let Some(kind) = kind { - tags.push(Tag::from_standardized_without_cell(TagStandard::Kind { - kind: *kind, - uppercase: is_root, - })); + tags.push( + Nip22Tag::Kind { + kind: *kind, + uppercase: is_root, + } + .to_tag(), + ); } } Self::Coordinate { @@ -163,41 +384,47 @@ impl<'a> CommentTarget<'a> { let kind: Kind = address.kind; tags.reserve_exact(3); - tags.push(Tag::from_standardized_without_cell( - TagStandard::Coordinate { + tags.push( + Nip22Tag::Coordinate { coordinate: address.clone().into_owned(), - relay_url: relay_hint.clone().map(|r| r.into_owned()), + relay_hint: relay_hint.clone().map(|r| r.into_owned()), uppercase: is_root, - }, - )); - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { + } + .to_tag(), + ); + tags.push( + Nip22Tag::PublicKey { public_key, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - alias: None, + relay_hint: relay_hint.clone().map(|r| r.into_owned()), uppercase: is_root, - }, - )); - tags.push(Tag::from_standardized_without_cell(TagStandard::Kind { - kind, - uppercase: is_root, - })); + } + .to_tag(), + ); + tags.push( + Nip22Tag::Kind { + kind, + uppercase: is_root, + } + .to_tag(), + ); } Self::External { content, hint } => { tags.reserve_exact(2); - tags.push(Tag::from_standardized_without_cell( - TagStandard::ExternalContent { + tags.push( + Nip22Tag::ExternalContent { content: ExternalContentId::clone(content), hint: hint.clone().map(|r| r.into_owned()), uppercase: is_root, - }, - )); - tags.push(Tag::from_standardized_without_cell( - TagStandard::Nip73Kind { + } + .to_tag(), + ); + tags.push( + Nip22Tag::Nip73Kind { kind: content.kind(), uppercase: is_root, - }, - )) + } + .to_tag(), + ) } } @@ -208,7 +435,7 @@ impl<'a> CommentTarget<'a> { impl<'e> From<&'e Event> for CommentTarget<'_> { fn from(event: &'e Event) -> Self { if let Some(coordinate) = event.coordinate() { - CommentTarget::coordinate(Cow::Owned(coordinate.into_owned()), None) + CommentTarget::coordinate(Cow::Owned(coordinate), None) } else { CommentTarget::event(event.id, event.kind, Some(event.pubkey), None) } @@ -232,37 +459,41 @@ fn extract_data(event: &Event, is_root: bool) -> Option<CommentTarget<'_>> { // Try to extract event if let Some((event_id, relay_hint, public_key)) = extract_event(event, is_root) { + let kind: Kind = extract_kind(event, is_root)?; + return Some(CommentTarget::Event { - id: *event_id, - relay_hint: relay_hint.map(Cow::Borrowed), - pubkey_hint: public_key.copied(), - kind: extract_kind(event, is_root).copied(), + id: event_id, + relay_hint: relay_hint.map(Cow::Owned), + pubkey_hint: public_key, + kind: Some(kind), }); } // Try to extract coordinate if let Some((address, relay_hint)) = extract_coordinate(event, is_root) { - // Extract kind - // TODO: for now we allow optional `k`/`K` tag, but according to NIP-22, it should be mandatory. - let kind: Option<Kind> = extract_kind(event, is_root).copied(); + let kind: Kind = extract_kind(event, is_root)?; // Check if matches the address kind - if let Some(kind) = kind { - if kind != address.kind { - return None; - } + if kind != address.kind { + return None; } return Some(CommentTarget::Coordinate { - address: Cow::Borrowed(address), - relay_hint: relay_hint.map(Cow::Borrowed), + address: Cow::Owned(address), + relay_hint: relay_hint.map(Cow::Owned), }); } if let Some((content, hint)) = extract_external(event, is_root) { + let kind: Nip73Kind = extract_nip73_kind(event, is_root)?; + + if kind != content.kind() { + return None; + } + return Some(CommentTarget::External { - content: Cow::Borrowed(content), - hint: hint.map(Cow::Borrowed), + content: Cow::Owned(content), + hint: hint.map(Cow::Owned), }); } @@ -282,12 +513,23 @@ fn check_return<T>(val: T, is_root: bool, uppercase: bool) -> Option<T> { /// # Example: /// * is_root = true -> returns first `K` tag /// * is_root = false -> returns first `k` tag -fn extract_kind(event: &Event, is_root: bool) -> Option<&Kind> { +fn extract_kind(event: &Event, is_root: bool) -> Option<Kind> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::K, is_root)) - .find_map(|tag| match tag { - TagStandard::Kind { kind, uppercase } => check_return(kind, is_root, *uppercase), + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Kind { kind, uppercase }) => check_return(kind, is_root, uppercase), + _ => None, + }) +} + +/// Returns the first NIP-73 kind tag that matches the `is_root` condition. +fn extract_nip73_kind(event: &Event, is_root: bool) -> Option<Nip73Kind> { + event + .tags + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Nip73Kind { kind, uppercase }) => check_return(kind, is_root, uppercase), _ => None, }) } @@ -300,22 +542,17 @@ fn extract_kind(event: &Event, is_root: bool) -> Option<&Kind> { fn extract_event( event: &Event, is_root: bool, -) -> Option<(&EventId, Option<&RelayUrl>, Option<&PublicKey>)> { +) -> Option<(EventId, Option<RelayUrl>, Option<PublicKey>)> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::E, is_root)) - .find_map(|tag| match tag { - TagStandard::Event { - event_id, - relay_url, + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Event { + id, + relay_hint, public_key, uppercase, - .. - } => check_return( - (event_id, relay_url.as_ref(), public_key.as_ref()), - is_root, - *uppercase, - ), + }) => check_return((id, relay_hint, public_key), is_root, uppercase), _ => None, }) } @@ -325,17 +562,16 @@ fn extract_event( /// # Example: /// * is_root = true -> returns first `A` tag /// * is_root = false -> returns first `a` tag -fn extract_coordinate(event: &Event, is_root: bool) -> Option<(&Coordinate, Option<&RelayUrl>)> { +fn extract_coordinate(event: &Event, is_root: bool) -> Option<(Coordinate, Option<RelayUrl>)> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::A, is_root)) - .find_map(|tag| match tag { - TagStandard::Coordinate { + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Coordinate { coordinate, - relay_url, + relay_hint, uppercase, - .. - } => check_return((coordinate, relay_url.as_ref()), is_root, *uppercase), + }) => check_return((coordinate, relay_hint), is_root, uppercase), _ => None, }) } @@ -345,49 +581,201 @@ fn extract_coordinate(event: &Event, is_root: bool) -> Option<(&Coordinate, Opti /// # Example: /// * is_root = true -> returns first `I` tag /// * is_root = false -> returns first `i` tag -fn extract_external(event: &Event, is_root: bool) -> Option<(&ExternalContentId, Option<&Url>)> { +fn extract_external(event: &Event, is_root: bool) -> Option<(ExternalContentId, Option<Url>)> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::I, is_root)) - .find_map(|tag| match tag { - TagStandard::ExternalContent { + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::ExternalContent { content, hint, uppercase, - } => check_return((content, hint.as_ref()), is_root, *uppercase), + }) => check_return((content, hint), is_root, uppercase), _ => None, }) } +fn parse_a_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // At the moment the NIP-22 "a" tag is the same as the NIP-01, but with possibility of uppercasing. + let (coordinate, relay_hint) = nip01::parse_a_tag(iter)?; + + Ok(Nip22Tag::Coordinate { + coordinate, + relay_hint, + uppercase, + }) +} + +fn parse_e_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // At the moment the NIP-22 "e" tag is the same as the NIP-01, but with possibility of uppercasing. + let (id, relay_hint, public_key) = nip01::parse_e_tag(iter)?; + + Ok(Nip22Tag::Event { + id, + relay_hint, + public_key, + uppercase, + }) +} + +fn parse_i_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let (content, hint) = nip73::parse_i_tag(iter)?; + + Ok(Nip22Tag::ExternalContent { + content, + hint, + uppercase, + }) +} + +fn parse_k_tag<T, S>(mut iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let kind: S = iter.next().ok_or(TagCodecError::Missing("kind"))?; + + if let Ok(kind_number) = u16::from_str(kind.as_ref()) { + Ok(Nip22Tag::Kind { + kind: Kind::from_u16(kind_number), + uppercase, + }) + } else { + Ok(Nip22Tag::Nip73Kind { + kind: Nip73Kind::from_str(kind.as_ref())?, + uppercase, + }) + } +} + +fn parse_p_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // At the moment the NIP-22 "p" tag is the same as the NIP-01, but with possibility of uppercasing. + let (public_key, relay_hint) = nip01::parse_p_tag(iter)?; + + Ok(Nip22Tag::PublicKey { + public_key, + relay_hint, + uppercase, + }) +} + +#[inline] +fn maybe_uppercase(mut tag: Tag, uppercase: bool) -> Tag { + if uppercase { + tag[0] = tag[0].to_ascii_uppercase(); + } + tag +} + #[cfg(all(test, feature = "std", feature = "os-rng"))] mod tests { use super::*; use crate::prelude::*; fn check_kind(tags: &[Tag], kind: Kind, uppercase: bool) { - assert!( - tags.contains(&Tag::from_standardized_without_cell(TagStandard::Kind { - kind, - uppercase - })) - ); + assert!(tags.contains(&Tag::from(Nip22Tag::Kind { kind, uppercase }))); } fn check_nip73_kind(tags: &[Tag], kind: Nip73Kind, uppercase: bool) { - assert!(tags.contains(&Tag::from_standardized_without_cell( - TagStandard::Nip73Kind { kind, uppercase } - ))); + assert!(tags.contains(&Tag::from(Nip22Tag::Nip73Kind { kind, uppercase }))); } fn check_pubkey(tags: &[Tag], public_key: PublicKey, uppercase: bool) { - assert!(tags.contains(&Tag::from_standardized_without_cell( - TagStandard::PublicKey { - public_key, - relay_url: None, - alias: None, - uppercase + assert!(tags.contains(&Tag::from(Nip22Tag::PublicKey { + public_key, + relay_hint: None, + uppercase, + }))); + } + + #[test] + fn test_standardized_event_tag() { + let keys = Keys::generate(); + let kind = Kind::GitPatch; + let id = EventId::new( + &keys.public_key(), + &Timestamp::from_secs(1), + &kind, + &Tags::new(), + "", + ); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("E"), + id.to_hex(), + relay_hint.to_string(), + keys.public_key().to_string(), + ]; + let parsed = Nip22Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip22Tag::Event { + id, + relay_hint: Some(relay_hint), + public_key: Some(keys.public_key()), + uppercase: true, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_event_tag_with_empty_relay_hint() { + let keys = Keys::generate(); + let id = EventId::all_zeros(); + let tag = vec![ + String::from("E"), + id.to_hex(), + String::new(), + keys.public_key().to_string(), + ]; + let parsed = Nip22Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip22Tag::Event { + id, + relay_hint: None, + public_key: Some(keys.public_key()), + uppercase: true, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_external_content_tag() { + let content = ExternalContentId::Url(Url::parse("https://rust-nostr.org").unwrap()); + let hint = Url::parse("https://example.com").unwrap(); + let tag = vec![String::from("I"), content.to_string(), hint.to_string()]; + let parsed = Nip22Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip22Tag::ExternalContent { + content, + hint: Some(hint), + uppercase: true, } - ))); + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); } #[test] @@ -406,33 +794,42 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!( - root_vec.contains(&Tag::from_standardized_without_cell(TagStandard::Event { - event_id, - relay_url: None, - marker: None, - public_key: Some(keys.public_key()), - uppercase: true - })) - ); + assert!(root_vec.contains(&Tag::from(Nip22Tag::Event { + id: event_id, + relay_hint: None, + public_key: Some(keys.public_key()), + uppercase: true, + }))); check_pubkey(&root_vec, keys.public_key(), true); check_kind(&root_vec, kind, true); // Parent let parent_vec = comment_target.as_vec(false); - assert!( - parent_vec.contains(&Tag::from_standardized_without_cell(TagStandard::Event { - event_id, - relay_url: None, - marker: None, - public_key: Some(keys.public_key()), - uppercase: false - })) - ); + assert!(parent_vec.contains(&Tag::from(Nip22Tag::Event { + id: event_id, + relay_hint: None, + public_key: Some(keys.public_key()), + uppercase: false, + }))); check_pubkey(&parent_vec, keys.public_key(), false); check_kind(&parent_vec, kind, false); } + #[test] + fn test_invalid_event_tag_pubkey() { + let event_id = EventId::all_zeros(); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("E"), + event_id.to_hex(), + relay_hint.to_string(), + String::from("not-a-pubkey"), + ]; + + let err = Nip22Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, super::Error::Nip01(nip01::Error::Keys(_)))); + } + #[test] fn test_coordinate() { let keys = Keys::generate(); @@ -443,19 +840,21 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!(root_vec.contains(&Tag::from_standardized_without_cell( - TagStandard::Coordinate { - coordinate: coordinate.clone(), - relay_url: None, - uppercase: true - } - ))); + assert!(root_vec.contains(&Tag::from(Nip22Tag::Coordinate { + coordinate: coordinate.clone(), + relay_hint: None, + uppercase: true, + }))); check_pubkey(&root_vec, keys.public_key(), true); check_kind(&root_vec, kind, true); // Parent let parent_vec = comment_target.as_vec(false); - assert!(parent_vec.contains(&Tag::coordinate(coordinate, None))); + assert!(parent_vec.contains(&Tag::from(Nip22Tag::Coordinate { + coordinate, + relay_hint: None, + uppercase: false, + }))); check_pubkey(&parent_vec, keys.public_key(), false); check_kind(&parent_vec, kind, false); } @@ -469,24 +868,20 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!(root_vec.contains(&Tag::from_standardized_without_cell( - TagStandard::ExternalContent { - content: external_content.clone(), - hint: None, - uppercase: true - } - ))); + assert!(root_vec.contains(&Tag::from(Nip22Tag::ExternalContent { + content: external_content.clone(), + hint: None, + uppercase: true, + }))); check_nip73_kind(&root_vec, kind.clone(), true); // Parent let parent_vec = comment_target.as_vec(false); - assert!(parent_vec.contains(&Tag::from_standardized_without_cell( - TagStandard::ExternalContent { - content: external_content.clone(), - hint: None, - uppercase: false - } - ))); + assert!(parent_vec.contains(&Tag::from(Nip22Tag::ExternalContent { + content: external_content.clone(), + hint: None, + uppercase: false, + }))); check_nip73_kind(&parent_vec, kind, false); } } diff --git a/crates/nostr/src/nips/nip23.rs b/crates/nostr/src/nips/nip23.rs new file mode 100644 index 000000000..f8cc095de --- /dev/null +++ b/crates/nostr/src/nips/nip23.rs @@ -0,0 +1,218 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-23: Long-form Content +//! +//! <https://github.com/nostr-protocol/nips/blob/master/23.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::{ + take_and_parse_from_str, take_and_parse_optional_from_str, take_string, take_timestamp, +}; +use crate::types::image; +use crate::types::url::{self, Url}; +use crate::{ImageDimensions, Timestamp}; + +const TITLE: &str = "title"; +const IMAGE: &str = "image"; +const SUMMARY: &str = "summary"; +const PUBLISHED_AT: &str = "published_at"; +const HASHTAG: &str = "t"; + +/// NIP-23 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Image error + Image(image::Error), + /// Parse Int error + ParseInt(ParseIntError), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Image(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(url::Error::Url(e)) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-23 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/23.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip23Tag { + /// `title` tag + Title(String), + /// `image` tag + Image(Url, Option<ImageDimensions>), + /// `summary` tag + Summary(String), + /// `published_at` tag + PublishedAt(Timestamp), + /// `t` tag + Hashtag(String), +} + +impl TagCodec for Nip23Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + IMAGE => { + let (url, dimensions) = parse_image_tag(iter)?; + Ok(Self::Image(url, dimensions)) + } + SUMMARY => Ok(Self::Summary(take_string(&mut iter, "summary")?)), + PUBLISHED_AT => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::PublishedAt(timestamp)) + } + HASHTAG => Ok(Self::Hashtag( + take_string(&mut iter, "hashtag")?.to_lowercase(), + )), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + Self::Image(url, dimensions) => { + let mut tag: Vec<String> = Vec::with_capacity(2 + dimensions.is_some() as usize); + tag.push(String::from(IMAGE)); + tag.push(url.to_string()); + if let Some(dimensions) = dimensions { + tag.push(dimensions.to_string()); + } + Tag::new(tag) + } + Self::Summary(summary) => Tag::new(vec![String::from(SUMMARY), summary.clone()]), + Self::PublishedAt(timestamp) => { + Tag::new(vec![String::from(PUBLISHED_AT), timestamp.to_string()]) + } + Self::Hashtag(hashtag) => Tag::new(vec![String::from(HASHTAG), hashtag.to_lowercase()]), + } + } +} + +impl_tag_codec_conversions!(Nip23Tag); + +fn parse_image_tag<T, S>(mut iter: T) -> Result<(Url, Option<ImageDimensions>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "image URL")?; + let dimensions: Option<ImageDimensions> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((url, dimensions)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_title_tag() { + let tag = vec!["title", "Lorem Ipsum"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::Title(String::from("Lorem Ipsum"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_image_tag() { + let tag = vec!["image", "https://example.com/image.jpg", "1024x768"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip23Tag::Image( + Url::parse("https://example.com/image.jpg").unwrap(), + Some(ImageDimensions::new(1024, 768)) + ) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_summary_tag() { + let tag = vec!["summary", "Article summary"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::Summary(String::from("Article summary"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_published_at_tag() { + let tag = vec!["published_at", "1296962229"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::PublishedAt(Timestamp::from(1296962229))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_hashtag_tag() { + let tag = vec!["t", "Placeholder"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::Hashtag(String::from("placeholder"))); + assert_eq!( + parsed.to_tag(), + Tag::parse(vec!["t", "placeholder"]).unwrap() + ); + } +} diff --git a/crates/nostr/src/nips/nip25.rs b/crates/nostr/src/nips/nip25.rs index 8ce317338..ef0ee7e31 100644 --- a/crates/nostr/src/nips/nip25.rs +++ b/crates/nostr/src/nips/nip25.rs @@ -6,8 +6,84 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/25.md> -use super::nip01::Coordinate; -use crate::{Event, EventId, Kind, PublicKey, RelayUrl, Tag, TagStandard, Tags}; +use alloc::string::{String, ToString}; +use core::fmt; +use core::num::ParseIntError; + +use super::nip01::{Coordinate, Nip01Tag}; +use super::util::take_and_parse_from_str; +use crate::event::tag::{Tag, TagCodec, TagCodecError, Tags, impl_tag_codec_conversions}; +use crate::{Event, EventId, Kind, PublicKey, RelayUrl}; + +/// NIP-25 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Failed to parse integer + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-25 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/25.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip25Tag { + /// `k` tag + Kind(Kind), +} + +impl TagCodec for Nip25Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "k" => { + let kind: Kind = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "kind")?; + Ok(Self::Kind(kind)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Kind(kind) => Tag::new(vec![String::from("k"), kind.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip25Tag); /// Reaction target #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -30,7 +106,7 @@ impl ReactionTarget { Self { event_id: event.id, public_key: event.pubkey, - coordinate: event.coordinate().map(|c| c.into_owned()), + coordinate: event.coordinate(), kind: Some(event.kind), relay_hint, } @@ -43,32 +119,35 @@ impl ReactionTarget { // Serialization order: keep the `e` and `a` tags together, followed by the `p` and other tags. - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { - event_id: self.event_id, - relay_url: self.relay_hint.clone(), - public_key: Some(self.public_key), - marker: None, - uppercase: false, - })); + tags.push( + Nip01Tag::Event { + id: self.event_id, + relay_hint: self.relay_hint.clone(), + public_key: Some(self.public_key), + } + .to_tag(), + ); if let Some(coordinate) = self.coordinate { - tags.push(Tag::coordinate(coordinate, self.relay_hint.clone())); + tags.push( + Nip01Tag::Coordinate { + coordinate, + relay_hint: self.relay_hint.clone(), + } + .to_tag(), + ); } - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { + tags.push( + Nip01Tag::PublicKey { public_key: self.public_key, - relay_url: self.relay_hint, - alias: None, - uppercase: false, - }, - )); + relay_hint: self.relay_hint, + } + .to_tag(), + ); if let Some(kind) = self.kind { - tags.push(Tag::from_standardized_without_cell(TagStandard::Kind { - kind, - uppercase: false, - })); + tags.push(Nip25Tag::Kind(kind).to_tag()); } tags @@ -80,9 +159,23 @@ impl From<&Event> for ReactionTarget { Self { event_id: event.id, public_key: event.pubkey, - coordinate: event.coordinate().map(|c| c.into_owned()), + coordinate: event.coordinate(), kind: Some(event.kind), relay_hint: None, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kind_tag() { + let tag = vec!["k", "1"]; + let parsed = Nip25Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip25Tag::Kind(Kind::TextNote)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip30.rs b/crates/nostr/src/nips/nip30.rs new file mode 100644 index 000000000..23bc6346f --- /dev/null +++ b/crates/nostr/src/nips/nip30.rs @@ -0,0 +1,207 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-30: Custom Emoji +//! +//! <https://github.com/nostr-protocol/nips/blob/master/30.md> + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; + +use super::nip01::{self, Coordinate}; +use super::util::{take_and_parse_from_str, take_and_parse_optional_coordinate, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; + +const EMOJI: &str = "emoji"; + +/// NIP-30 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// NIP-01 error + Nip01(nip01::Error), + /// URL parse error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), + /// Invalid shortcode + InvalidShortcode, +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nip01(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + Self::InvalidShortcode => f.write_str("Invalid shortcode"), + } + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-30 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/30.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip30Tag { + /// `emoji` tag + Emoji { + /// Emoji shortcode + shortcode: String, + /// URL to image + image_url: Url, + /// Optional emoji set address + emoji_set: Option<Coordinate>, + }, +} + +impl TagCodec for Nip30Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + EMOJI => { + let (shortcode, image_url, emoji_set) = parse_emoji_tag(iter)?; + Ok(Self::Emoji { + shortcode, + image_url, + emoji_set, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Emoji { + shortcode, + image_url, + emoji_set, + } => { + let mut tag: Vec<String> = Vec::with_capacity(3 + emoji_set.is_some() as usize); + tag.push(String::from(EMOJI)); + tag.push(shortcode.clone()); + tag.push(image_url.to_string()); + + if let Some(emoji_set) = emoji_set { + tag.push(emoji_set.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip30Tag); + +fn parse_emoji_tag<T, S>(mut iter: T) -> Result<(String, Url, Option<Coordinate>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let shortcode: String = take_string(&mut iter, "shortcode")?; + + if !is_valid_shortcode(&shortcode) { + return Err(Error::InvalidShortcode); + } + + let image_url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "image URL")?; + let emoji_set: Option<Coordinate> = take_and_parse_optional_coordinate(&mut iter)?; + + Ok((shortcode, image_url, emoji_set)) +} + +fn is_valid_shortcode(shortcode: &str) -> bool { + !shortcode.is_empty() + && shortcode + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Kind, PublicKey}; + + #[test] + fn test_nip30_emoji_tag() { + let image_url = Url::parse("https://example.com/emoji.png").unwrap(); + let tag = vec!["emoji", "soapbox", "https://example.com/emoji.png"]; + let parsed = Nip30Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip30Tag::Emoji { + shortcode: String::from("soapbox"), + image_url, + emoji_set: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_nip30_emoji_tag_with_set() { + let image_url = Url::parse("https://example.com/emoji.png").unwrap(); + let emoji_set = Coordinate::new( + Kind::EmojiSet, + PublicKey::from_hex("79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6") + .unwrap(), + ) + .identifier("blobcats"); + let tag = vec![ + "emoji", + "soapbox", + "https://example.com/emoji.png", + "30030:79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6:blobcats", + ]; + let parsed = Nip30Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip30Tag::Emoji { + shortcode: String::from("soapbox"), + image_url, + emoji_set: Some(emoji_set), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_nip30_invalid_shortcode() { + let tag = vec!["emoji", "soap box", "https://example.com/emoji.png"]; + assert_eq!(Nip30Tag::parse(&tag).unwrap_err(), Error::InvalidShortcode); + } +} diff --git a/crates/nostr/src/nips/nip31.rs b/crates/nostr/src/nips/nip31.rs new file mode 100644 index 000000000..71771a31a --- /dev/null +++ b/crates/nostr/src/nips/nip31.rs @@ -0,0 +1,100 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-31: Dealing with unknown event kinds +//! +//! <https://github.com/nostr-protocol/nips/blob/master/31.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +const ALT: &str = "alt"; + +/// NIP-31 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-31 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/31.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip31Tag { + /// `alt` tag + Alt(String), +} + +impl TagCodec for Nip31Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + ALT => { + let alt: String = take_string(&mut iter, "alt value")?; + Ok(Self::Alt(alt)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Alt(value) => Tag::new(vec![String::from(ALT), value.clone()]), + } + } +} + +impl_tag_codec_conversions!(Nip31Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nip31_alt_tag() { + let tag = vec!["alt", "Something"]; + let parsed = Nip31Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip31Tag::Alt(String::from("Something"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_invalid_alt_tag_missing_value() { + let tag = vec!["alt"]; + assert_eq!( + Nip31Tag::parse(&tag).unwrap_err(), + Error::Codec(TagCodecError::Missing("alt value")) + ); + } +} diff --git a/crates/nostr/src/nips/nip32.rs b/crates/nostr/src/nips/nip32.rs new file mode 100644 index 000000000..a9a70e758 --- /dev/null +++ b/crates/nostr/src/nips/nip32.rs @@ -0,0 +1,119 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-32: Labeling +//! +//! <https://github.com/nostr-protocol/nips/blob/master/32.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +/// NIP-32 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-32 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/32.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip32Tag { + /// `L` tag + LabelNamespace(String), + /// `l` tag + Label { + /// Label value + value: String, + /// Label namespace + namespace: String, + }, +} + +impl TagCodec for Nip32Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "L" => Ok(Self::LabelNamespace(take_string( + &mut iter, + "label namespace", + )?)), + "l" => { + let value: String = take_string(&mut iter, "label")?; + let namespace: String = take_string(&mut iter, "label namespace")?; + + Ok(Self::Label { value, namespace }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::LabelNamespace(namespace) => Tag::new(vec![String::from("L"), namespace.clone()]), + Self::Label { value, namespace } => { + Tag::new(vec![String::from("l"), value.clone(), namespace.clone()]) + } + } + } +} + +impl_tag_codec_conversions!(Nip32Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_label_namespace_tag() { + let tag = vec!["L", "test"]; + let parsed = Nip32Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip32Tag::LabelNamespace(String::from("test"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_label_tag() { + let tag = vec!["l", "other", "test"]; + let parsed = Nip32Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip32Tag::Label { + value: String::from("other"), + namespace: String::from("test") + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip34.rs b/crates/nostr/src/nips/nip34.rs index 248d24923..7fe1fde03 100644 --- a/crates/nostr/src/nips/nip34.rs +++ b/crates/nostr/src/nips/nip34.rs @@ -8,20 +8,464 @@ #![allow(clippy::wrong_self_convention)] -use alloc::borrow::Cow; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; +use core::str::FromStr; +use hashes::hex::HexToArrayError; use hashes::sha1::Hash as Sha1Hash; -use crate::event::builder::{Error, EventBuilder, WrongKindError}; -use crate::nips::nip01::{self, Coordinate}; -use crate::types::url::Url; -use crate::{EventId, Kind, PublicKey, RelayUrl, Tag, TagKind, TagStandard, Timestamp}; +use super::nip01::{self, Coordinate}; +use super::nip22::Nip22Tag; +use super::util::{take_and_parse_from_str, take_string}; +use crate::event::builder::{Error as BuilderError, EventBuilder, WrongKindError}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; +use crate::{EventId, Kind, PublicKey, RelayUrl, Timestamp, key}; + +const EUC: &str = "euc"; +const APPLIED_AS_COMMITS: &str = "applied-as-commits"; +const BRANCH_NAME: &str = "branch-name"; +const CLONE: &str = "clone"; +const COMMIT: &str = "commit"; +const COMMIT_PGP_SIG: &str = "commit-pgp-sig"; +const COMMITTER: &str = "committer"; +const CURRENT_COMMIT: &str = "c"; +const DESCRIPTION: &str = "description"; +const GRASP: &str = "g"; +const HEAD: &str = "HEAD"; +const MAINTAINERS: &str = "maintainers"; +const MERGE_BASE: &str = "merge-base"; +const MERGE_COMMIT: &str = "merge-commit"; +const NAME: &str = "name"; +const PARENT_COMMIT: &str = "parent-commit"; +const REFERENCE: &str = "r"; +const REFS_HEADS: &str = "refs/heads/"; +const REFS_TAGS: &str = "refs/tags/"; +const SUBJECT: &str = "subject"; +const WEB: &str = "web"; +const RELAYS: &str = "relays"; + +/// NIP-34 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Keys error + Keys(key::Error), + /// NIP-01 error + Nip01(nip01::Error), + /// Relay URL error + RelayUrl(url::Error), + /// URL error + Url(url::ParseError), + /// Hex to array error + Hex(HexToArrayError), + /// Parse integer error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), + /// Invalid `HEAD` tag + InvalidHeadTag, +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Keys(e) => e.fmt(f), + Self::Nip01(e) => e.fmt(f), + Self::RelayUrl(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Hex(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + Self::InvalidHeadTag => f.write_str("Invalid HEAD tag"), + } + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::RelayUrl(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<HexToArrayError> for Error { + fn from(e: HexToArrayError) -> Self { + Self::Hex(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-34 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/34.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip34Tag { + /// `applied-as-commits` tag + AppliedAsCommits(Vec<Sha1Hash>), + /// `branch-name` tag + BranchName(String), + /// `clone` tag + Clone(Vec<Url>), + /// `commit` tag + Commit(Sha1Hash), + /// `commit-pgp-sig` tag + CommitPgpSig(String), + /// `committer` tag + Committer { + /// Name + name: String, + /// Email + email: String, + /// Timestamp + timestamp: Timestamp, + /// Timezone offset in minutes + offset_minutes: i32, + }, + /// `c` tag + CurrentCommit(Sha1Hash), + /// `description` tag + Description(String), + /// `r` tag with `euc` marker + EarliestUniqueCommitId(Sha1Hash), + /// `g` tag + Grasp(RelayUrl), + /// `HEAD` tag + Head(String), + /// `maintainers` tag + Maintainers(Vec<PublicKey>), + /// `merge-base` tag + MergeBase(Sha1Hash), + /// `merge-commit` tag + MergeCommit(Sha1Hash), + /// `name` tag + Name(String), + /// `parent-commit` tag + ParentCommit(Sha1Hash), + /// `r` tag + Reference(Sha1Hash), + /// `refs/heads/<branch>` tag + RefHead { + /// Branch name + branch: String, + /// Commit ID + commit: Sha1Hash, + }, + /// `refs/tags/<tag>` tag + RefTag { + /// Tag name + name: String, + /// Commit ID + commit: Sha1Hash, + }, + /// `relays` tag + Relays(Vec<RelayUrl>), + /// `subject` tag + Subject(String), + /// `web` tag + Web(Vec<Url>), +} + +impl TagCodec for Nip34Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + let kind: &str = kind.as_ref(); + + match kind { + APPLIED_AS_COMMITS => Ok(Self::AppliedAsCommits(parse_commit_list(iter)?)), + BRANCH_NAME => Ok(Self::BranchName(take_string(&mut iter, "branch name")?)), + CLONE => Ok(Self::Clone(parse_url_list(iter)?)), + COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "commit ID")?; + Ok(Self::Commit(commit)) + } + COMMIT_PGP_SIG => Ok(Self::CommitPgpSig(take_string( + &mut iter, + "commit pgp signature", + )?)), + COMMITTER => { + let name: String = iter + .next() + .map(|value| value.as_ref().to_string()) + .unwrap_or_default(); + let email: String = iter + .next() + .map(|value| value.as_ref().to_string()) + .unwrap_or_default(); + let timestamp: Timestamp = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "committer timestamp")?; + let offset_minutes: i32 = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "committer offset")?; + + Ok(Self::Committer { + name, + email, + timestamp, + offset_minutes, + }) + } + CURRENT_COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "current commit ID")?; + Ok(Self::CurrentCommit(commit)) + } + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + GRASP => { + let relay: RelayUrl = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "grasp relay URL")?; + Ok(Self::Grasp(relay)) + } + HEAD => { + let head: S = iter.next().ok_or(TagCodecError::Missing("head"))?; + let branch: &str = head + .as_ref() + .strip_prefix("ref: refs/heads/") + .ok_or(Error::InvalidHeadTag)?; + Ok(Self::Head(branch.to_string())) + } + MAINTAINERS => Ok(Self::Maintainers(parse_public_keys(iter)?)), + MERGE_BASE => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "merge base")?; + Ok(Self::MergeBase(commit)) + } + MERGE_COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "merge commit ID")?; + Ok(Self::MergeCommit(commit)) + } + NAME => Ok(Self::Name(take_string(&mut iter, "name")?)), + PARENT_COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "parent commit ID")?; + Ok(Self::ParentCommit(commit)) + } + REFERENCE => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "reference commit ID")?; + match iter.next() { + Some(marker) if marker.as_ref() == EUC => { + Ok(Self::EarliestUniqueCommitId(commit)) + } + Some(_) => Err(TagCodecError::Unknown.into()), + None => Ok(Self::Reference(commit)), + } + } + SUBJECT => Ok(Self::Subject(take_string(&mut iter, "subject")?)), + WEB => Ok(Self::Web(parse_url_list(iter)?)), + RELAYS => Ok(Self::Relays(parse_relay_urls(iter)?)), + _ if kind.starts_with(REFS_HEADS) => { + let commit: S = iter.next().ok_or(TagCodecError::Missing("commit id"))?; + Ok(Self::RefHead { + branch: kind.trim_start_matches(REFS_HEADS).to_string(), + commit: Sha1Hash::from_str(commit.as_ref())?, + }) + } + _ if kind.starts_with(REFS_TAGS) => { + let commit: S = iter.next().ok_or(TagCodecError::Missing("commit id"))?; + Ok(Self::RefTag { + name: kind.trim_start_matches(REFS_TAGS).to_string(), + commit: Sha1Hash::from_str(commit.as_ref())?, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } -/// Earlier unique commit ID marker -pub const EUC: &str = "euc"; + fn to_tag(&self) -> Tag { + match self { + Self::AppliedAsCommits(commits) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + commits.len()); + tag.push(String::from(APPLIED_AS_COMMITS)); + tag.extend(commits.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::BranchName(name) => Tag::new(vec![String::from(BRANCH_NAME), name.clone()]), + Self::Clone(urls) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); + tag.push(String::from(CLONE)); + tag.extend(urls.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Commit(commit) => Tag::new(vec![String::from(COMMIT), commit.to_string()]), + Self::CommitPgpSig(signature) => { + Tag::new(vec![String::from(COMMIT_PGP_SIG), signature.clone()]) + } + Self::Committer { + name, + email, + timestamp, + offset_minutes, + } => Tag::new(vec![ + String::from(COMMITTER), + name.clone(), + email.clone(), + timestamp.to_string(), + offset_minutes.to_string(), + ]), + Self::CurrentCommit(commit) => { + Tag::new(vec![String::from(CURRENT_COMMIT), commit.to_string()]) + } + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::EarliestUniqueCommitId(commit) => Tag::new(vec![ + String::from(REFERENCE), + commit.to_string(), + String::from(EUC), + ]), + Self::Grasp(relay) => Tag::new(vec![String::from(GRASP), relay.to_string()]), + Self::Head(branch) => Tag::new(vec![ + String::from(HEAD), + format!("ref: refs/heads/{branch}"), + ]), + Self::Maintainers(public_keys) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + public_keys.len()); + tag.push(String::from(MAINTAINERS)); + tag.extend(public_keys.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::MergeBase(commit) => Tag::new(vec![String::from(MERGE_BASE), commit.to_string()]), + Self::MergeCommit(commit) => { + Tag::new(vec![String::from(MERGE_COMMIT), commit.to_string()]) + } + Self::Name(name) => Tag::new(vec![String::from(NAME), name.clone()]), + Self::ParentCommit(commit) => { + Tag::new(vec![String::from(PARENT_COMMIT), commit.to_string()]) + } + Self::Reference(commit) => Tag::new(vec![String::from(REFERENCE), commit.to_string()]), + Self::RefHead { branch, commit } => { + Tag::new(vec![format!("{REFS_HEADS}{branch}"), commit.to_string()]) + } + Self::RefTag { name, commit } => { + Tag::new(vec![format!("{REFS_TAGS}{name}"), commit.to_string()]) + } + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + relays.len()); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Subject(subject) => Tag::new(vec![String::from(SUBJECT), subject.clone()]), + Self::Web(urls) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); + tag.push(String::from(WEB)); + tag.extend(urls.iter().map(ToString::to_string)); + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip34Tag); + +fn parse_commit_list<I, S>(iter: I) -> Result<Vec<Sha1Hash>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<Sha1Hash> = iter + .into_iter() + .map(|value| Sha1Hash::from_str(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("commits").into()); + } + + Ok(values) +} + +fn parse_public_keys<I, S>(iter: I) -> Result<Vec<PublicKey>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<PublicKey> = iter + .into_iter() + .map(|value| PublicKey::from_hex(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("public keys").into()); + } + + Ok(values) +} + +fn parse_relay_urls<I, S>(iter: I) -> Result<Vec<RelayUrl>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<RelayUrl> = iter + .into_iter() + .map(|value| RelayUrl::parse(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("relay URLs").into()); + } + + Ok(values) +} + +fn parse_url_list<I, S>(iter: I) -> Result<Vec<Url>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<Url> = iter + .into_iter() + .map(|value| Url::parse(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("URLs").into()); + } + + Ok(values) +} /// Git Repository Announcement /// @@ -53,10 +497,10 @@ pub struct GitRepositoryAnnouncement { } impl GitRepositoryAnnouncement { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { if self.id.is_empty() { // TODO: should return another error? - return Err(Error::NIP01(nip01::Error::InvalidCoordinate)); + return Err(BuilderError::NIP01(nip01::Error::InvalidCoordinate)); } let mut tags: Vec<Tag> = Vec::with_capacity(1); @@ -66,49 +510,37 @@ impl GitRepositoryAnnouncement { // Add name if let Some(name) = self.name { - tags.push(Tag::from_standardized_without_cell(TagStandard::Name(name))); + tags.push(Nip34Tag::Name(name).to_tag()); } // Add description if let Some(description) = self.description { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Description(description), - )); + tags.push(Nip34Tag::Description(description).to_tag()); } // Add web if !self.web.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Web( - self.web, - ))); + tags.push(Nip34Tag::Web(self.web).to_tag()); } // Add clone if !self.clone.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::GitClone( - self.clone, - ))); + tags.push(Nip34Tag::Clone(self.clone).to_tag()); } // Add relays if !self.relays.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Relays( - self.relays, - ))); + tags.push(Nip34Tag::Relays(self.relays).to_tag()); } // Add EUC if let Some(commit) = self.euc { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitEarliestUniqueCommitId(commit), - )); + tags.push(Nip34Tag::EarliestUniqueCommitId(commit).to_tag()); } // Add maintainers if !self.maintainers.is_empty() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitMaintainers(self.maintainers), - )); + tags.push(Nip34Tag::Maintainers(self.maintainers).to_tag()); } // Build @@ -131,10 +563,10 @@ pub struct GitIssue { impl GitIssue { /// Based on <https://github.com/nostr-protocol/nips/blob/ea36ec9ed7596e49bf7f217b05954c1fecacad88/34.md> revision. - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -155,9 +587,7 @@ impl GitIssue { // Add subject if let Some(subject) = self.subject { - tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( - subject, - ))); + tags.push(Nip34Tag::Subject(subject).to_tag()); } // Add labels @@ -243,10 +673,10 @@ pub struct GitPatch { } impl GitPatch { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -266,7 +696,7 @@ impl GitPatch { tags.push(Tag::public_key(owner_public_key)); // Add EUC (without `euc` marker) - tags.push(Tag::reference(self.euc.to_string())); + tags.push(Nip34Tag::Reference(self.euc).to_tag()); // Serialize content to string (used later) let content: String = self.content.to_string(); @@ -285,27 +715,19 @@ impl GitPatch { .. } => { tags.reserve_exact(5); - tags.push(Tag::reference(commit.to_string())); - tags.push(Tag::from_standardized_without_cell(TagStandard::GitCommit( - commit, - ))); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("parent-commit")), - vec![parent_commit.to_string()], - )); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("commit-pgp-sig")), - vec![commit_pgp_sig.unwrap_or_default()], - )); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("committer")), - vec![ - committer.name.unwrap_or_default(), - committer.email.unwrap_or_default(), - committer.timestamp.to_string(), - committer.offset_minutes.to_string(), - ], - )); + tags.push(Nip34Tag::Reference(commit).to_tag()); + tags.push(Nip34Tag::Commit(commit).to_tag()); + tags.push(Nip34Tag::ParentCommit(parent_commit).to_tag()); + tags.push(Nip34Tag::CommitPgpSig(commit_pgp_sig.unwrap_or_default()).to_tag()); + tags.push( + Nip34Tag::Committer { + name: committer.name.unwrap_or_default(), + email: committer.email.unwrap_or_default(), + timestamp: committer.timestamp, + offset_minutes: committer.offset_minutes, + } + .to_tag(), + ); } } @@ -341,10 +763,10 @@ pub struct GitPullRequest { } impl GitPullRequest { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -367,32 +789,23 @@ impl GitPullRequest { // Add subject if let Some(subject) = self.subject { - tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( - subject, - ))); + tags.push(Nip34Tag::Subject(subject).to_tag()); } // Add labels tags.extend(self.labels.into_iter().map(Tag::hashtag)); // Add current commit - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("c")), - vec![self.current_commit.to_string()], - )); + tags.push(Nip34Tag::CurrentCommit(self.current_commit).to_tag()); // Add clone URLs if !self.clone.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::GitClone( - self.clone, - ))); + tags.push(Nip34Tag::Clone(self.clone).to_tag()); } // Add branch name if let Some(branch_name) = self.branch_name { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitBranchName(branch_name), - )); + tags.push(Nip34Tag::BranchName(branch_name).to_tag()); } // Add root patch event (if this is a revision) @@ -402,9 +815,7 @@ impl GitPullRequest { // Add merge base if let Some(merge_base) = self.merge_base { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitMergeBase(merge_base), - )); + tags.push(Nip34Tag::MergeBase(merge_base).to_tag()); } // Build @@ -430,10 +841,10 @@ pub struct GitPullRequestUpdate { } impl GitPullRequestUpdate { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -454,33 +865,35 @@ impl GitPullRequestUpdate { tags.push(Tag::public_key(owner_public_key)); // Add NIP-22 tags for the pull request being updated - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("E")), - vec![self.pull_request_event.to_string()], - )); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("P")), - vec![self.pull_request_author.to_string()], - )); + tags.push( + Nip22Tag::Event { + id: self.pull_request_event, + relay_hint: None, + public_key: None, + uppercase: true, + } + .to_tag(), + ); + tags.push( + Nip22Tag::PublicKey { + public_key: self.pull_request_author, + relay_hint: None, + uppercase: true, + } + .to_tag(), + ); // Add updated current commit - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("c")), - vec![self.current_commit.to_string()], - )); + tags.push(Nip34Tag::CurrentCommit(self.current_commit).to_tag()); // Add clone URLs if !self.clone.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::GitClone( - self.clone, - ))); + tags.push(Nip34Tag::Clone(self.clone).to_tag()); } // Add merge base if let Some(merge_base) = self.merge_base { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitMergeBase(merge_base), - )); + tags.push(Nip34Tag::MergeBase(merge_base).to_tag()); } // Build @@ -502,7 +915,7 @@ impl GitUserGraspList { let tags: Vec<Tag> = self .grasp_servers .into_iter() - .map(|url| Tag::custom(TagKind::Custom(Cow::Borrowed("g")), vec![url.to_string()])) + .map(|url| Nip34Tag::Grasp(url).to_tag()) .collect(); EventBuilder::new(Kind::GitUserGraspList, "").tags(tags) @@ -561,6 +974,30 @@ mod tests { assert_eq!(event.tags, tags); } + #[test] + fn test_standardized_git_head_tag() { + let tag = vec![String::from("HEAD"), String::from("ref: refs/heads/main")]; + let parsed = Nip34Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip34Tag::Head(String::from("main"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_applied_as_commits_tag() { + let commit_1 = Sha1Hash::from_str("59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca").unwrap(); + let commit_2 = Sha1Hash::from_str("b1fa697b5cd42fbb6ec9fef9009609200387e0b4").unwrap(); + let tag = vec![ + String::from("applied-as-commits"), + commit_1.to_string(), + commit_2.to_string(), + ]; + let parsed = Nip34Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip34Tag::AppliedAsCommits(vec![commit_1, commit_2])); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + #[test] fn test_git_issue() { let pk = @@ -663,4 +1100,63 @@ mod tests { .unwrap(); assert_eq!(event.tags, tags); } + + #[test] + fn test_git_pull_request_update() { + let pk = + PublicKey::parse("npub1drvpzev3syqt0kjrls50050uzf25gehpz9vgdw08hvex7e0vgfeq0eseet") + .unwrap(); + let repository = Coordinate::new(Kind::GitRepoAnnouncement, pk).identifier("rust-nostr"); + let pull_request_event = + EventId::from_hex("70b09cb6f4f6b2b8c3d2b6f0bbf8f4f6ca1d9c1df4d5f85f0dbb2a7a9c7c4f21") + .unwrap(); + let pull_request_author = + PublicKey::from_hex("68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272") + .unwrap(); + + let update = GitPullRequestUpdate { + repository, + pull_request_event, + pull_request_author, + current_commit: Sha1Hash::from_str("b1fa697b5cd42fbb6ec9fef9009609200387e0b4").unwrap(), + clone: vec![Url::parse("https://github.com/rust-nostr/nostr.git").unwrap()], + merge_base: Some( + Sha1Hash::from_str("c88d901b42ff8389330d6d5d4044cf1d196696f3").unwrap(), + ), + }; + + let keys = Keys::generate(); + let event: Event = update + .to_event_builder() + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + + assert_eq!(event.kind, Kind::GitPullRequestUpdate); + assert!(event.content.is_empty()); + + let tags = Tags::parse([ + vec![ + "a", + "30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:rust-nostr", + ], + vec![ + "p", + "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272", + ], + vec![ + "E", + "70b09cb6f4f6b2b8c3d2b6f0bbf8f4f6ca1d9c1df4d5f85f0dbb2a7a9c7c4f21", + ], + vec![ + "P", + "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272", + ], + vec!["c", "b1fa697b5cd42fbb6ec9fef9009609200387e0b4"], + vec!["clone", "https://github.com/rust-nostr/nostr.git"], + vec!["merge-base", "c88d901b42ff8389330d6d5d4044cf1d196696f3"], + ]) + .unwrap(); + assert_eq!(event.tags, tags); + } } diff --git a/crates/nostr/src/nips/nip35.rs b/crates/nostr/src/nips/nip35.rs index a07738b43..1bba49aad 100644 --- a/crates/nostr/src/nips/nip35.rs +++ b/crates/nostr/src/nips/nip35.rs @@ -9,12 +9,13 @@ //! <https://github.com/nostr-protocol/nips/blob/master/35.md> use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use hashes::sha1::Hash as Sha1Hash; use crate::types::url::Url; -use crate::{EventBuilder, Kind, Tag, TagKind}; +use crate::{EventBuilder, Kind, Tag}; /// Represents a file within a torrent. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -54,23 +55,20 @@ impl Torrent { + self.hashtags.len(), ); - tags.push(Tag::title(self.title)); + tags.push(Tag::new(vec![String::from("title"), self.title])); - tags.push(Tag::custom(TagKind::x(), [self.info_hash.to_string()])); + tags.push(Tag::custom("x", [self.info_hash.to_string()])); for file in self.files.into_iter() { - tags.push(Tag::custom( - TagKind::File, - [file.name, file.size.to_string()], - )); + tags.push(Tag::custom("file", [file.name, file.size.to_string()])); } for tracker in self.trackers.into_iter() { - tags.push(Tag::custom(TagKind::Tracker, [tracker.to_string()])); + tags.push(Tag::custom("tracker", [tracker.to_string()])); } for cat in self.categories.into_iter() { - tags.push(Tag::custom(TagKind::i(), [format!("tcat:{cat}")])); + tags.push(Tag::custom("i", [format!("tcat:{cat}")])); } for tag in self.hashtags.into_iter() { diff --git a/crates/nostr/src/nips/nip38.rs b/crates/nostr/src/nips/nip38.rs index b27507e34..4c312cafe 100644 --- a/crates/nostr/src/nips/nip38.rs +++ b/crates/nostr/src/nips/nip38.rs @@ -82,7 +82,7 @@ impl From<LiveStatus> for Vec<Tag> { } if let Some(content) = reference { - tags.push(Tag::reference(content)); + tags.push(Tag::new(vec![String::from("r"), content])); } tags diff --git a/crates/nostr/src/nips/nip39.rs b/crates/nostr/src/nips/nip39.rs index e97b9e8da..d70ca24e1 100644 --- a/crates/nostr/src/nips/nip39.rs +++ b/crates/nostr/src/nips/nip39.rs @@ -2,17 +2,25 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP39: External Identities in Profiles +//! NIP-39: External Identities in Profiles //! //! <https://github.com/nostr-protocol/nips/blob/master/39.md> use alloc::string::{String, ToString}; +use alloc::vec; use core::fmt; use core::str::FromStr; -/// NIP56 error +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +const IDENTITY: &str = "i"; + +/// NIP-39 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// Codec error + Codec(TagCodecError), /// Invalid identity InvalidIdentity, } @@ -22,11 +30,18 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Codec(e) => e.fmt(f), Self::InvalidIdentity => f.write_str("Invalid identity tag"), } } } +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Supported external identity providers /// /// <https://github.com/nostr-protocol/nips/blob/master/39.md> @@ -105,7 +120,101 @@ impl Identity { } #[inline] - pub(crate) fn tag_platform_identity(&self) -> String { + fn tag_platform_identity(&self) -> String { format!("{}:{}", self.platform, self.ident) } } + +/// Standardized NIP-39 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/39.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip39Tag { + /// `i` tag + Identity(Identity), +} + +impl TagCodec for Nip39Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + IDENTITY => { + let platform_ident: String = take_string(&mut iter, "identity")?; + let proof: String = take_string(&mut iter, "proof")?; + + Ok(Self::Identity(Identity::new(platform_ident, proof)?)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Identity(identity) => Tag::new(vec![ + String::from(IDENTITY), + identity.tag_platform_identity(), + identity.proof.clone(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip39Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_tag() { + let parsed = + Nip39Tag::parse(["i", "github:semisol", "9721ce4ee4fceb91c9711ca2a6c9a5ab"]).unwrap(); + + assert_eq!( + parsed, + Nip39Tag::Identity(Identity { + platform: ExternalIdentity::GitHub, + ident: String::from("semisol"), + proof: String::from("9721ce4ee4fceb91c9711ca2a6c9a5ab"), + }) + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["i", "github:semisol", "9721ce4ee4fceb91c9711ca2a6c9a5ab",]).unwrap() + ); + } + + #[test] + fn test_identity_tag_with_extra_values() { + let parsed = Nip39Tag::parse([ + "i", + "twitter:semisol_public", + "1619358434134196225", + "extra", + ]) + .unwrap(); + + assert_eq!( + parsed, + Nip39Tag::Identity(Identity { + platform: ExternalIdentity::Twitter, + ident: String::from("semisol_public"), + proof: String::from("1619358434134196225"), + }) + ); + } + + #[test] + fn test_identity_tag_missing_proof() { + let err = Nip39Tag::parse(["i", "github:semisol"]).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("proof"))); + } +} diff --git a/crates/nostr/src/nips/nip40.rs b/crates/nostr/src/nips/nip40.rs new file mode 100644 index 000000000..d81b2ff7f --- /dev/null +++ b/crates/nostr/src/nips/nip40.rs @@ -0,0 +1,134 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-40: Expiration Timestamp +//! +//! <https://github.com/nostr-protocol/nips/blob/master/40.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::take_timestamp; +use crate::types::time::Timestamp; + +const EXPIRATION: &str = "expiration"; + +/// NIP-40 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-40 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/40.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip40Tag { + /// Expiration timestamp + Expiration(Timestamp), +} + +impl TagCodec for Nip40Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + EXPIRATION => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::Expiration(timestamp)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + let Self::Expiration(timestamp) = self; + let tag: Vec<String> = vec![String::from(EXPIRATION), timestamp.to_string()]; + Tag::new(tag) + } +} + +impl_tag_codec_conversions!(Nip40Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty_tag() { + let tag: Vec<String> = Vec::new(); + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::missing_tag_kind())); + } + + #[test] + fn test_non_existing_tag() { + let tag = vec!["hello"]; + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Unknown)); + } + + #[test] + fn test_standardized_expiration_tag() { + let raw = 1600000000; + let timestamp = Timestamp::from_secs(raw); + + // Simple + let tag = vec!["expiration".to_string(), raw.to_string()]; + let parsed = Nip40Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip40Tag::Expiration(timestamp)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid timestamp + let tag = vec!["expiration", "hello"]; + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::ParseInt(_))); + + // Missing timestamp + let tag = vec!["expiration"]; + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("timestamp"))); + } +} diff --git a/crates/nostr/src/nips/nip42.rs b/crates/nostr/src/nips/nip42.rs index 675b3b17d..f4fcca786 100644 --- a/crates/nostr/src/nips/nip42.rs +++ b/crates/nostr/src/nips/nip42.rs @@ -6,7 +6,93 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/42.md> -use crate::{Event, Kind, RelayUrl, TagKind, TagStandard}; +use alloc::string::{String, ToString}; +use alloc::vec; +use core::fmt; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::{take_relay_url, take_string}; +use crate::types::url; +use crate::{Event, Kind, RelayUrl}; + +const CHALLENGE: &str = "challenge"; +const RELAY: &str = "relay"; + +/// NIP-42 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-42 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/42.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip42Tag { + /// Authentication challenge + Challenge(String), + /// Relay URL + Relay(RelayUrl), +} + +impl TagCodec for Nip42Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + CHALLENGE => Ok(Self::Challenge(take_string(&mut iter, "challenge")?)), + RELAY => { + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(relay_url)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Challenge(challenge) => { + Tag::new(vec![String::from(CHALLENGE), challenge.clone()]) + } + Self::Relay(relay) => Tag::new(vec![String::from(RELAY), relay.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip42Tag); /// Check if the [`Event`] is a valid authentication. /// @@ -23,23 +109,23 @@ pub fn is_valid_auth_event(event: &Event, relay_url: &RelayUrl, challenge: &str) } // Check if it has "relay" tag - match event.tags.find_standardized(TagKind::Relay) { - Some(TagStandard::Relay(url)) => { - if url != relay_url { - return false; - } - } - Some(..) | None => return false, + let relay_matches: bool = event.tags.iter().any(|tag| match Nip42Tag::try_from(tag) { + Ok(Nip42Tag::Relay(url)) => &url == relay_url, + _ => false, + }); + + if !relay_matches { + return false; } // Check if it has the challenge - match event.tags.find_standardized(TagKind::Challenge) { - Some(TagStandard::Challenge(c)) => { - if c != challenge { - return false; - } - } - Some(..) | None => return false, + let challenge_matches: bool = event.tags.iter().any(|tag| match Nip42Tag::try_from(tag) { + Ok(Nip42Tag::Challenge(value)) => value == challenge, + _ => false, + }); + + if !challenge_matches { + return false; } // Valid @@ -51,6 +137,29 @@ mod tests { use super::*; use crate::{EventBuilder, Keys}; + #[test] + fn test_standardized_challenge_tag() { + let tag = vec!["challenge".to_string(), "1234567890".to_string()]; + let parsed = Nip42Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip42Tag::Challenge(String::from("1234567890"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_relay_tag() { + let relay = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["relay".to_string(), relay.to_string()]; + let parsed = Nip42Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip42Tag::Relay(relay.clone())); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + assert_eq!( + Nip42Tag::try_from(Tag::parse(["relay", "wss://relay.damus.io"]).unwrap()).unwrap(), + Nip42Tag::Relay(relay) + ); + } + #[test] fn test_valid_auth_event() { let keys = Keys::generate(); diff --git a/crates/nostr/src/nips/nip51.rs b/crates/nostr/src/nips/nip51.rs index dad8231bf..d1d32d5d1 100644 --- a/crates/nostr/src/nips/nip51.rs +++ b/crates/nostr/src/nips/nip51.rs @@ -1,15 +1,141 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP51: Lists +//! NIP-51: Lists //! //! <https://github.com/nostr-protocol/nips/blob/master/51.md> -use alloc::string::String; +use alloc::string::{String, ToString}; use alloc::vec::Vec; +use core::fmt; use super::nip01::Coordinate; -use crate::{EventId, PublicKey, Tag, TagStandard, Url}; +use super::nip30::Nip30Tag; +use super::util::{take_event_id, take_public_key, take_relay_url, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, RelayUrl, Url}; +use crate::{EventId, PublicKey, event, key}; + +const WORD: &str = "word"; +const PUBLIC_KEY: &str = "p"; +const HASHTAG: &str = "t"; +const EVENT: &str = "e"; +const RELAY: &str = "relay"; + +/// NIP-51 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Event error + Event(event::Error), + /// Key error + Key(key::Error), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Event(e) => e.fmt(f), + Self::Key(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Key(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-51 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/51.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip51Tag { + /// `p` tag + PublicKey(PublicKey), + /// `t` tag + Hashtag(String), + /// `e` tag + Event(EventId), + /// `relay` tag + Relay(RelayUrl), + /// `word` tag + Word(String), +} + +impl TagCodec for Nip51Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + PUBLIC_KEY => { + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + Ok(Self::PublicKey(public_key)) + } + HASHTAG => { + let hashtag: String = take_string(&mut iter, "hashtag")?; + Ok(Self::Hashtag(hashtag.to_lowercase())) + } + EVENT => { + let event_id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + Ok(Self::Event(event_id)) + } + RELAY => { + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(relay_url)) + } + WORD => Ok(Self::Word(take_string(&mut iter, "word")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::PublicKey(public_key) => { + Tag::new(vec![String::from(PUBLIC_KEY), public_key.to_hex()]) + } + Self::Hashtag(hashtag) => Tag::new(vec![String::from(HASHTAG), hashtag.to_lowercase()]), + Self::Event(event_id) => Tag::new(vec![String::from(EVENT), event_id.to_hex()]), + Self::Relay(relay_url) => Tag::new(vec![String::from(RELAY), relay_url.to_string()]), + Self::Word(word) => Tag::new(vec![String::from(WORD), word.clone()]), + } + } +} + +impl_tag_codec_conversions!(Nip51Tag); /// Things the user doesn't want to see in their feeds #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -36,15 +162,15 @@ impl From<MuteList> for Vec<Tag> { let mut tags = Vec::with_capacity(public_keys.len() + hashtags.len() + event_ids.len() + words.len()); - tags.extend(public_keys.into_iter().map(Tag::public_key)); - tags.extend(hashtags.into_iter().map(Tag::hashtag)); - tags.extend(event_ids.into_iter().map(Tag::event)); tags.extend( - words + public_keys .into_iter() - .map(TagStandard::Word) - .map(Tag::from_standardized), + .map(Nip51Tag::PublicKey) + .map(Into::into), ); + tags.extend(hashtags.into_iter().map(Nip51Tag::Hashtag).map(Into::into)); + tags.extend(event_ids.into_iter().map(Nip51Tag::Event).map(Into::into)); + tags.extend(words.into_iter().map(Nip51Tag::Word).map(Into::into)); tags } @@ -57,10 +183,6 @@ pub struct Bookmarks { pub event_ids: Vec<EventId>, /// Coordinates pub coordinate: Vec<Coordinate>, - /// Hashtags - pub hashtags: Vec<String>, - /// Urls - pub urls: Vec<Url>, } impl From<Bookmarks> for Vec<Tag> { @@ -68,21 +190,12 @@ impl From<Bookmarks> for Vec<Tag> { Bookmarks { event_ids, coordinate, - hashtags, - urls, }: Bookmarks, ) -> Self { - let mut tags = - Vec::with_capacity(event_ids.len() + coordinate.len() + hashtags.len() + urls.len()); + let mut tags = Vec::with_capacity(event_ids.len() + coordinate.len()); tags.extend(event_ids.into_iter().map(Tag::event)); tags.extend(coordinate.into_iter().map(Tag::from)); - tags.extend(hashtags.into_iter().map(Tag::hashtag)); - tags.extend( - urls.into_iter() - .map(TagStandard::Url) - .map(Tag::from_standardized), - ); tags } @@ -126,8 +239,13 @@ impl From<Emojis> for Vec<Tag> { fn from(Emojis { emojis, coordinate }: Emojis) -> Self { let mut tags = Vec::with_capacity(emojis.len() + coordinate.len()); - tags.extend(emojis.into_iter().map(|(s, url)| { - Tag::from_standardized_without_cell(TagStandard::Emoji { shortcode: s, url }) + tags.extend(emojis.into_iter().map(|(shortcode, image_url)| { + Nip30Tag::Emoji { + shortcode, + image_url, + emoji_set: None, + } + .to_tag() })); tags.extend(coordinate.into_iter().map(Tag::from)); @@ -159,3 +277,68 @@ impl From<ArticlesCuration> for Vec<Tag> { tags } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_public_key_tag() { + let public_key = + PublicKey::from_hex("04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9") + .unwrap(); + let tag = vec![ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", + ]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::PublicKey(public_key)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_hashtag_tag() { + let tag = vec!["t", "Nostr"]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::Hashtag(String::from("nostr"))); + assert_eq!(parsed.to_tag(), Tag::parse(["t", "nostr"]).unwrap()); + } + + #[test] + fn test_event_tag() { + let event_id = + EventId::from_hex("9ae37aa68f48645127299e9453eb5d908a0cbb6058ff340d528ed4d37c8994fb") + .unwrap(); + let tag = vec![ + "e", + "9ae37aa68f48645127299e9453eb5d908a0cbb6058ff340d528ed4d37c8994fb", + ]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::Event(event_id)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_word_tag() { + let tag = vec!["word", "spam"]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::Word(String::from("spam"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_relay_tag() { + let tag = vec!["relay", "wss://relay.damus.io"]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip51Tag::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip53.rs b/crates/nostr/src/nips/nip53.rs index dbecfe59f..ce2410344 100644 --- a/crates/nostr/src/nips/nip53.rs +++ b/crates/nostr/src/nips/nip53.rs @@ -7,35 +7,141 @@ //! <https://github.com/nostr-protocol/nips/blob/master/53.md> use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use core::str::FromStr; use secp256k1::schnorr::Signature; -use crate::types::{RelayUrl, Url}; -use crate::{ - Alphabet, ImageDimensions, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, Timestamp, +use super::nip01::{self, Coordinate}; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_from_str, take_and_parse_optional_relay_url, + take_coordinate, take_event_id, take_optional_string, take_public_key, take_string, + take_timestamp, }; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::key::{self, PublicKey}; +use crate::types::image; +use crate::types::url::{self, RelayUrl, Url}; +use crate::{Event, EventId, ImageDimensions, Kind, Timestamp, event}; + +const TITLE: &str = "title"; +const SUMMARY: &str = "summary"; +const IMAGE: &str = "image"; +const STREAMING: &str = "streaming"; +const RECORDING: &str = "recording"; +const STARTS: &str = "starts"; +const ENDS: &str = "ends"; +const STATUS: &str = "status"; +const CURRENT_PARTICIPANTS: &str = "current_participants"; +const TOTAL_PARTICIPANTS: &str = "total_participants"; +const RELAYS: &str = "relays"; +const ROOM: &str = "room"; +const SERVICE: &str = "service"; +const ENDPOINT: &str = "endpoint"; +const PINNED: &str = "pinned"; +const HAND: &str = "hand"; /// NIP53 Error -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq)] pub enum Error { - /// Unknown [`LiveEventMarker`] - UnknownLiveEventMarker(String), + /// Secp256k1 error + Secp256k1(secp256k1::Error), + /// Keys error + Keys(key::Error), + /// Event error + Event(event::Error), + /// Url error + Url(url::Error), + /// URL parse error + UrlParse(url::ParseError), + /// Image error + Image(image::Error), + /// NIP-01 error + NIP01(nip01::Error), + /// Parse int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), /// Description missing from event DescriptionMissing, } +impl core::error::Error for Error {} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::UnknownLiveEventMarker(u) => write!(f, "Unknown marker: {u}"), + Self::Secp256k1(e) => e.fmt(f), + Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::UrlParse(e) => e.fmt(f), + Self::Image(e) => e.fmt(f), + Self::NIP01(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::DescriptionMissing => f.write_str("Event missing a description"), } } } +impl From<secp256k1::Error> for Error { + fn from(e: secp256k1::Error) -> Self { + Self::Secp256k1(e) + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::UrlParse(e) + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::NIP01(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Live Event Marker #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum LiveEventMarker { @@ -45,6 +151,12 @@ pub enum LiveEventMarker { Speaker, /// Participant Participant, + /// Moderator + Moderator, + /// Owner + Owner, + /// Custom role label + Custom(String), } impl fmt::Display for LiveEventMarker { @@ -60,6 +172,9 @@ impl LiveEventMarker { Self::Host => "Host", Self::Speaker => "Speaker", Self::Participant => "Participant", + Self::Moderator => "Moderator", + Self::Owner => "Owner", + Self::Custom(value) => value.as_str(), } } } @@ -68,12 +183,14 @@ impl FromStr for LiveEventMarker { type Err = Error; fn from_str(s: &str) -> Result<Self, Self::Err> { - match s { - "Host" => Ok(Self::Host), - "Speaker" => Ok(Self::Speaker), - "Participant" => Ok(Self::Participant), - s => Err(Error::UnknownLiveEventMarker(s.to_string())), - } + Ok(match s { + "Host" => Self::Host, + "Speaker" => Self::Speaker, + "Participant" => Self::Participant, + "Moderator" => Self::Moderator, + "Owner" => Self::Owner, + other => Self::Custom(other.to_string()), + }) } } @@ -123,6 +240,285 @@ where } } +/// Live event participant tag +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LiveEventParticipant { + /// Participant public key + pub public_key: PublicKey, + /// Participant relay URL + pub relay_url: Option<RelayUrl>, + /// Participant role + pub marker: LiveEventMarker, + /// Optional proof + pub proof: Option<Signature>, +} + +/// Live event space reference +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LiveEventSpace { + /// Coordinate + pub coordinate: Coordinate, + /// Optional relay URL + pub relay_url: Option<RelayUrl>, + /// Optional marker + pub marker: Option<String>, +} + +/// NIP53 tags +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip53Tag { + /// Title + Title(String), + /// Summary + Summary(String), + /// Image + Image(Url, Option<ImageDimensions>), + /// Hashtag + Hashtag(String), + /// Streaming URL + Streaming(Url), + /// Recording URL + Recording(Url), + /// Start timestamp + Starts(Timestamp), + /// End timestamp + Ends(Timestamp), + /// Status + Status(LiveEventStatus), + /// Current participants count + CurrentParticipants(u64), + /// Total participants count + TotalParticipants(u64), + /// Preferred relays + Relays(Vec<RelayUrl>), + /// Participant role tag + Participant(LiveEventParticipant), + /// Room display name + Room(String), + /// Service URL + Service(Url), + /// Endpoint URL + Endpoint(Url), + /// Pinned event + Pinned(EventId), + /// Space reference + Space(LiveEventSpace), + /// Hand raised flag + Hand(bool), +} + +impl TagCodec for Nip53Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + SUMMARY => Ok(Self::Summary(take_string(&mut iter, "summary")?)), + IMAGE => { + let image: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "image URL")?; + let dimensions: Option<ImageDimensions> = + take_and_parse_optional_from_str(&mut iter)?; + Ok(Self::Image(image, dimensions)) + } + "t" => { + let hashtag: String = take_string(&mut iter, "hashtag")?; + if hashtag.chars().any(char::is_uppercase) { + return Err( + TagCodecError::Invalid("hashtag contains uppercase characters").into(), + ); + } + + Ok(Self::Hashtag(hashtag)) + } + STREAMING => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "streaming URL")?; + Ok(Self::Streaming(url)) + } + RECORDING => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "recording URL")?; + Ok(Self::Recording(url)) + } + STARTS => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::Starts(timestamp)) + } + ENDS => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::Ends(timestamp)) + } + STATUS => Ok(Self::Status(LiveEventStatus::from(take_string( + &mut iter, "status", + )?))), + CURRENT_PARTICIPANTS => { + let num: u64 = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "current participants")?; + Ok(Self::CurrentParticipants(num)) + } + TOTAL_PARTICIPANTS => { + let num: u64 = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "total participants")?; + Ok(Self::TotalParticipants(num)) + } + RELAYS => { + let mut relays: Vec<RelayUrl> = Vec::new(); + for relay in iter { + relays.push(RelayUrl::parse(relay.as_ref())?); + } + Ok(Self::Relays(relays)) + } + "p" => parse_p_tag(iter), + ROOM => Ok(Self::Room(take_string(&mut iter, "room")?)), + SERVICE => { + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "service URL")?; + Ok(Self::Service(url)) + } + ENDPOINT => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "endpoint URL")?; + Ok(Self::Endpoint(url)) + } + PINNED => { + let event_id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + Ok(Self::Pinned(event_id)) + } + "a" => parse_a_tag(iter), + HAND => parse_hand_tag(iter), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + Self::Summary(summary) => Tag::new(vec![String::from(SUMMARY), summary.clone()]), + Self::Image(image, dimensions) => { + let mut tag = vec![String::from(IMAGE), image.to_string()]; + if let Some(dimensions) = dimensions { + tag.push(dimensions.to_string()); + } + Tag::new(tag) + } + Self::Hashtag(hashtag) => Tag::new(vec![String::from("t"), hashtag.clone()]), + Self::Streaming(url) => Tag::new(vec![String::from(STREAMING), url.to_string()]), + Self::Recording(url) => Tag::new(vec![String::from(RECORDING), url.to_string()]), + Self::Starts(timestamp) => Tag::new(vec![String::from(STARTS), timestamp.to_string()]), + Self::Ends(timestamp) => Tag::new(vec![String::from(ENDS), timestamp.to_string()]), + Self::Status(status) => Tag::new(vec![String::from(STATUS), status.to_string()]), + Self::CurrentParticipants(count) => { + Tag::new(vec![String::from(CURRENT_PARTICIPANTS), count.to_string()]) + } + Self::TotalParticipants(count) => { + Tag::new(vec![String::from(TOTAL_PARTICIPANTS), count.to_string()]) + } + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + relays.len()); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(|relay| relay.to_string())); + Tag::new(tag) + } + Self::Participant(participant) => { + let mut tag = vec![ + String::from("p"), + participant.public_key.to_string(), + participant + .relay_url + .as_ref() + .map(|relay| relay.to_string()) + .unwrap_or_default(), + participant.marker.to_string(), + ]; + if let Some(proof) = participant.proof { + tag.push(proof.to_string()); + } + Tag::new(tag) + } + Self::Room(room) => Tag::new(vec![String::from(ROOM), room.clone()]), + Self::Service(service) => Tag::new(vec![String::from(SERVICE), service.to_string()]), + Self::Endpoint(endpoint) => { + Tag::new(vec![String::from(ENDPOINT), endpoint.to_string()]) + } + Self::Pinned(event_id) => Tag::new(vec![String::from(PINNED), event_id.to_hex()]), + Self::Space(space) => { + let mut tag = vec![String::from("a"), space.coordinate.to_string()]; + if let Some(relay_url) = &space.relay_url { + tag.push(relay_url.to_string()); + } + if let Some(marker) = &space.marker { + if space.relay_url.is_none() { + tag.push(String::new()); + } + tag.push(marker.clone()); + } + Tag::new(tag) + } + Self::Hand(hand) => Tag::new(vec![ + String::from(HAND), + if *hand { "1" } else { "0" }.to_string(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip53Tag); + +fn parse_p_tag<T, S>(mut iter: T) -> Result<Nip53Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_url: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let marker: LiveEventMarker = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "marker")?; + let proof: Option<Signature> = take_and_parse_optional_from_str(&mut iter)?; + + Ok(Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker, + proof, + })) +} + +fn parse_a_tag<T, S>(mut iter: T) -> Result<Nip53Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let coordinate = take_coordinate::<_, _, Error>(&mut iter)?; + let relay_url: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let marker: Option<String> = take_optional_string(&mut iter); + + Ok(Nip53Tag::Space(LiveEventSpace { + coordinate, + relay_url, + marker, + })) +} + +fn parse_hand_tag<T, S>(mut iter: T) -> Result<Nip53Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let hand: S = iter.next().ok_or(TagCodecError::Missing("hand"))?; + let hand: bool = match hand.as_ref() { + "1" => true, + "0" => false, + _ => return Err(TagCodecError::Unknown.into()), + }; + + Ok(Nip53Tag::Hand(hand)) +} + /// Live Event Host #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LiveEventHost { @@ -137,8 +533,14 @@ pub struct LiveEventHost { /// Live Event #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LiveEvent { - /// Unique event ID + /// Event kind + pub kind: Kind, + /// Unique event identifier (`d` tag) pub id: String, + /// Room display name + pub room: Option<String>, + /// Parent space reference + pub space: Option<LiveEventSpace>, /// Event title pub title: Option<String>, /// Event summary @@ -147,10 +549,14 @@ pub struct LiveEvent { pub image: Option<(Url, Option<ImageDimensions>)>, /// Hashtags pub hashtags: Vec<String>, - /// Steaming URL + /// Streaming URL pub streaming: Option<Url>, /// Recording URL pub recording: Option<Url>, + /// Service URL + pub service: Option<Url>, + /// Endpoint URL + pub endpoint: Option<Url>, /// Starts at pub starts: Option<Timestamp>, /// Ends at @@ -163,12 +569,20 @@ pub struct LiveEvent { pub total_participants: Option<u64>, /// Relays pub relays: Vec<RelayUrl>, + /// Pinned live chat messages + pub pinned: Vec<EventId>, /// Host pub host: Option<LiveEventHost>, + /// Owners + pub owners: Vec<(PublicKey, Option<RelayUrl>)>, + /// Moderators + pub moderators: Vec<(PublicKey, Option<RelayUrl>)>, /// Speakers pub speakers: Vec<(PublicKey, Option<RelayUrl>)>, /// Participants pub participants: Vec<(PublicKey, Option<RelayUrl>)>, + /// Hand raised flag for presence events + pub hand: Option<bool>, } impl LiveEvent { @@ -178,22 +592,103 @@ impl LiveEvent { S: Into<String>, { Self { + kind: Kind::LiveEvent, id: id.into(), + room: None, + space: None, title: None, summary: None, image: None, hashtags: Vec::new(), streaming: None, recording: None, + service: None, + endpoint: None, starts: None, ends: None, status: None, current_participants: None, total_participants: None, relays: Vec::new(), + pinned: Vec::new(), host: None, + owners: Vec::new(), + moderators: Vec::new(), speakers: Vec::new(), participants: Vec::new(), + hand: None, + } + } + + /// Parse live-activity data from an [`Event`]. + pub fn from_event(event: &Event) -> Result<Self, Error> { + let id = if event.kind == Kind::Custom(10312) { + String::new() + } else { + event.tags.identifier().ok_or(Error::DescriptionMissing)? + }; + + let mut live_event = Self::new(id); + live_event.kind = event.kind; + + for tag in event.tags.iter() { + let parsed = match Nip53Tag::try_from(tag) { + Ok(tag) => tag, + Err(Error::Codec(TagCodecError::Unknown)) => continue, + Err(err) => return Err(err), + }; + + live_event.apply_tag(parsed); + } + + Ok(live_event) + } + + fn apply_tag(&mut self, tag: Nip53Tag) { + match tag { + Nip53Tag::Title(title) => self.title = Some(title), + Nip53Tag::Summary(summary) => self.summary = Some(summary), + Nip53Tag::Image(image, dim) => self.image = Some((image, dim)), + Nip53Tag::Hashtag(hashtag) => self.hashtags.push(hashtag), + Nip53Tag::Streaming(url) => self.streaming = Some(url), + Nip53Tag::Recording(url) => self.recording = Some(url), + Nip53Tag::Starts(starts) => self.starts = Some(starts), + Nip53Tag::Ends(ends) => self.ends = Some(ends), + Nip53Tag::Status(status) => self.status = Some(status), + Nip53Tag::CurrentParticipants(count) => self.current_participants = Some(count), + Nip53Tag::TotalParticipants(count) => self.total_participants = Some(count), + Nip53Tag::Relays(mut relays) => self.relays.append(&mut relays), + Nip53Tag::Participant(participant) => match participant.marker { + LiveEventMarker::Host => { + self.host = Some(LiveEventHost { + public_key: participant.public_key, + relay_url: participant.relay_url, + proof: participant.proof, + }); + } + LiveEventMarker::Owner => { + self.owners + .push((participant.public_key, participant.relay_url)); + } + LiveEventMarker::Moderator => { + self.moderators + .push((participant.public_key, participant.relay_url)); + } + LiveEventMarker::Speaker => { + self.speakers + .push((participant.public_key, participant.relay_url)); + } + LiveEventMarker::Participant | LiveEventMarker::Custom(_) => { + self.participants + .push((participant.public_key, participant.relay_url)); + } + }, + Nip53Tag::Room(room) => self.room = Some(room), + Nip53Tag::Service(service) => self.service = Some(service), + Nip53Tag::Endpoint(endpoint) => self.endpoint = Some(endpoint), + Nip53Tag::Pinned(event_id) => self.pinned.push(event_id), + Nip53Tag::Space(space) => self.space = Some(space), + Nip53Tag::Hand(hand) => self.hand = Some(hand), } } } @@ -201,50 +696,105 @@ impl LiveEvent { impl From<LiveEvent> for Vec<Tag> { fn from(live_event: LiveEvent) -> Self { let LiveEvent { + kind: _, id, + room, + space, title, summary, image, hashtags, streaming, recording, + service, + endpoint, starts, ends, status, current_participants, total_participants, relays, + pinned, host, + owners, + moderators, speakers, participants, + hand, } = live_event; let mut tags = Vec::with_capacity(1); - tags.push(Tag::identifier(id)); + if !id.is_empty() { + tags.push(Tag::identifier(id)); + } + + if let Some(room) = room { + tags.push(Nip53Tag::Room(room).to_tag()); + } + + if let Some(space) = space { + tags.push(Nip53Tag::Space(space).to_tag()); + } if let Some(title) = title { - tags.push(Tag::from_standardized_without_cell(TagStandard::Title( - title, - ))); + tags.push(Nip53Tag::Title(title).to_tag()); } if let Some(summary) = summary { - tags.push(Tag::from_standardized_without_cell(TagStandard::Summary( - summary, - ))); + tags.push(Nip53Tag::Summary(summary).to_tag()); + } + + if let Some((image, dim)) = image { + tags.push(Nip53Tag::Image(image, dim).to_tag()); + } + + for hashtag in hashtags.into_iter() { + tags.push(Nip53Tag::Hashtag(hashtag).to_tag()); } if let Some(streaming) = streaming { - tags.push(Tag::from_standardized_without_cell(TagStandard::Streaming( - streaming, - ))); + tags.push(Nip53Tag::Streaming(streaming).to_tag()); + } + + if let Some(recording) = recording { + tags.push(Nip53Tag::Recording(recording).to_tag()); + } + + if let Some(service) = service { + tags.push(Nip53Tag::Service(service).to_tag()); + } + + if let Some(endpoint) = endpoint { + tags.push(Nip53Tag::Endpoint(endpoint).to_tag()); + } + + if let Some(starts) = starts { + tags.push(Nip53Tag::Starts(starts).to_tag()); + } + + if let Some(ends) = ends { + tags.push(Nip53Tag::Ends(ends).to_tag()); } if let Some(status) = status { - tags.push(Tag::from_standardized_without_cell( - TagStandard::LiveEventStatus(status), - )); + tags.push(Nip53Tag::Status(status).to_tag()); + } + + if let Some(current_participants) = current_participants { + tags.push(Nip53Tag::CurrentParticipants(current_participants).to_tag()); + } + + if let Some(total_participants) = total_participants { + tags.push(Nip53Tag::TotalParticipants(total_participants).to_tag()); + } + + if !relays.is_empty() { + tags.push(Nip53Tag::Relays(relays).to_tag()); + } + + for event_id in pinned.into_iter() { + tags.push(Nip53Tag::Pinned(event_id).to_tag()); } if let Some(LiveEventHost { @@ -253,82 +803,67 @@ impl From<LiveEvent> for Vec<Tag> { proof, }) = host { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKeyLiveEvent { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { public_key, relay_url, marker: LiveEventMarker::Host, proof, - }, - )); + }) + .to_tag(), + ); } - for (public_key, relay_url) in speakers.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKeyLiveEvent { + for (public_key, relay_url) in owners.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { public_key, relay_url, - marker: LiveEventMarker::Speaker, + marker: LiveEventMarker::Owner, proof: None, - }, - )); + }) + .to_tag(), + ); } - for (public_key, relay_url) in participants.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKeyLiveEvent { + for (public_key, relay_url) in moderators.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { public_key, relay_url, - marker: LiveEventMarker::Participant, + marker: LiveEventMarker::Moderator, proof: None, - }, - )); + }) + .to_tag(), + ); } - if let Some((image, dim)) = image { - tags.push(Tag::from_standardized_without_cell(TagStandard::Image( - image, dim, - ))); - } - - for hashtag in hashtags.into_iter() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Hashtag( - hashtag, - ))); - } - - if let Some(recording) = recording { - tags.push(Tag::from_standardized_without_cell(TagStandard::Recording( - recording, - ))); - } - - if let Some(starts) = starts { - tags.push(Tag::from_standardized_without_cell(TagStandard::Starts( - starts, - ))); - } - - if let Some(ends) = ends { - tags.push(Tag::from_standardized_without_cell(TagStandard::Ends(ends))); - } - - if let Some(current_participants) = current_participants { - tags.push(Tag::from_standardized_without_cell( - TagStandard::CurrentParticipants(current_participants), - )); + for (public_key, relay_url) in speakers.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker: LiveEventMarker::Speaker, + proof: None, + }) + .to_tag(), + ); } - if let Some(total_participants) = total_participants { - tags.push(Tag::from_standardized_without_cell( - TagStandard::TotalParticipants(total_participants), - )); + for (public_key, relay_url) in participants.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker: LiveEventMarker::Participant, + proof: None, + }) + .to_tag(), + ); } - if !relays.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Relays( - relays, - ))); + if let Some(hand) = hand { + tags.push(Nip53Tag::Hand(hand).to_tag()); } tags @@ -339,55 +874,176 @@ impl TryFrom<Vec<Tag>> for LiveEvent { type Error = Error; fn try_from(tags: Vec<Tag>) -> Result<Self, Self::Error> { - // Extract content of `d` tag - let id: &str = tags + let id: String = tags .iter() - .find(|t| t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D))) + .find(|t| t.kind() == "d") .and_then(|t| t.content()) + .map(|value| value.to_string()) .ok_or(Error::DescriptionMissing)?; let mut live_event = LiveEvent::new(id); for tag in tags.into_iter() { - let Some(tag) = tag.to_standardized() else { - continue; + let parsed = match Nip53Tag::try_from(tag) { + Ok(tag) => tag, + Err(Error::Codec(TagCodecError::Unknown)) => continue, + Err(err) => return Err(err), }; - match tag { - TagStandard::Title(title) => live_event.title = Some(title), - TagStandard::Summary(summary) => live_event.summary = Some(summary), - TagStandard::Streaming(url) => live_event.streaming = Some(url), - TagStandard::LiveEventStatus(status) => live_event.status = Some(status), - TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker, - proof, - } => match marker { - LiveEventMarker::Host => { - live_event.host = Some(LiveEventHost { - public_key, - relay_url, - proof, - }) - } - LiveEventMarker::Speaker => live_event.speakers.push((public_key, relay_url)), - LiveEventMarker::Participant => { - live_event.participants.push((public_key, relay_url)) - } - }, - TagStandard::Image(image, dim) => live_event.image = Some((image, dim)), - TagStandard::Hashtag(hashtag) => live_event.hashtags.push(hashtag), - TagStandard::Recording(url) => live_event.recording = Some(url), - TagStandard::Starts(starts) => live_event.starts = Some(starts), - TagStandard::Ends(ends) => live_event.ends = Some(ends), - TagStandard::CurrentParticipants(n) => live_event.current_participants = Some(n), - TagStandard::TotalParticipants(n) => live_event.total_participants = Some(n), - TagStandard::Relays(mut relays) => live_event.relays.append(&mut relays), - _ => {} - } + live_event.apply_tag(parsed); } Ok(live_event) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::JsonUtil; + + #[test] + fn test_live_event_marker() { + assert_eq!( + LiveEventMarker::from_str("Host").unwrap(), + LiveEventMarker::Host + ); + assert_eq!( + LiveEventMarker::from_str("Moderator").unwrap(), + LiveEventMarker::Moderator + ); + assert_eq!( + LiveEventMarker::from_str("Owner").unwrap(), + LiveEventMarker::Owner + ); + assert_eq!( + LiveEventMarker::from_str("Invited").unwrap(), + LiveEventMarker::Custom(String::from("Invited")) + ); + } + + #[test] + fn test_standardized_nip53_tags() { + let service = Nip53Tag::parse(["service", "https://meet.example.com/room"]).unwrap(); + assert_eq!( + service, + Nip53Tag::Service(Url::parse("https://meet.example.com/room").unwrap()) + ); + + let pinned_id = + EventId::from_hex("97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188") + .unwrap(); + let pinned = Nip53Tag::parse([ + "pinned", + "97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188", + ]) + .unwrap(); + assert_eq!(pinned, Nip53Tag::Pinned(pinned_id)); + + let space = Nip53Tag::parse([ + "a", + "30312:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:main-conference-room", + "wss://nostr.example.com", + "root", + ]) + .unwrap(); + assert_eq!( + space, + Nip53Tag::Space(LiveEventSpace { + coordinate: Coordinate::new( + Kind::Custom(30312), + PublicKey::from_hex( + "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca", + ) + .unwrap(), + ) + .identifier("main-conference-room"), + relay_url: Some(RelayUrl::parse("wss://nostr.example.com").unwrap()), + marker: Some(String::from("root")), + }) + ); + + let hand = Nip53Tag::parse(["hand", "1"]).unwrap(); + assert_eq!(hand, Nip53Tag::Hand(true)); + } + + #[test] + fn test_live_event_from_event() { + let event = Event::from_json( + r#"{ + "content": "", + "created_at": 1687286726, + "id": "97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188", + "kind": 30313, + "pubkey": "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", + "sig": "997f62ddfc0827c121043074d50cfce7a528e978c575722748629a4137c45b75bdbc84170bedc723ef0a5a4c3daebf1fef2e93f5e2ddb98e5d685d022c30b622", + "tags": [ + ["d", "annual-meeting-2025"], + ["a", "30312:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:main-conference-room", "wss://nostr.example.com"], + ["title", "Annual Company Meeting 2025"], + ["summary", "Yearly company-wide meeting"], + ["image", "https://example.com/meeting.jpg"], + ["starts", "1676262123"], + ["ends", "1676269323"], + ["status", "live"], + ["total_participants", "180"], + ["current_participants", "175"], + ["p", "91cf94e5ca91cf94e5ca91cf94e5ca91cf94e5ca91cf94e5ca91cf94e5ca91cf", "wss://provider1.com/", "Speaker"] + ] +}"#, + ) + .unwrap(); + + let live_event = LiveEvent::from_event(&event).unwrap(); + assert_eq!(live_event.kind, Kind::Custom(30313)); + assert_eq!(live_event.id, "annual-meeting-2025"); + assert_eq!( + live_event.space, + Some(LiveEventSpace { + coordinate: Coordinate::new( + Kind::Custom(30312), + PublicKey::from_hex( + "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca", + ) + .unwrap(), + ) + .identifier("main-conference-room"), + relay_url: Some(RelayUrl::parse("wss://nostr.example.com").unwrap()), + marker: None, + }) + ); + assert_eq!( + live_event.title, + Some(String::from("Annual Company Meeting 2025")) + ); + assert_eq!(live_event.status, Some(LiveEventStatus::Live)); + assert_eq!(live_event.total_participants, Some(180)); + assert_eq!(live_event.current_participants, Some(175)); + assert_eq!(live_event.speakers.len(), 1); + } + + #[test] + fn test_room_presence_from_event() { + let event = Event::from_json( + r#"{ + "content": "", + "created_at": 1687286726, + "id": "97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188", + "kind": 10312, + "pubkey": "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", + "sig": "997f62ddfc0827c121043074d50cfce7a528e978c575722748629a4137c45b75bdbc84170bedc723ef0a5a4c3daebf1fef2e93f5e2ddb98e5d685d022c30b622", + "tags": [ + ["a", "30312:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:main-conference-room", "wss://nostr.example.com", "root"], + ["hand", "1"] + ] +}"#, + ) + .unwrap(); + + let presence = LiveEvent::from_event(&event).unwrap(); + assert_eq!(presence.kind, Kind::Custom(10312)); + assert!(presence.id.is_empty()); + assert_eq!(presence.hand, Some(true)); + assert_eq!(presence.space.unwrap().marker, Some(String::from("root"))); + } +} diff --git a/crates/nostr/src/nips/nip56.rs b/crates/nostr/src/nips/nip56.rs index 44ca741ec..2d1ad6de0 100644 --- a/crates/nostr/src/nips/nip56.rs +++ b/crates/nostr/src/nips/nip56.rs @@ -2,16 +2,28 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP56: Reporting +//! NIP-56: Reporting //! //! <https://github.com/nostr-protocol/nips/blob/master/56.md> +use alloc::string::{String, ToString}; +use alloc::vec; use core::fmt; use core::str::FromStr; +use super::util::{take_and_parse_from_str, take_event_id, take_public_key}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventId, PublicKey, event, key}; + /// NIP56 error -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq)] pub enum Error { + /// Keys error + Keys(key::Error), + /// Event error + Event(event::Error), + /// Codec error + Codec(TagCodecError), /// Unknown [`Report`] UnknownReportType, } @@ -21,11 +33,32 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::UnknownReportType => f.write_str("Unknown report type"), } } } +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Report /// /// <https://github.com/nostr-protocol/nips/blob/master/56.md> @@ -84,3 +117,183 @@ impl FromStr for Report { } } } + +/// Standardized NIP-56 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/56.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip56Tag { + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Report + report: Report, + }, + /// `p` tag + PublicKey { + /// Public key + public_key: PublicKey, + /// Report + report: Report, + }, +} + +impl TagCodec for Nip56Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "e" => { + let (id, report) = parse_e_tag(iter)?; + Ok(Self::Event { id, report }) + } + "p" => { + let (public_key, report) = parse_p_tag(iter)?; + Ok(Self::PublicKey { public_key, report }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Event { id, report } => { + Tag::new(vec![String::from("e"), id.to_hex(), report.to_string()]) + } + Self::PublicKey { public_key, report } => Tag::new(vec![ + String::from("p"), + public_key.to_hex(), + report.to_string(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip56Tag); + +fn parse_e_tag<T, S>(mut iter: T) -> Result<(EventId, Report), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let report: Report = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "report")?; + + Ok((id, report)) +} + +fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Report), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let report: Report = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "report")?; + + Ok((public_key, report)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_report_e_tag() { + let tag = vec![ + "e", + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", + "nudity", + ]; + let parsed = Nip56Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip56Tag::Event { + id: EventId::from_hex( + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" + ) + .unwrap(), + report: Report::Nudity, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_report_p_tag() { + let tag = vec![ + "p", + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", + "impersonation", + ]; + let parsed = Nip56Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip56Tag::PublicKey { + public_key: PublicKey::from_hex( + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" + ) + .unwrap(), + report: Report::Impersonation, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_missing_report() { + let tag = vec![ + "p", + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::Codec(TagCodecError::Missing("report")) + )); + + let tag = vec![ + "e", + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::Codec(TagCodecError::Missing("report")) + )); + } + + #[test] + fn test_empty_report() { + let tag = vec![ + "p", + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", + "", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::UnknownReportType + )); + + let tag = vec![ + "e", + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", + "", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::UnknownReportType + )); + } +} diff --git a/crates/nostr/src/nips/nip57.rs b/crates/nostr/src/nips/nip57.rs index 3bff32f63..a08d83bf0 100644 --- a/crates/nostr/src/nips/nip57.rs +++ b/crates/nostr/src/nips/nip57.rs @@ -2,13 +2,15 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP57: Lightning Zaps +//! NIP-57: Lightning Zaps //! //! <https://github.com/nostr-protocol/nips/blob/master/57.md> use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use aes::Aes256; #[cfg(feature = "rand")] @@ -33,14 +35,17 @@ use rand::{CryptoRng, RngCore}; use secp256k1::{Secp256k1, Signing, Verification}; use super::nip01::Coordinate; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_from_str, take_optional_string, + take_public_key, take_relay_url, take_string, +}; #[cfg(all(feature = "std", feature = "os-rng"))] use crate::SECP256K1; use crate::event::builder::Error as BuilderError; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; use crate::key::Error as KeyError; -use crate::{ - Event, EventId, JsonUtil, PublicKey, RelayUrl, SecretKey, Tag, TagStandard, Timestamp, event, - util, -}; +use crate::types::url; +use crate::{Event, EventId, JsonUtil, PublicKey, RelayUrl, SecretKey, Timestamp, event, util}; #[cfg(feature = "rand")] use crate::{EventBuilder, Keys, Kind}; @@ -50,6 +55,15 @@ type Aes256CbcDec = Decryptor<Aes256>; const PRIVATE_ZAP_MSG_BECH32_PREFIX: Hrp = Hrp::parse_unchecked("pzap"); const PRIVATE_ZAP_IV_BECH32_PREFIX: Hrp = Hrp::parse_unchecked("iv"); +const ANON: &str = "anon"; +const AMOUNT: &str = "amount"; +const BOLT11: &str = "bolt11"; +const DESCRIPTION: &str = "description"; +const LNURL: &str = "lnurl"; +const PREIMAGE: &str = "preimage"; +const RELAYS: &str = "relays"; +const SENDER: &str = "P"; +const ZAP: &str = "zap"; #[allow(missing_docs)] #[derive(Debug)] @@ -57,8 +71,12 @@ pub enum Error { Key(KeyError), Builder(BuilderError), Event(event::Error), + Url(url::Error), + ParseInt(ParseIntError), Bech32Decode(bech32::DecodeError), Bech32Encode(bech32::EncodeError), + /// Codec error + Codec(TagCodecError), InvalidPrivateZapMessage, PrivateZapMessageNotFound, /// Wrong prefix or variant @@ -75,8 +93,11 @@ impl fmt::Display for Error { Self::Key(e) => e.fmt(f), Self::Builder(e) => e.fmt(f), Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), Self::Bech32Decode(e) => e.fmt(f), Self::Bech32Encode(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidPrivateZapMessage => f.write_str("Invalid private zap message"), Self::PrivateZapMessageNotFound => f.write_str("Private zap message not found"), Self::WrongBech32Prefix => f.write_str("Wrong bech32 prefix"), @@ -105,6 +126,18 @@ impl From<event::Error> for Error { } } +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + impl From<bech32::DecodeError> for Error { fn from(e: bech32::DecodeError) -> Self { Self::Bech32Decode(e) @@ -117,6 +150,181 @@ impl From<bech32::EncodeError> for Error { } } +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-57 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/57.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip57Tag { + /// `relays` tag + Relays(Vec<RelayUrl>), + /// `amount` tag + Amount { + /// Amount in millisats + millisats: u64, + /// Optional bolt11 invoice + bolt11: Option<String>, + }, + /// `lnurl` tag + Lnurl(String), + /// `anon` tag + Anon { + /// Optional private zap payload + msg: Option<String>, + }, + /// `bolt11` tag + Bolt11(String), + /// `description` tag + Description(String), + /// `preimage` tag + Preimage(String), + /// `P` tag + Sender(PublicKey), + /// `zap` tag + Zap { + /// Receiver public key + public_key: PublicKey, + /// Relay used to fetch the receiver metadata + relay_url: RelayUrl, + /// Optional split weight + weight: Option<u64>, + }, +} + +impl TagCodec for Nip57Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + RELAYS => Ok(Self::Relays(parse_relays(iter)?)), + AMOUNT => { + let (millisats, bolt11) = parse_amount_tag(iter)?; + Ok(Self::Amount { millisats, bolt11 }) + } + LNURL => Ok(Self::Lnurl(take_string(&mut iter, "LNURL")?)), + ANON => Ok(Self::Anon { + msg: take_optional_string(&mut iter), + }), + BOLT11 => Ok(Self::Bolt11(take_string(&mut iter, "BOLT11")?)), + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + PREIMAGE => Ok(Self::Preimage(take_string(&mut iter, "preimage")?)), + SENDER => { + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + Ok(Self::Sender(public_key)) + } + ZAP => { + let (public_key, relay_url, weight) = parse_zap_tag(iter)?; + Ok(Self::Zap { + public_key, + relay_url, + weight, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(relays.len() + 1); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Amount { millisats, bolt11 } => { + let mut tag: Vec<String> = vec![String::from(AMOUNT), millisats.to_string()]; + if let Some(bolt11) = bolt11 { + tag.push(bolt11.clone()); + } + Tag::new(tag) + } + Self::Lnurl(lnurl) => Tag::new(vec![String::from(LNURL), lnurl.clone()]), + Self::Anon { msg } => { + let mut tag: Vec<String> = vec![String::from(ANON)]; + if let Some(msg) = msg { + tag.push(msg.clone()); + } + Tag::new(tag) + } + Self::Bolt11(bolt11) => Tag::new(vec![String::from(BOLT11), bolt11.clone()]), + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::Preimage(preimage) => Tag::new(vec![String::from(PREIMAGE), preimage.clone()]), + Self::Sender(public_key) => Tag::new(vec![String::from(SENDER), public_key.to_hex()]), + Self::Zap { + public_key, + relay_url, + weight, + } => { + let mut tag: Vec<String> = vec![ + String::from(ZAP), + public_key.to_hex(), + relay_url.to_string(), + ]; + + if let Some(weight) = weight { + tag.push(weight.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip57Tag); + +fn parse_relays<T, S>(iter: T) -> Result<Vec<RelayUrl>, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let mut relays: Vec<RelayUrl> = Vec::new(); + + for relay in iter { + relays.push(RelayUrl::parse(relay.as_ref())?); + } + + Ok(relays) +} + +fn parse_amount_tag<T, S>(mut iter: T) -> Result<(u64, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let millisats: u64 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "amount")?; + let bolt11: Option<String> = iter.next().map(|bolt11| bolt11.as_ref().to_string()); + + Ok((millisats, bolt11)) +} + +fn parse_zap_tag<T, S>(mut iter: T) -> Result<(PublicKey, RelayUrl, Option<u64>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + let weight: Option<u64> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((public_key, relay_url, weight)) +} + /// Zap Type #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ZapType { @@ -226,9 +434,7 @@ impl From<ZapRequestData> for Vec<Tag> { let mut tags: Vec<Tag> = vec![Tag::public_key(public_key)]; if !relays.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Relays( - relays, - ))); + tags.push(Nip57Tag::Relays(relays).into()); } if let Some(event_id) = event_id { @@ -240,16 +446,17 @@ impl From<ZapRequestData> for Vec<Tag> { } if let Some(amount) = amount { - tags.push(Tag::from_standardized_without_cell(TagStandard::Amount { - millisats: amount, - bolt11: None, - })); + tags.push( + Nip57Tag::Amount { + millisats: amount, + bolt11: None, + } + .into(), + ); } if let Some(lnurl) = lnurl { - tags.push(Tag::from_standardized_without_cell(TagStandard::Lnurl( - lnurl, - ))); + tags.push(Nip57Tag::Lnurl(lnurl).into()); } tags @@ -262,9 +469,7 @@ pub fn anonymous_zap_request(data: ZapRequestData) -> Result<Event, Error> { let keys = Keys::generate(); let message: String = data.message.clone(); let mut tags: Vec<Tag> = data.into(); - tags.push(Tag::from_standardized_without_cell(TagStandard::Anon { - msg: None, - })); + tags.push(Nip57Tag::Anon { msg: None }.into()); Ok(EventBuilder::new(Kind::ZapRequest, message) .tags(tags) .sign_with_keys(&keys)?) @@ -308,9 +513,7 @@ where // Compose event let mut tags: Vec<Tag> = data.into(); - tags.push(Tag::from_standardized_without_cell(TagStandard::Anon { - msg: Some(msg), - })); + tags.push(Nip57Tag::Anon { msg: Some(msg) }.into()); let private_zap_keys: Keys = Keys::new_with_ctx(secp, secret_key); Ok(EventBuilder::new(Kind::ZapRequest, "") .tags(tags) @@ -360,10 +563,10 @@ where Ok(format!("{encrypted_bech32_msg}_{iv_bech32}")) } -fn extract_anon_tag_message(event: &Event) -> Result<&String, Error> { +fn extract_anon_tag_message(event: &Event) -> Result<String, Error> { for tag in event.tags.iter() { - if let Some(TagStandard::Anon { msg }) = tag.as_standardized() { - return msg.as_ref().ok_or(Error::InvalidPrivateZapMessage); + if let Ok(Nip57Tag::Anon { msg }) = Nip57Tag::try_from(tag) { + return msg.ok_or(Error::InvalidPrivateZapMessage); } } Err(Error::PrivateZapMessageNotFound) @@ -395,7 +598,7 @@ pub fn decrypt_received_private_zap_message( } fn decrypt_private_zap_message(key: [u8; 32], private_zap_event: &Event) -> Result<Event, Error> { - let msg: &String = extract_anon_tag_message(private_zap_event)?; + let msg: String = extract_anon_tag_message(private_zap_event)?; let mut splitted = msg.split('_'); let msg: &str = splitted.next().ok_or(Error::InvalidPrivateZapMessage)?; @@ -428,6 +631,115 @@ fn decrypt_private_zap_message(key: [u8; 32], private_zap_event: &Event) -> Resu mod tests { use super::*; + #[test] + fn test_relays_tag() { + let tag = vec!["relays", "wss://relay.damus.io", "wss://relay.primal.net"]; + let parsed = Nip57Tag::parse(tag).unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Relays(vec![ + RelayUrl::parse("wss://relay.damus.io").unwrap(), + RelayUrl::parse("wss://relay.primal.net").unwrap(), + ]) + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["relays", "wss://relay.damus.io", "wss://relay.primal.net"]).unwrap() + ); + } + + #[test] + fn test_amount_tag() { + let tag = vec!["amount", "21000", "lnbc21u1p0test"]; + let parsed = Nip57Tag::parse(tag).unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Amount { + millisats: 21000, + bolt11: Some(String::from("lnbc21u1p0test")), + } + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["amount", "21000", "lnbc21u1p0test"]).unwrap() + ); + } + + #[test] + fn test_anon_tag() { + let tag = vec!["anon", "encrypted-message"]; + let parsed = Nip57Tag::parse(tag).unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Anon { + msg: Some(String::from("encrypted-message")), + } + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["anon", "encrypted-message"]).unwrap() + ); + } + + #[test] + fn test_sender_tag() { + let public_key = + PublicKey::from_hex("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322") + .unwrap(); + let parsed = Nip57Tag::parse([ + "P", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", + ]) + .unwrap(); + + assert_eq!(parsed, Nip57Tag::Sender(public_key)); + assert_eq!( + parsed.to_tag(), + Tag::parse([ + "P", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", + ]) + .unwrap() + ); + } + + #[test] + fn test_zap_tag() { + let public_key = + PublicKey::from_hex("82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2") + .unwrap(); + let relay_url = RelayUrl::parse("wss://nostr.oxtr.dev").unwrap(); + let parsed = Nip57Tag::parse([ + "zap", + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + "wss://nostr.oxtr.dev", + "2", + ]) + .unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Zap { + public_key, + relay_url, + weight: Some(2), + } + ); + assert_eq!( + parsed.to_tag(), + Tag::parse([ + "zap", + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + "wss://nostr.oxtr.dev", + "2", + ]) + .unwrap() + ); + } + #[test] fn test_encrypt_decrypt_private_zap_message() { let alice_keys = Keys::generate(); diff --git a/crates/nostr/src/nips/nip58.rs b/crates/nostr/src/nips/nip58.rs index fbaf0fd76..5ac94b226 100644 --- a/crates/nostr/src/nips/nip58.rs +++ b/crates/nostr/src/nips/nip58.rs @@ -2,19 +2,37 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP58: Badges +//! NIP-58: Badges //! //! <https://github.com/nostr-protocol/nips/blob/master/58.md> +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; -use crate::types::RelayUrl; -use crate::{Event, Kind, PublicKey, Tag, TagStandard}; +use super::nip01::Nip01Tag; +use super::util::{take_and_parse_from_str, take_and_parse_optional_from_str, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; +use crate::types::{RelayUrl, image}; +use crate::{Event, ImageDimensions, Kind, PublicKey}; + +const IDENTIFIER: &str = "d"; +const NAME: &str = "name"; +const DESCRIPTION: &str = "description"; +const IMAGE: &str = "image"; +const THUMB: &str = "thumb"; #[derive(Debug, PartialEq, Eq)] /// Badge Award error pub enum Error { + /// Image error + Image(image::Error), + /// Url error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), /// Invalid length InvalidLength, /// Invalid kind @@ -32,15 +50,127 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Image(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidLength => f.write_str("invalid length"), Self::InvalidKind => f.write_str("invalid kind"), Self::IdentifierTagNotFound => f.write_str("identifier tag not found"), - Self::MismatchedBadgeDefinitionOrAward => f.write_str("mismatched badge definition/award"), - Self::BadgeAwardsLackAwardedPublicKey => f.write_str("badge award events lack the awarded public keybadge award events lack the awarded public key"), + Self::MismatchedBadgeDefinitionOrAward => { + f.write_str("mismatched badge definition/award") + } + Self::BadgeAwardsLackAwardedPublicKey => { + f.write_str("badge award events lack the awarded public key") + } + } + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-58 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/58.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip58Tag { + /// `d` tag + Identifier(String), + /// `name` tag + Name(String), + /// `description` tag + Description(String), + /// `image` tag + Image(Url, Option<ImageDimensions>), + /// `thumb` tag + Thumb(Url, Option<ImageDimensions>), +} + +impl TagCodec for Nip58Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + IDENTIFIER => Ok(Self::Identifier(take_string(&mut iter, "identifier")?)), + NAME => Ok(Self::Name(take_string(&mut iter, "name")?)), + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + IMAGE => { + let (url, dimensions) = parse_url_and_dimensions_tag(iter, "image URL")?; + Ok(Self::Image(url, dimensions)) + } + THUMB => { + let (url, dimensions) = parse_url_and_dimensions_tag(iter, "thumbnail URL")?; + Ok(Self::Thumb(url, dimensions)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Identifier(identifier) => { + Tag::new(vec![String::from(IDENTIFIER), identifier.clone()]) + } + Self::Name(name) => Tag::new(vec![String::from(NAME), name.clone()]), + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::Image(url, dimensions) => to_url_and_dimensions_tag(IMAGE, url, dimensions), + Self::Thumb(url, dimensions) => to_url_and_dimensions_tag(THUMB, url, dimensions), } } } +impl_tag_codec_conversions!(Nip58Tag); + +fn parse_url_and_dimensions_tag<T, S>( + mut iter: T, + missing_error: &'static str, +) -> Result<(Url, Option<ImageDimensions>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, missing_error)?; + let dimensions: Option<ImageDimensions> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((url, dimensions)) +} + +fn to_url_and_dimensions_tag(kind: &str, url: &Url, dimensions: &Option<ImageDimensions>) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + dimensions.is_some() as usize); + tag.push(String::from(kind)); + tag.push(url.to_string()); + + if let Some(dimensions) = dimensions { + tag.push(dimensions.to_string()); + } + + Tag::new(tag) +} + /// Helper function to filter events for a specific [`Kind`] #[inline] pub(crate) fn filter_for_kind(events: Vec<Event>, kind_needed: &Kind) -> Vec<Event> { @@ -51,16 +181,83 @@ pub(crate) fn filter_for_kind(events: Vec<Event>, kind_needed: &Kind) -> Vec<Eve } /// Helper function to extract the awarded public key from an array of PubKey tags -pub(crate) fn extract_awarded_public_key<'a>( - tags: &'a [Tag], - awarded_public_key: &PublicKey, -) -> Option<(&'a PublicKey, &'a Option<RelayUrl>)> { - tags.iter().find_map(|t| match t.as_standardized() { - Some(TagStandard::PublicKey { +pub(crate) fn extract_awarded_public_key( + tags: &[Tag], + awarded_public_key: PublicKey, +) -> Option<(PublicKey, Option<RelayUrl>)> { + tags.iter().find_map(|t| match Nip01Tag::try_from(t) { + Ok(Nip01Tag::PublicKey { public_key, - relay_url, - .. - }) if public_key == awarded_public_key => Some((public_key, relay_url)), + relay_hint, + }) if public_key == awarded_public_key => Some((public_key, relay_hint)), _ => None, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identifier_tag() { + let tag = vec!["d", "bravery"]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip58Tag::Identifier(String::from("bravery"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_name_tag() { + let tag = vec!["name", "Medal of Bravery"]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip58Tag::Name(String::from("Medal of Bravery"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_description_tag() { + let tag = vec!["description", "Awarded to users demonstrating bravery"]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip58Tag::Description(String::from("Awarded to users demonstrating bravery")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_image_tag() { + let tag = vec![ + "image", + "https://nostr.academy/awards/bravery.png", + "1024x1024", + ]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip58Tag::Image( + Url::parse("https://nostr.academy/awards/bravery.png").unwrap(), + Some(ImageDimensions::new(1024, 1024)) + ) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_thumb_tag() { + let tag = vec![ + "thumb", + "https://nostr.academy/awards/bravery_256x256.png", + "256x256", + ]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip58Tag::Thumb( + Url::parse("https://nostr.academy/awards/bravery_256x256.png").unwrap(), + Some(ImageDimensions::new(256, 256)) + ) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip60.rs b/crates/nostr/src/nips/nip60.rs index 4f5155c1a..be2613b22 100644 --- a/crates/nostr/src/nips/nip60.rs +++ b/crates/nostr/src/nips/nip60.rs @@ -10,7 +10,7 @@ use core::str::FromStr; use serde::{Deserialize, Serialize}; use super::nip44; -use crate::event::{self, Event, EventId, TagKind, tag}; +use crate::event::{self, Event, EventId, tag}; #[cfg(all(feature = "std", feature = "os-rng"))] use crate::event::{EventBuilder, Kind, Tag}; use crate::key::{PublicKey, SecretKey}; @@ -449,7 +449,7 @@ impl SpendingHistory { // Add created event references (encrypted) for event_id in &self.created { let tag: Tag = Tag::custom( - TagKind::e(), + "e", [ event_id.to_hex(), String::new(), @@ -462,7 +462,7 @@ impl SpendingHistory { // Add destroyed event references (encrypted) for event_id in &self.destroyed { let tag: Tag = Tag::custom( - TagKind::e(), + "e", [ event_id.to_hex(), String::new(), @@ -542,14 +542,15 @@ impl QuoteEvent { // Extract mint URL from tags let mint: Url = event .tags - .find(TagKind::custom(MINT)) + .iter() + .find(|t| t.kind() == MINT) .and_then(|tag| tag.content()) .ok_or(Error::MissingMintTag)? .parse() .map_err(|_| Error::InvalidMintUrl)?; // Extract NIP-40 expiration from tags if present - let expiration: Option<Timestamp> = event.tags.expiration().copied(); + let expiration: Option<Timestamp> = event.tags.expiration(); Ok(Self { quote_id, @@ -590,7 +591,7 @@ impl QuoteEvent { let mut tags: Vec<Tag> = Vec::with_capacity(2); // Add mint tag - tags.push(Tag::custom(TagKind::custom(MINT), [self.mint.as_str()])); + tags.push(Tag::custom(MINT, [self.mint.as_str()])); // Add NIP-40 expiration tag (current time + 2 weeks) let expiration: Timestamp = Timestamp::now() + 14 * 24 * 60 * 60; // 2 weeks in seconds diff --git a/crates/nostr/src/nips/nip62.rs b/crates/nostr/src/nips/nip62.rs index 26735af86..7226843a0 100644 --- a/crates/nostr/src/nips/nip62.rs +++ b/crates/nostr/src/nips/nip62.rs @@ -6,9 +6,48 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/62.md> +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; +use core::fmt; -use crate::RelayUrl; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, RelayUrl}; + +const RELAY: &str = "relay"; +const ALL_RELAYS: &str = "ALL_RELAYS"; + +/// NIP-70 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} /// Request to Vanish target #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -40,3 +79,161 @@ impl VanishTarget { Self::AllRelays } } + +/// Standardized NIP-62 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/62.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip62Tag { + /// Global Request to Vanish + /// + /// `["relay", "ALL_RELAYS"]` + AllRelays, + /// Request to Vanish from Relay + /// + /// `["relay", "<relay-url>"]` + Relay(RelayUrl), +} + +impl TagCodec for Nip62Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + RELAY => parse_relay_tag(iter), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::AllRelays => Tag::new(vec![String::from(RELAY), String::from(ALL_RELAYS)]), + Self::Relay(relay) => Tag::new(vec![String::from(RELAY), relay.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip62Tag); + +fn parse_relay_tag<T, S>(mut iter: T) -> Result<Nip62Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let relay_url: S = iter.next().ok_or(TagCodecError::Missing("relay URL"))?; + + match relay_url.as_ref() { + ALL_RELAYS => Ok(Nip62Tag::AllRelays), + other => { + let relay_url: RelayUrl = RelayUrl::parse(other)?; + Ok(Nip62Tag::Relay(relay_url)) + } + } +} + +/// Check whether a NIP-62 request to vanish applies to the given relay. +/// +/// Returns `true` if the event is a `kind:62` vanish request and it contains +/// either: +/// - an `["relay", "ALL_RELAYS"]` tag, or +/// - an `["relay", "<relay-url>"]` tag matching `relay_url`. +/// +/// Returns `false` for events with a different kind or for events that do not +/// target the given relay. +pub fn is_valid_vanish_request_for_relay(tags: &[Tag], relay_url: Option<&RelayUrl>) -> bool { + tags.iter().any(|tag| match Nip62Tag::try_from(tag) { + Ok(Nip62Tag::AllRelays) => true, + Ok(Nip62Tag::Relay(relay)) => Some(&relay) == relay_url, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "std")] + use crate::{EventBuilder, PublicKey}; + + #[test] + fn test_standardized_relay_tag() { + let tag = vec!["relay", "wss://relay.damus.io"]; + let parsed = Nip62Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip62Tag::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_all_relays_tag() { + let tag = vec!["relay", "ALL_RELAYS"]; + let parsed = Nip62Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip62Tag::AllRelays); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + #[cfg(feature = "std")] + fn test_is_valid_vanish_request_for_relay() { + let relay_a = RelayUrl::parse("wss://relay.a.com").unwrap(); + let relay_b = RelayUrl::parse("wss://relay.b.com").unwrap(); + + let all_relays = EventBuilder::request_vanish(VanishTarget::all_relays()) + .unwrap() + .build( + PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + ); + + assert!(is_valid_vanish_request_for_relay( + all_relays.tags.as_slice(), + Some(&relay_a) + )); + assert!(is_valid_vanish_request_for_relay( + all_relays.tags.as_slice(), + Some(&relay_b) + )); + + let single_relay = EventBuilder::request_vanish(VanishTarget::relay(relay_a.clone())) + .unwrap() + .build( + PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + ); + + assert!(is_valid_vanish_request_for_relay( + single_relay.tags.as_slice(), + Some(&relay_a) + )); + assert!(!is_valid_vanish_request_for_relay( + single_relay.tags.as_slice(), + Some(&relay_b) + )); + + let other_kind = EventBuilder::text_note("hello").build( + PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .unwrap(), + ); + + assert!(!is_valid_vanish_request_for_relay( + other_kind.tags.as_slice(), + Some(&relay_a) + )); + } +} diff --git a/crates/nostr/src/nips/nip65.rs b/crates/nostr/src/nips/nip65.rs index 125bff210..1b0767b11 100644 --- a/crates/nostr/src/nips/nip65.rs +++ b/crates/nostr/src/nips/nip65.rs @@ -6,14 +6,25 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/65.md> +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use core::fmt; use core::str::FromStr; -use crate::{Event, RelayUrl, TagStandard}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::take_relay_url; +use crate::types::url; +use crate::{Event, RelayUrl}; + +const RELAY_METADATA: &str = "r"; /// NIP56 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), /// Invalid Relay Metadata InvalidRelayMetadata, } @@ -23,11 +34,25 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidRelayMetadata => f.write_str("Invalid relay metadata"), } } } +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Relay Metadata #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RelayMetadata { @@ -77,38 +102,122 @@ impl FromStr for RelayMetadata { } } +/// Standardized NIP-65 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/65.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip65Tag { + /// Relay metadata + RelayMetadata { + /// Relay URL + relay_url: RelayUrl, + /// Relay metadata + metadata: Option<RelayMetadata>, + }, +} + +impl TagCodec for Nip65Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + RELAY_METADATA => { + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + + let metadata: Option<RelayMetadata> = match iter.next() { + Some(metadata) => Some(RelayMetadata::from_str(metadata.as_ref())?), + None => None, + }; + + Ok(Self::RelayMetadata { + relay_url, + metadata, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::RelayMetadata { + relay_url, + metadata, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + metadata.is_some() as usize); + + tag.push(String::from(RELAY_METADATA)); + tag.push(relay_url.to_string()); + + if let Some(metadata) = metadata { + tag.push(metadata.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip65Tag); + /// Extracts the relay info (url, optional read/write flag) from the event #[inline] pub fn extract_relay_list( event: &Event, -) -> impl Iterator<Item = (&RelayUrl, &Option<RelayMetadata>)> { - event.tags.iter().filter_map(|tag| { - if let Some(TagStandard::RelayMetadata { - relay_url, - metadata, - }) = tag.as_standardized() - { - Some((relay_url, metadata)) - } else { - None - } - }) +) -> impl Iterator<Item = (RelayUrl, Option<RelayMetadata>)> + '_ { + event + .tags + .iter() + .filter_map(|tag| match Nip65Tag::try_from(tag) { + Ok(Nip65Tag::RelayMetadata { + relay_url, + metadata, + }) => Some((relay_url, metadata)), + _ => None, + }) } -/// Extracts the relay info (url, optional read/write flag) from the event -#[inline] -pub fn extract_owned_relay_list( - event: Event, -) -> impl Iterator<Item = (RelayUrl, Option<RelayMetadata>)> { - event.tags.into_iter().filter_map(|tag| { - if let Some(TagStandard::RelayMetadata { - relay_url, - metadata, - }) = tag.to_standardized() - { - Some((relay_url, metadata)) - } else { - None - } - }) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_standardized_relay_metadata_tag() { + let relay_url = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["r".to_string(), relay_url.to_string(), String::from("read")]; + let parsed = Nip65Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip65Tag::RelayMetadata { + relay_url: relay_url.clone(), + metadata: Some(RelayMetadata::Read), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_relay_metadata_tag_without_marker() { + let relay_url = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["r".to_string(), relay_url.to_string()]; + let parsed = Nip65Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip65Tag::RelayMetadata { + relay_url: relay_url.clone(), + metadata: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } } diff --git a/crates/nostr/src/nips/nip70.rs b/crates/nostr/src/nips/nip70.rs new file mode 100644 index 000000000..3f007b872 --- /dev/null +++ b/crates/nostr/src/nips/nip70.rs @@ -0,0 +1,90 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-70: Protected Events +//! +//! <https://github.com/nostr-protocol/nips/blob/master/70.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +/// NIP-70 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-70 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/70.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip70Tag { + /// Protected event tag + /// + /// `["-"]` + Protected, +} + +impl TagCodec for Nip70Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "-" => Ok(Self::Protected), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Protected => Tag::new(vec![String::from("-")]), + } + } +} + +impl_tag_codec_conversions!(Nip70Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_standardized_protected_tag() { + let tag = vec!["-"]; + let parsed = Nip70Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip70Tag::Protected); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip73.rs b/crates/nostr/src/nips/nip73.rs index f9e06daa1..120fa5e97 100644 --- a/crates/nostr/src/nips/nip73.rs +++ b/crates/nostr/src/nips/nip73.rs @@ -2,14 +2,18 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP73: External Content IDs +//! NIP-73: External Content IDs //! //! <https://github.com/nostr-protocol/nips/blob/master/73.md> use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; use core::fmt; use core::str::FromStr; +use super::util::{take_and_parse_from_str, take_and_parse_optional_from_str}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; use crate::types::Url; const HASHTAG: &str = "#"; @@ -26,6 +30,10 @@ const BLOCKCHAIN_ADDR: &str = ":address:"; /// NIP73 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// URL error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), /// Invalid external content InvalidExternalContent, /// Invalid NIP-73 kind @@ -37,12 +45,26 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidExternalContent => f.write_str("invalid external content ID"), Self::InvalidNip73Kind => f.write_str("Invalid NIP-73 kind"), } } } +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// External Content ID #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ExternalContentId { @@ -92,7 +114,7 @@ pub enum Nip73Kind { /// Books kind "isbn" Book, /// Geohashes kind "geo" - Geohashe, + Geohash, /// Movies kind "isan" Movie, /// Papers kind "doi" @@ -116,7 +138,7 @@ impl fmt::Display for Nip73Kind { match self { Self::Url => f.write_str("web"), Self::Book => f.write_str("isbn"), - Self::Geohashe => f.write_str("geo"), + Self::Geohash => f.write_str("geo"), Self::Movie => f.write_str("isan"), Self::Paper => f.write_str("doi"), Self::Hashtag => HASHTAG.fmt(f), @@ -180,7 +202,7 @@ impl FromStr for Nip73Kind { match nip73_kind { "web" => Ok(Self::Url), "isbn" => Ok(Self::Book), - "geo" => Ok(Self::Geohashe), + "geo" => Ok(Self::Geohash), "isan" => Ok(Self::Movie), "doi" => Ok(Self::Paper), HASHTAG => Ok(Self::Hashtag), @@ -276,7 +298,7 @@ impl ExternalContentId { match self { Self::Url(_) => Nip73Kind::Url, Self::Hashtag(_) => Nip73Kind::Hashtag, - Self::Geohash(_) => Nip73Kind::Geohashe, + Self::Geohash(_) => Nip73Kind::Geohash, Self::Book(_) => Nip73Kind::Book, Self::PodcastFeed(_) => Nip73Kind::PodcastFeed, Self::PodcastEpisode(_) => Nip73Kind::PodcastEpisode, @@ -300,6 +322,86 @@ fn extract_chain_id(chain: &str) -> (String, Option<String>) { } } +/// Standardized NIP-73 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/73.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip73Tag { + /// `i` tag + ExternalContent { + /// External content + content: ExternalContentId, + /// Optional URL hint + hint: Option<Url>, + }, + /// `k` tag + Kind(Nip73Kind), +} + +impl TagCodec for Nip73Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "i" => { + let (content, hint) = parse_i_tag(iter)?; + Ok(Self::ExternalContent { content, hint }) + } + "k" => { + let kind: Nip73Kind = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "kind")?; + Ok(Self::Kind(kind)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::ExternalContent { content, hint } => serialize_i_tag(content, hint.as_ref()), + Self::Kind(kind) => serialize_k_tag(kind), + } + } +} + +impl_tag_codec_conversions!(Nip73Tag); + +pub(super) fn parse_i_tag<T, S>(mut iter: T) -> Result<(ExternalContentId, Option<Url>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let content: ExternalContentId = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "content")?; + let hint: Option<Url> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((content, hint)) +} + +pub(super) fn serialize_i_tag(content: &ExternalContentId, hint: Option<&Url>) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + hint.is_some() as usize); + + tag.push(String::from("i")); + tag.push(content.to_string()); + + if let Some(hint) = hint { + tag.push(hint.to_string()); + } + + Tag::new(tag) +} + +#[inline] +pub(super) fn serialize_k_tag(kind: &Nip73Kind) -> Tag { + Tag::new(vec![String::from("k"), kind.to_string()]) +} + #[cfg(test)] mod tests { use super::*; @@ -462,4 +564,60 @@ mod tests { Err(Error::InvalidExternalContent) ); } + + #[test] + fn test_i_tag() { + let raw = [ + "i", + "https://myblog.example.com/post/2012-03-27/hello-world", + ]; + let tag = Nip73Tag::parse(raw).unwrap(); + + assert_eq!( + tag, + Nip73Tag::ExternalContent { + content: ExternalContentId::Url( + Url::parse("https://myblog.example.com/post/2012-03-27/hello-world").unwrap() + ), + hint: None + } + ); + + assert_eq!(tag.to_tag().as_slice(), &raw); + } + + #[test] + fn test_i_tag_with_hint() { + let raw = [ + "i", + "podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f", + "https://fountain.fm/episode/z1y9TMQRuqXl2awyrQxg", + ]; + let tag = Nip73Tag::parse(raw).unwrap(); + + assert_eq!( + tag, + Nip73Tag::ExternalContent { + content: ExternalContentId::PodcastEpisode( + "d98d189b-dc7b-45b1-8720-d4b98690f31f".to_string() + ), + hint: Some(Url::parse("https://fountain.fm/episode/z1y9TMQRuqXl2awyrQxg").unwrap()) + } + ); + + assert_eq!(tag.to_tag().as_slice(), &raw); + } + + #[test] + fn test_k_tag() { + let raw = ["k", "bitcoin:address"]; + let tag = Nip73Tag::parse(raw).unwrap(); + + assert_eq!( + tag, + Nip73Tag::Kind(Nip73Kind::BlockchainAddress(String::from("bitcoin"))) + ); + + assert_eq!(tag.to_tag().as_slice(), &raw); + } } diff --git a/crates/nostr/src/nips/nip7d.rs b/crates/nostr/src/nips/nip7d.rs new file mode 100644 index 000000000..77b46b4f7 --- /dev/null +++ b/crates/nostr/src/nips/nip7d.rs @@ -0,0 +1,88 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-7D: Threads +//! +//! <https://github.com/nostr-protocol/nips/blob/master/7D.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +const TITLE: &str = "title"; + +/// NIP-7D error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(err) => err.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(err: TagCodecError) -> Self { + Self::Codec(err) + } +} + +/// Standardized NIP-7D tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/7D.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip7DTag { + /// `title` tag + Title(String), +} + +impl TagCodec for Nip7DTag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + } + } +} + +impl_tag_codec_conversions!(Nip7DTag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_title_tag() { + let tag = vec!["title", "Lorem Ipsum"]; + let parsed = Nip7DTag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip7DTag::Title(String::from("Lorem Ipsum"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip88.rs b/crates/nostr/src/nips/nip88.rs index 52d58071c..f85fb96c0 100644 --- a/crates/nostr/src/nips/nip88.rs +++ b/crates/nostr/src/nips/nip88.rs @@ -2,28 +2,38 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP88: Polls +//! NIP-88: Polls //! //! <https://github.com/nostr-protocol/nips/blob/master/88.md> -use alloc::borrow::Cow; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use core::str::FromStr; -use crate::{Event, EventBuilder, EventId, Kind, RelayUrl, Tag, TagKind, TagStandard, Timestamp}; +use super::util::{take_and_parse_from_str, take_relay_url, take_string, take_timestamp}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url; +use crate::{Event, EventBuilder, EventId, Kind, RelayUrl, Timestamp}; -pub(crate) const ENDS_AT_TAG_KIND_STR: &str = "endsAt"; -pub(crate) const ENDS_AT_TAG_KIND: TagKind = TagKind::Custom(Cow::Borrowed(ENDS_AT_TAG_KIND_STR)); +const ENDS_AT: &str = "endsAt"; +const POLL_TYPE: &str = "polltype"; +const POLL_OPTION: &str = "option"; +const POLL_RESPONSE: &str = "response"; +const RELAY: &str = "relay"; /// NIP88 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// Url error + Url(url::Error), + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), /// Unknown poll type UnknownPollType, - /// Unexpected tag - UnexpectedTag, } impl core::error::Error for Error {} @@ -31,12 +41,32 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Url(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::UnknownPollType => f.write_str("unknown poll type"), - Self::UnexpectedTag => f.write_str("unexpected tag"), } } } +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Poll type #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum PollType { @@ -83,6 +113,80 @@ pub struct PollOption { pub text: String, } +/// Standardized NIP-88 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/88.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip88Tag { + /// Poll option + PollOption(PollOption), + /// Poll response + PollResponse(String), + /// Poll type + PollType(PollType), + /// Relay URL + Relay(RelayUrl), + /// Poll expiration timestamp + PollEndsAt(Timestamp), +} + +impl TagCodec for Nip88Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + POLL_OPTION => Ok(Self::PollOption(PollOption { + id: take_string(&mut iter, "poll ID")?, + text: take_string(&mut iter, "poll option text")?, + })), + POLL_RESPONSE => Ok(Self::PollResponse(take_string(&mut iter, "poll response")?)), + POLL_TYPE => { + let poll_type: PollType = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "poll type")?; + Ok(Self::PollType(poll_type)) + } + RELAY => { + let relay: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(relay)) + } + ENDS_AT => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::PollEndsAt(timestamp)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::PollOption(option) => Tag::new(vec![ + String::from(POLL_OPTION), + option.id.clone(), + option.text.clone(), + ]), + Self::PollResponse(response) => { + Tag::new(vec![String::from(POLL_RESPONSE), response.clone()]) + } + Self::PollType(poll_type) => { + Tag::new(vec![String::from(POLL_TYPE), poll_type.to_string()]) + } + Self::Relay(relay) => Tag::new(vec![String::from(RELAY), relay.to_string()]), + Self::PollEndsAt(timestamp) => { + Tag::new(vec![String::from(ENDS_AT), timestamp.to_string()]) + } + } + } +} + +impl_tag_codec_conversions!(Nip88Tag); + /// Poll #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Poll { @@ -101,41 +205,23 @@ pub struct Poll { impl Poll { /// Parse poll data from an [`Event`]. pub fn from_event(event: &Event) -> Result<Self, Error> { - // Search poll type - let poll_type: PollType = match event.tags.find_standardized(TagKind::PollType) { - Some(TagStandard::PollType(poll_type)) => *poll_type, - // Found an unexpected tag. - Some(..) => return Err(Error::UnexpectedTag), - // If no valid "polltype" tag is found, the "singlechoice" will be the default. - None => PollType::SingleChoice, - }; - - // Search poll options - let options: Vec<PollOption> = event - .tags - .filter_standardized(TagKind::Option) - .filter_map(|tag| match tag { - TagStandard::PollOption(option) => Some(option.clone()), - _ => None, - }) - .collect(); - - // Search relays - let relays: Vec<RelayUrl> = event - .tags - .filter_standardized(TagKind::Relay) - .filter_map(|tag| match tag { - TagStandard::Relay(url) => Some(url.clone()), - _ => None, - }) - .collect(); - - // Search ends timestamp - let ends_at: Option<Timestamp> = match event.tags.find_standardized(ENDS_AT_TAG_KIND) { - Some(TagStandard::PollEndsAt(timestamp)) => Some(*timestamp), - Some(..) => return Err(Error::UnexpectedTag), - None => None, - }; + let mut poll_type: PollType = PollType::SingleChoice; + let mut options: Vec<PollOption> = Vec::new(); + let mut relays: Vec<RelayUrl> = Vec::new(); + let mut ends_at: Option<Timestamp> = None; + + for tag in event.tags.iter() { + match Nip88Tag::try_from(tag) { + Ok(Nip88Tag::PollType(value)) => poll_type = value, + Ok(Nip88Tag::PollOption(option)) => options.push(option), + Ok(Nip88Tag::Relay(url)) => relays.push(url), + Ok(Nip88Tag::PollEndsAt(timestamp)) => ends_at = Some(timestamp), + Ok(Nip88Tag::PollResponse(..)) | Err(Error::Codec(TagCodecError::Unknown)) => (), + Err(Error::UnknownPollType) + | Err(Error::Codec(TagCodecError::Missing("poll type"))) => (), + Err(e) => return Err(e), + } + } Ok(Self { title: event.content.clone(), @@ -150,22 +236,18 @@ impl Poll { pub(crate) fn to_event_builder(self) -> EventBuilder { let mut tags: Vec<Tag> = Vec::with_capacity(1 + self.options.len() + self.relays.len()); - tags.push(Tag::from_standardized_without_cell(TagStandard::PollType( - self.r#type, - ))); + tags.push(Nip88Tag::PollType(self.r#type).to_tag()); for option in self.options.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PollOption(option), - )); + tags.push(Nip88Tag::PollOption(option).to_tag()); } for url in self.relays.into_iter() { - tags.push(Tag::relay(url)); + tags.push(Nip88Tag::Relay(url).to_tag()); } if let Some(timestamp) = self.ends_at { - tags.push(Tag::custom(ENDS_AT_TAG_KIND, [timestamp.to_string()])); + tags.push(Nip88Tag::PollEndsAt(timestamp).to_tag()); } EventBuilder::new(Kind::Poll, self.title).tags(tags) @@ -198,7 +280,7 @@ impl PollResponse { Self::SingleChoice { poll_id, response } => { vec![ Tag::event(poll_id), - Tag::from_standardized_without_cell(TagStandard::PollResponse(response)), + Nip88Tag::PollResponse(response).to_tag(), ] } Self::MultipleChoice { poll_id, responses } => { @@ -207,9 +289,7 @@ impl PollResponse { tags.push(Tag::event(poll_id)); for response in responses.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PollResponse(response), - )); + tags.push(Nip88Tag::PollResponse(response).to_tag()); } tags @@ -243,6 +323,54 @@ mod tests { assert!(PollType::from_str("unknown").is_err()); } + #[test] + fn test_standardized_poll_tags() { + let option = Nip88Tag::parse(["option", "qj518h583", "Yay"]).unwrap(); + assert_eq!( + option, + Nip88Tag::PollOption(PollOption { + id: "qj518h583".to_string(), + text: "Yay".to_string(), + }) + ); + assert_eq!( + option.to_tag(), + Tag::parse(["option", "qj518h583", "Yay"]).unwrap() + ); + + let poll_type = Nip88Tag::parse(["polltype", "multiplechoice"]).unwrap(); + assert_eq!(poll_type, Nip88Tag::PollType(PollType::MultipleChoice)); + assert_eq!( + poll_type.to_tag(), + Tag::parse(["polltype", "multiplechoice"]).unwrap() + ); + + let relay = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let relay_tag = Nip88Tag::parse(["relay", "wss://relay.damus.io"]).unwrap(); + assert_eq!(relay_tag, Nip88Tag::Relay(relay.clone())); + assert_eq!( + relay_tag.to_tag(), + Tag::parse(["relay", relay.as_str()]).unwrap() + ); + + let ends_at = Nip88Tag::parse(["endsAt", "1788888888"]).unwrap(); + assert_eq!( + ends_at, + Nip88Tag::PollEndsAt(Timestamp::from_secs(1788888888)) + ); + assert_eq!( + ends_at.to_tag(), + Tag::parse(["endsAt", "1788888888"]).unwrap() + ); + + let response = Nip88Tag::parse(["response", "qj518h583"]).unwrap(); + assert_eq!(response, Nip88Tag::PollResponse("qj518h583".to_string())); + assert_eq!( + response.to_tag(), + Tag::parse(["response", "qj518h583"]).unwrap() + ); + } + #[test] fn test_poll_from_event() { let event = Event::from_json(r#"{ diff --git a/crates/nostr/src/nips/nip89.rs b/crates/nostr/src/nips/nip89.rs new file mode 100644 index 000000000..84c755d1f --- /dev/null +++ b/crates/nostr/src/nips/nip89.rs @@ -0,0 +1,204 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-89: Recommended Application Handlers +//! +//! <https://github.com/nostr-protocol/nips/blob/master/89.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; + +use super::nip01::Coordinate; +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::RelayUrl; + +const CLIENT: &str = "client"; + +/// NIP-89 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-89 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/89.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip89Tag { + /// `client` tag + Client { + /// Client name + name: String, + /// Client address and optional hint + address: Option<(Coordinate, Option<RelayUrl>)>, + }, +} + +impl TagCodec for Nip89Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + CLIENT => { + let (name, address) = parse_client_tag(iter)?; + Ok(Self::Client { name, address }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Client { name, address } => { + let mut tag: Vec<String> = vec![CLIENT.to_string(), name.clone()]; + + match address { + Some((coordinate, Some(hint))) => { + tag.reserve_exact(2); + tag.push(coordinate.to_string()); + tag.push(hint.to_string()); + } + Some((coordinate, None)) => { + tag.push(coordinate.to_string()); + } + _ => {} + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip89Tag); + +#[allow(clippy::type_complexity)] +fn parse_client_tag<T, S>( + mut iter: T, +) -> Result<(String, Option<(Coordinate, Option<RelayUrl>)>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // Possible cases: + // - ["client", "My Client"] + // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>"] + // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>", "wss://relay1"] + + let name: String = take_string(&mut iter, "client name")?; + + let coordinate: Option<S> = iter.next(); + + // Since the address is optional, + // don't return an error if the coordinate or relay hint parsing fails. + let address: Option<(Coordinate, Option<RelayUrl>)> = match coordinate { + // Try to parse the coordinate + Some(coordinate) => match Coordinate::parse(coordinate.as_ref()) { + // Coordinate parsing success + Ok(coordinate) => { + let relay_url: Option<S> = iter.next(); + let relay_url: Option<RelayUrl> = + relay_url.and_then(|url| RelayUrl::parse(url.as_ref()).ok()); + Some((coordinate, relay_url)) + } + // Failed to parse the coordinate + Err(..) => None, + }, + // Nothing to parse + None => None, + }; + + Ok((name, address)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_tag() { + let tag = vec!["client", "voyage"]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip89Tag::Client { + name: String::from("voyage"), + address: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_client_tag_with_coordinate() { + let tag = vec![ + "client", + "voyage", + "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", + ]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip89Tag::Client {name: String::from("voyage"), address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None))}); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_client_tag_with_coordinate_and_relay_hint() { + let tag = vec![ + "client", + "voyage", + "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", + "wss://relay.damus.io", + ]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip89Tag::Client {name: String::from("voyage"), address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), Some(RelayUrl::parse("wss://relay.damus.io").unwrap())))}); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_client_tag_with_coordinate_and_empty_relay_hint() { + let tag = vec![ + "client", + "voyage", + "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", + "", + ]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip89Tag::Client {name: String::from("voyage"), address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None))}); + assert_eq!( + parsed.to_tag(), + Tag::parse(tag[..=2].iter().copied()).unwrap() + ); // The empty relay-hint is not serialized + } +} diff --git a/crates/nostr/src/nips/nip90.rs b/crates/nostr/src/nips/nip90.rs index b96037d39..5557a4119 100644 --- a/crates/nostr/src/nips/nip90.rs +++ b/crates/nostr/src/nips/nip90.rs @@ -6,27 +6,86 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/90.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use core::str::FromStr; -use crate::{Event, EventId, PublicKey}; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_relay_url, take_optional_string, take_string, +}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url; +use crate::{Event, EventId, JsonUtil, PublicKey, RelayUrl, event}; + +const INPUT: &str = "i"; +const OUTPUT: &str = "output"; +const PARAM: &str = "param"; +const BID: &str = "bid"; +const RELAYS: &str = "relays"; +const REQUEST: &str = "request"; +const AMOUNT: &str = "amount"; +const STATUS: &str = "status"; +const ENCRYPTED: &str = "encrypted"; /// DVM Error -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum Error { + /// Event error + Event(event::Error), + /// Parse int error + ParseInt(ParseIntError), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), + /// Unknown input type + UnknownInputType, /// Unknown status UnknownStatus, } +impl core::error::Error for Error {} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Event(e) => e.fmt(f), + Self::ParseInt(e) => fmt::Display::fmt(e, f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + Self::UnknownInputType => f.write_str("Unknown input type"), Self::UnknownStatus => f.write_str("Unknown status"), } } } +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Data Vending Machine Status #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum DataVendingMachineStatus { @@ -76,6 +135,262 @@ impl FromStr for DataVendingMachineStatus { } } +/// Job input type +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JobInputType { + /// URL input + Url, + /// Event input + Event, + /// Previous job output input + Job, + /// Plain text input + Text, +} + +impl fmt::Display for JobInputType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl JobInputType { + /// Get as `&str` + pub fn as_str(&self) -> &str { + match self { + Self::Url => "url", + Self::Event => "event", + Self::Job => "job", + Self::Text => "text", + } + } +} + +impl FromStr for JobInputType { + type Err = Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "url" => Ok(Self::Url), + "event" => Ok(Self::Event), + "job" => Ok(Self::Job), + "text" => Ok(Self::Text), + _ => Err(Error::UnknownInputType), + } + } +} + +/// Standardized NIP-90 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/90.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip90Tag { + /// `i` tag + Input { + /// Input data + data: String, + /// Input type + input_type: JobInputType, + /// Relay hint for event/job inputs + relay_hint: Option<RelayUrl>, + /// Optional marker + marker: Option<String>, + }, + /// `output` tag + Output(String), + /// `param` tag + Param { + /// Parameter name + name: String, + /// Parameter value + value: String, + }, + /// `bid` tag + Bid(u64), + /// `relays` tag + Relays(Vec<RelayUrl>), + /// `request` tag + Request(Event), + /// `amount` tag + Amount { + /// Amount in millisats + millisats: u64, + /// Optional bolt11 invoice + bolt11: Option<String>, + }, + /// `status` tag + Status { + /// Job status + status: DataVendingMachineStatus, + /// Optional human-readable extra info + extra_info: Option<String>, + }, + /// `encrypted` tag + Encrypted, +} + +impl TagCodec for Nip90Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + INPUT => { + let (data, input_type, relay_hint, marker) = parse_input_tag(iter)?; + Ok(Self::Input { + data, + input_type, + relay_hint, + marker, + }) + } + OUTPUT => Ok(Self::Output(take_string(&mut iter, "output")?)), + PARAM => Ok(Nip90Tag::Param { + name: take_string(&mut iter, "param name")?, + value: take_string(&mut iter, "param value")?, + }), + BID => { + let bid: u64 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "bid")?; + Ok(Self::Bid(bid)) + } + RELAYS => Ok(Self::Relays(parse_relays_tag(iter)?)), + REQUEST => { + let request: S = iter.next().ok_or(TagCodecError::Missing("request"))?; + Ok(Self::Request(Event::from_json(request.as_ref())?)) + } + AMOUNT => { + let (millisats, bolt11) = parse_amount_tag(iter)?; + Ok(Self::Amount { millisats, bolt11 }) + } + STATUS => { + let (status, extra_info) = parse_status_tag(iter)?; + Ok(Self::Status { status, extra_info }) + } + ENCRYPTED => Ok(Self::Encrypted), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Input { + data, + input_type, + relay_hint, + marker, + } => { + let mut tag: Vec<String> = Vec::with_capacity( + 3 + relay_hint.is_some() as usize + marker.is_some() as usize, + ); + tag.push(String::from(INPUT)); + tag.push(data.clone()); + tag.push(input_type.to_string()); + + match relay_hint { + Some(relay_hint) => tag.push(relay_hint.to_string()), + None => { + if marker.is_some() { + tag.push(String::new()); + } + } + } + + if let Some(marker) = marker { + tag.push(marker.clone()); + } + + Tag::new(tag) + } + Self::Output(output) => Tag::new(vec![String::from(OUTPUT), output.clone()]), + Self::Param { name, value } => { + Tag::new(vec![String::from(PARAM), name.clone(), value.clone()]) + } + Self::Bid(bid) => Tag::new(vec![String::from(BID), bid.to_string()]), + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(relays.len() + 1); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Request(event) => Tag::new(vec![String::from(REQUEST), event.as_json()]), + Self::Amount { millisats, bolt11 } => { + let mut tag: Vec<String> = vec![String::from(AMOUNT), millisats.to_string()]; + if let Some(bolt11) = bolt11 { + tag.push(bolt11.clone()); + } + Tag::new(tag) + } + Self::Status { status, extra_info } => { + let mut tag: Vec<String> = vec![String::from(STATUS), status.to_string()]; + if let Some(extra_info) = extra_info { + tag.push(extra_info.clone()); + } + Tag::new(tag) + } + Self::Encrypted => Tag::new(vec![String::from(ENCRYPTED)]), + } + } +} + +impl_tag_codec_conversions!(Nip90Tag); + +fn parse_input_tag<T, S>( + mut iter: T, +) -> Result<(String, JobInputType, Option<RelayUrl>, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let data: String = take_string(&mut iter, "input data")?; + let input_type: JobInputType = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "input type")?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let marker: Option<String> = take_optional_string(&mut iter); + + Ok((data, input_type, relay_hint, marker)) +} + +fn parse_relays_tag<T, S>(iter: T) -> Result<Vec<RelayUrl>, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let mut relays: Vec<RelayUrl> = Vec::new(); + for relay in iter { + relays.push(RelayUrl::parse(relay.as_ref())?); + } + Ok(relays) +} + +fn parse_amount_tag<T, S>(mut iter: T) -> Result<(u64, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let millisats: u64 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "amount")?; + let bolt11: Option<String> = take_optional_string(&mut iter); + + Ok((millisats, bolt11)) +} + +fn parse_status_tag<T, S>(mut iter: T) -> Result<(DataVendingMachineStatus, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let status: DataVendingMachineStatus = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "status")?; + let extra_info: Option<String> = take_optional_string(&mut iter); + + Ok((status, extra_info)) +} + /// Data Vending Machine (DVM) - Job Feedback data /// /// <https://github.com/nostr-protocol/nips/blob/master/90.md> @@ -132,3 +447,74 @@ impl JobFeedbackData { self } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_input_tag() { + let tag = vec!["i", "hello", "text"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Input { + data: String::from("hello"), + input_type: JobInputType::Text, + relay_hint: None, + marker: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_param_tag() { + let tag = vec!["param", "lang", "es"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Param { + name: String::from("lang"), + value: String::from("es"), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_amount_tag() { + let tag = vec!["amount", "21000", "lnbc1..."]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Amount { + millisats: 21000, + bolt11: Some(String::from("lnbc1...")), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_status_tag() { + let tag = vec!["status", "processing", "working"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Status { + status: DataVendingMachineStatus::Processing, + extra_info: Some(String::from("working")), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_encrypted_tag() { + let tag = vec!["encrypted"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip90Tag::Encrypted); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nip94.rs b/crates/nostr/src/nips/nip94.rs index 7387862ba..f25942a79 100644 --- a/crates/nostr/src/nips/nip94.rs +++ b/crates/nostr/src/nips/nip94.rs @@ -2,19 +2,101 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP94: File Metadata +//! NIP-94: File Metadata //! //! <https://github.com/nostr-protocol/nips/blob/master/94.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; +use core::str::FromStr; +use hashes::hex::HexToArrayError; use hashes::sha256::Hash as Sha256Hash; -use crate::{ImageDimensions, Tag, TagKind, TagStandard, Url}; +use super::util::{take_and_parse_from_str, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::{image, url}; +use crate::{ImageDimensions, Url}; + +const URL: &str = "url"; +const MIME_TYPE: &str = "m"; +const SHA256: &str = "x"; +const ORIGINAL_HASH: &str = "ox"; +const SIZE: &str = "size"; +const DIMENSIONS: &str = "dim"; +const MAGNET: &str = "magnet"; +const TORRENT_INFOHASH: &str = "i"; +const BLURHASH: &str = "blurhash"; +const THUMB: &str = "thumb"; +const IMAGE: &str = "image"; +const SUMMARY: &str = "summary"; +const ALT: &str = "alt"; +const FALLBACK: &str = "fallback"; +const SERVICE: &str = "service"; + +/// NIP-94 error +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + /// Parse int error + ParseInt(ParseIntError), + /// Hex decoding error + Hex(HexToArrayError), + /// URL parse error + Url(url::ParseError), + /// Image error + Image(image::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Hex(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Image(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<HexToArrayError> for Error { + fn from(e: HexToArrayError) -> Self { + Self::Hex(e) + } +} -/// Potential errors returned when parsing tags into a [FileMetadata] struct +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Potential errors returned when parsing tags into a [`FileMetadata`] struct. #[derive(Debug, PartialEq, Eq)] pub enum FileMetadataError { /// The URL of the file is missing (no `url` tag) @@ -37,6 +119,155 @@ impl fmt::Display for FileMetadataError { } } +/// Standardized NIP-94 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/94.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip94Tag { + /// `url` tag + Url(Url), + /// `m` tag + MimeType(String), + /// `x` tag + Sha256(Sha256Hash), + /// `ox` tag + OriginalHash(Sha256Hash), + /// `size` tag + Size(usize), + /// `dim` tag + Dim(ImageDimensions), + /// `magnet` tag + Magnet(String), + /// `i` tag + TorrentInfohash(String), + /// `blurhash` tag + Blurhash(String), + /// `thumb` tag + Thumb { + /// Thumbnail URL + url: Url, + /// Optional SHA256 hash + hash: Option<Sha256Hash>, + }, + /// `image` tag + Image { + /// Preview image URL + url: Url, + /// Optional SHA256 hash + hash: Option<Sha256Hash>, + }, + /// `summary` tag + Summary(String), + /// `alt` tag + Alt(String), + /// `fallback` tag + Fallback(Url), + /// `service` tag + Service(String), +} + +impl TagCodec for Nip94Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + URL => { + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "URL")?; + Ok(Self::Url(url)) + } + MIME_TYPE => Ok(Self::MimeType(take_string(&mut iter, "mime type")?)), + SHA256 => { + let hash: Sha256Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "sha256")?; + Ok(Self::Sha256(hash)) + } + ORIGINAL_HASH => { + let hash: Sha256Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "original hash")?; + Ok(Self::OriginalHash(hash)) + } + SIZE => { + let size: usize = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "size")?; + Ok(Self::Size(size)) + } + DIMENSIONS => { + let dim: ImageDimensions = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "dimensions")?; + Ok(Self::Dim(dim)) + } + MAGNET => Ok(Self::Magnet(take_string(&mut iter, "magnet link")?)), + TORRENT_INFOHASH => Ok(Self::TorrentInfohash(take_string(&mut iter, "infohash")?)), + BLURHASH => Ok(Self::Blurhash(take_string(&mut iter, "blurhash")?)), + THUMB => { + let (url, hash) = parse_thumb_or_image_tag(iter, "thumb URL")?; + Ok(Self::Thumb { url, hash }) + } + IMAGE => { + let (url, hash) = parse_thumb_or_image_tag(iter, "image URL")?; + Ok(Self::Image { url, hash }) + } + SUMMARY => Ok(Self::Summary(take_string(&mut iter, "summary")?)), + ALT => Ok(Self::Alt(take_string(&mut iter, "alt")?)), + FALLBACK => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "fallback URL")?; + Ok(Self::Fallback(url)) + } + SERVICE => Ok(Self::Service(take_string(&mut iter, "service name")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Url(url) => Tag::new(vec![String::from(URL), url.to_string()]), + Self::MimeType(mime_type) => { + Tag::new(vec![String::from(MIME_TYPE), mime_type.to_string()]) + } + Self::Sha256(hash) => Tag::new(vec![String::from(SHA256), hash.to_string()]), + Self::OriginalHash(hash) => { + Tag::new(vec![String::from(ORIGINAL_HASH), hash.to_string()]) + } + Self::Size(size) => Tag::new(vec![String::from(SIZE), size.to_string()]), + Self::Dim(dim) => Tag::new(vec![String::from(DIMENSIONS), dim.to_string()]), + Self::Magnet(uri) => Tag::new(vec![String::from(MAGNET), uri.to_string()]), + Self::TorrentInfohash(infohash) => { + Tag::new(vec![String::from(TORRENT_INFOHASH), infohash.to_string()]) + } + Self::Blurhash(blurhash) => { + Tag::new(vec![String::from(BLURHASH), blurhash.to_string()]) + } + Self::Thumb { url, hash } => { + let mut tag = vec![String::from(THUMB), url.to_string()]; + if let Some(hash) = hash { + tag.push(hash.to_string()); + } + Tag::new(tag) + } + Self::Image { url, hash } => { + let mut tag = vec![String::from(IMAGE), url.to_string()]; + if let Some(hash) = hash { + tag.push(hash.to_string()); + } + Tag::new(tag) + } + Self::Summary(summary) => Tag::new(vec![String::from(SUMMARY), summary.to_string()]), + Self::Alt(alt) => Tag::new(vec![String::from(ALT), alt.to_string()]), + Self::Fallback(url) => Tag::new(vec![String::from(FALLBACK), url.to_string()]), + Self::Service(service) => Tag::new(vec![String::from(SERVICE), service.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip94Tag); + /// File Metadata #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct FileMetadata { @@ -46,16 +277,34 @@ pub struct FileMetadata { pub mime_type: String, /// SHA256 of file pub hash: Sha256Hash, - /// AES 256 GCM - pub aes_256_gcm: Option<(String, String)>, + /// SHA-256 of the original file before any server-side transforms (`ox` tag) + pub original_hash: Option<Sha256Hash>, /// Size in bytes pub size: Option<usize>, /// Size in pixels pub dim: Option<ImageDimensions>, /// Magnet pub magnet: Option<String>, + /// Torrent infohash + pub torrent_infohash: Option<String>, /// Blurhash pub blurhash: Option<String>, + /// Thumbnail URL + pub thumb: Option<Url>, + /// Thumbnail SHA256 hash + pub thumb_hash: Option<Sha256Hash>, + /// Preview image URL + pub image: Option<Url>, + /// Preview image SHA256 hash + pub image_hash: Option<Sha256Hash>, + /// Short text summary / description + pub summary: Option<String>, + /// Alt text for accessibility + pub alt: Option<String>, + /// Fallback download URLs + pub fallback: Vec<Url>, + /// Serving service type + pub service: Option<String>, } impl FileMetadata { @@ -68,61 +317,123 @@ impl FileMetadata { url, mime_type: mime_type.into(), hash, - aes_256_gcm: None, + original_hash: None, size: None, dim: None, magnet: None, + torrent_infohash: None, blurhash: None, + thumb: None, + thumb_hash: None, + image: None, + image_hash: None, + summary: None, + alt: None, + fallback: Vec::new(), + service: None, } } - /// Add AES 256 GCM - pub fn aes_256_gcm<S>(self, key: S, iv: S) -> Self - where - S: Into<String>, - { - Self { - aes_256_gcm: Some((key.into(), iv.into())), - ..self - } + /// Set SHA-256 of the original file before server-side transforms (`ox` tag) + pub fn original_hash(mut self, hash: Sha256Hash) -> Self { + self.original_hash = Some(hash); + self } /// Add file size (bytes) - pub fn size(self, size: usize) -> Self { - Self { - size: Some(size), - ..self - } + pub fn size(mut self, size: usize) -> Self { + self.size = Some(size); + self } /// Add file size (pixels) - pub fn dimensions(self, dim: ImageDimensions) -> Self { - Self { - dim: Some(dim), - ..self - } + pub fn dimensions(mut self, dim: ImageDimensions) -> Self { + self.dim = Some(dim); + self } /// Add magnet - pub fn magnet<S>(self, magnet: S) -> Self + pub fn magnet<S>(mut self, magnet: S) -> Self where S: Into<String>, { - Self { - magnet: Some(magnet.into()), - ..self - } + self.magnet = Some(magnet.into()); + self + } + + /// Add torrent infohash + pub fn torrent_infohash<S>(mut self, infohash: S) -> Self + where + S: Into<String>, + { + self.torrent_infohash = Some(infohash.into()); + self } /// Add blurhash - pub fn blurhash<S>(self, blurhash: S) -> Self + pub fn blurhash<S>(mut self, blurhash: S) -> Self where S: Into<String>, { - Self { - blurhash: Some(blurhash.into()), - ..self - } + self.blurhash = Some(blurhash.into()); + self + } + + /// Add thumbnail URL + pub fn thumb(mut self, thumb: Url) -> Self { + self.thumb = Some(thumb); + self + } + + /// Add thumbnail SHA256 hash + pub fn thumb_hash(mut self, hash: Sha256Hash) -> Self { + self.thumb_hash = Some(hash); + self + } + + /// Add preview image URL + pub fn image(mut self, image: Url) -> Self { + self.image = Some(image); + self + } + + /// Add preview image SHA256 hash + pub fn image_hash(mut self, hash: Sha256Hash) -> Self { + self.image_hash = Some(hash); + self + } + + /// Add short text summary / description + pub fn summary<S>(mut self, summary: S) -> Self + where + S: Into<String>, + { + self.summary = Some(summary.into()); + self + } + + /// Add alt text for accessibility + pub fn alt<S>(mut self, alt: S) -> Self + where + S: Into<String>, + { + self.alt = Some(alt.into()); + self + } + + /// Add a fallback download URL + pub fn add_fallback(mut self, url: Url) -> Self { + self.fallback.push(url); + self + } + + /// Add the serving service type + pub fn service<S>(mut self, service: S) -> Self + where + S: Into<String>, + { + self.service = Some(service.into()); + self } } @@ -132,47 +443,86 @@ impl From<FileMetadata> for Vec<Tag> { url, mime_type, hash, - aes_256_gcm, + original_hash, size, dim, magnet, + torrent_infohash, blurhash, + thumb, + thumb_hash, + image, + image_hash, + summary, + alt, + fallback, + service, } = metadata; let mut tags: Vec<Tag> = Vec::with_capacity(3); - tags.push(Tag::from_standardized_without_cell(TagStandard::Url(url))); - tags.push(Tag::from_standardized_without_cell(TagStandard::MimeType( - mime_type, - ))); - tags.push(Tag::from_standardized_without_cell(TagStandard::Sha256( - hash, - ))); + tags.push(Nip94Tag::Url(url).to_tag()); + tags.push(Nip94Tag::MimeType(mime_type).to_tag()); + tags.push(Nip94Tag::Sha256(hash).to_tag()); - if let Some((key, iv)) = aes_256_gcm { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Aes256Gcm { key, iv }, - )); + if let Some(hash) = original_hash { + tags.push(Nip94Tag::OriginalHash(hash).to_tag()); } if let Some(size) = size { - tags.push(Tag::from_standardized_without_cell(TagStandard::Size(size))); + tags.push(Nip94Tag::Size(size).to_tag()); } if let Some(dim) = dim { - tags.push(Tag::from_standardized_without_cell(TagStandard::Dim(dim))); + tags.push(Nip94Tag::Dim(dim).to_tag()); } if let Some(magnet) = magnet { - tags.push(Tag::from_standardized_without_cell(TagStandard::Magnet( - magnet, - ))); + tags.push(Nip94Tag::Magnet(magnet).to_tag()); + } + + if let Some(infohash) = torrent_infohash { + tags.push(Nip94Tag::TorrentInfohash(infohash).to_tag()); } if let Some(blurhash) = blurhash { - tags.push(Tag::from_standardized_without_cell(TagStandard::Blurhash( - blurhash, - ))); + tags.push(Nip94Tag::Blurhash(blurhash).to_tag()); + } + + if let Some(url) = thumb { + tags.push( + Nip94Tag::Thumb { + url, + hash: thumb_hash, + } + .to_tag(), + ); + } + + if let Some(url) = image { + tags.push( + Nip94Tag::Image { + url, + hash: image_hash, + } + .to_tag(), + ); + } + + if let Some(summary) = summary { + tags.push(Nip94Tag::Summary(summary).to_tag()); + } + + if let Some(alt) = alt { + tags.push(Nip94Tag::Alt(alt).to_tag()); + } + + for url in fallback { + tags.push(Nip94Tag::Fallback(url).to_tag()); + } + + if let Some(service) = service { + tags.push(Nip94Tag::Service(service).to_tag()); } tags @@ -183,126 +533,265 @@ impl TryFrom<Vec<Tag>> for FileMetadata { type Error = FileMetadataError; fn try_from(value: Vec<Tag>) -> Result<Self, Self::Error> { - let url = match value - .iter() - .find(|t| t.kind() == TagKind::Url) - .map(|t| t.as_standardized()) - { - Some(Some(TagStandard::Url(url))) => Ok(url), - _ => Err(Self::Error::MissingUrl), - }?; - - let mime = match value - .iter() - .find(|t| { - let t = t.as_standardized(); - matches!(t, Some(TagStandard::MimeType(..))) - }) - .map(|t| t.as_standardized()) - { - Some(Some(TagStandard::MimeType(mime))) => Ok(mime), - _ => Err(Self::Error::MissingMimeType), - }?; - - let sha256 = match value - .iter() - .find(|t| { - let t = t.as_standardized(); - matches!(t, Some(TagStandard::Sha256(..))) - }) - .map(|t| t.as_standardized()) - { - Some(Some(TagStandard::Sha256(sha256))) => Ok(sha256), - _ => Err(Self::Error::MissingSha), - }?; - - let mut metadata = FileMetadata::new(url.clone(), mime, *sha256); - - if let Some(TagStandard::Aes256Gcm { key, iv }) = value.iter().find_map(|t| { - let t = t.as_standardized(); - if matches!(t, Some(TagStandard::Aes256Gcm { .. })) { - t - } else { - None + let mut url: Option<Url> = None; + let mut mime_type: Option<String> = None; + let mut hash: Option<Sha256Hash> = None; + let mut original_hash: Option<Sha256Hash> = None; + let mut size: Option<usize> = None; + let mut dim: Option<ImageDimensions> = None; + let mut magnet: Option<String> = None; + let mut torrent_infohash: Option<String> = None; + let mut blurhash: Option<String> = None; + let mut thumb: Option<Url> = None; + let mut thumb_hash: Option<Sha256Hash> = None; + let mut image: Option<Url> = None; + let mut image_hash: Option<Sha256Hash> = None; + let mut summary: Option<String> = None; + let mut alt: Option<String> = None; + let mut fallback: Vec<Url> = Vec::new(); + let mut service: Option<String> = None; + + for tag in value.iter().filter_map(|tag| Nip94Tag::try_from(tag).ok()) { + match tag { + Nip94Tag::Url(value) => { + if url.is_none() { + url = Some(value); + } + } + Nip94Tag::MimeType(value) => { + if mime_type.is_none() { + mime_type = Some(value); + } + } + Nip94Tag::Sha256(value) => { + if hash.is_none() { + hash = Some(value); + } + } + Nip94Tag::OriginalHash(value) => { + if original_hash.is_none() { + original_hash = Some(value); + } + } + Nip94Tag::Size(value) => { + if size.is_none() { + size = Some(value); + } + } + Nip94Tag::Dim(value) => { + if dim.is_none() { + dim = Some(value); + } + } + Nip94Tag::Magnet(value) => { + if magnet.is_none() { + magnet = Some(value); + } + } + Nip94Tag::TorrentInfohash(value) => { + if torrent_infohash.is_none() { + torrent_infohash = Some(value); + } + } + Nip94Tag::Blurhash(value) => { + if blurhash.is_none() { + blurhash = Some(value); + } + } + Nip94Tag::Thumb { url, hash } => { + if thumb.is_none() { + thumb = Some(url); + } + if thumb_hash.is_none() { + thumb_hash = hash; + } + } + Nip94Tag::Image { url, hash } => { + if image.is_none() { + image = Some(url); + } + if image_hash.is_none() { + image_hash = hash; + } + } + Nip94Tag::Summary(value) => { + if summary.is_none() { + summary = Some(value); + } + } + Nip94Tag::Alt(value) => { + if alt.is_none() { + alt = Some(value); + } + } + Nip94Tag::Fallback(value) => fallback.push(value), + Nip94Tag::Service(value) => { + if service.is_none() { + service = Some(value); + } + } } - }) { - metadata = metadata.aes_256_gcm(key, iv); } - if let Some(TagStandard::Size(size)) = value.iter().find_map(|t| { - let t = t.as_standardized(); - if matches!(t, Some(TagStandard::Size { .. })) { - t - } else { - None - } - }) { - metadata = metadata.size(*size); + let url = url.ok_or(FileMetadataError::MissingUrl)?; + let mime_type = mime_type.ok_or(FileMetadataError::MissingMimeType)?; + let hash = hash.ok_or(FileMetadataError::MissingSha)?; + + let mut metadata = FileMetadata::new(url, mime_type, hash); + + if let Some(hash) = original_hash { + metadata = metadata.original_hash(hash); } - if let Some(TagStandard::Dim(dim)) = value.iter().find_map(|t| { - let t = t.as_standardized(); - if matches!(t, Some(TagStandard::Dim { .. })) { - t - } else { - None - } - }) { - metadata = metadata.dimensions(*dim); + if let Some(size) = size { + metadata = metadata.size(size); } - if let Some(TagStandard::Magnet(magnet)) = value.iter().find_map(|t| { - let t = t.as_standardized(); - if matches!(t, Some(TagStandard::Magnet { .. })) { - t - } else { - None - } - }) { + if let Some(dim) = dim { + metadata = metadata.dimensions(dim); + } + + if let Some(magnet) = magnet { metadata = metadata.magnet(magnet); } - if let Some(TagStandard::Blurhash(bh)) = value.iter().find_map(|t| { - let t = t.as_standardized(); - if matches!(t, Some(TagStandard::Blurhash { .. })) { - t - } else { - None - } - }) { - metadata = metadata.blurhash(bh); + if let Some(infohash) = torrent_infohash { + metadata = metadata.torrent_infohash(infohash); + } + + if let Some(blurhash) = blurhash { + metadata = metadata.blurhash(blurhash); + } + + if let Some(url) = thumb { + metadata = metadata.thumb(url); + } + + if let Some(hash) = thumb_hash { + metadata = metadata.thumb_hash(hash); + } + + if let Some(url) = image { + metadata = metadata.image(url); + } + + if let Some(hash) = image_hash { + metadata = metadata.image_hash(hash); + } + + if let Some(summary) = summary { + metadata = metadata.summary(summary); + } + + if let Some(alt) = alt { + metadata = metadata.alt(alt); + } + + for url in fallback { + metadata = metadata.add_fallback(url); + } + + if let Some(service) = service { + metadata = metadata.service(service); } Ok(metadata) } } +fn parse_thumb_or_image_tag<T, S>( + mut iter: T, + missing_error: &'static str, +) -> Result<(Url, Option<Sha256Hash>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, missing_error)?; + let hash: Option<Sha256Hash> = match iter.next() { + Some(hash) if !hash.as_ref().is_empty() => Some(Sha256Hash::from_str(hash.as_ref())?), + _ => None, + }; + + Ok((url, hash)) +} + #[cfg(test)] mod tests { use core::str::FromStr; use super::*; - use crate::{ImageDimensions, Tag}; const IMAGE_URL: &str = "https://image.nostr.build/99a95fcb4b7a2591ad32467032c52a62d90a204d3b176bc2459ad7427a3f2b89.jpg"; const IMAGE_HASH: &str = "1aea8e98e0e5d969b7124f553b88dfae47d1f00472ea8c0dbf4ac4577d39ef02"; + const THUMB_URL: &str = "https://image.nostr.build/thumb.jpg"; + const THUMB_HASH: &str = "2aea8e98e0e5d969b7124f553b88dfae47d1f00472ea8c0dbf4ac4577d39ef02"; + + #[test] + fn test_standardized_thumb_tag() { + let url = Url::parse(THUMB_URL).unwrap(); + let hash = Sha256Hash::from_str(THUMB_HASH).unwrap(); + let tag = vec![String::from("thumb"), url.to_string(), hash.to_string()]; + let parsed = Nip94Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip94Tag::Thumb { + url: url.clone(), + hash: Some(hash), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_service_tag() { + let tag = vec![String::from("service"), String::from("nip96")]; + let parsed = Nip94Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip94Tag::Service(String::from("nip96"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } #[test] fn parses_valid_tag_vector() { let url = Url::parse(IMAGE_URL).unwrap(); let hash = Sha256Hash::from_str(IMAGE_HASH).unwrap(); + let thumb_url = Url::parse(THUMB_URL).unwrap(); + let thumb_hash = Sha256Hash::from_str(THUMB_HASH).unwrap(); let dim = ImageDimensions { width: 640, height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Sha256(hash)), - Tag::from_standardized_without_cell(TagStandard::Url(url.clone())), - Tag::from_standardized_without_cell(TagStandard::MimeType(String::from("image/jpeg"))), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Sha256(hash).to_tag(), + Nip94Tag::Url(url.clone()).to_tag(), + Nip94Tag::MimeType(String::from("image/jpeg")).to_tag(), + Nip94Tag::OriginalHash(hash).to_tag(), + Nip94Tag::TorrentInfohash(String::from("abc123")).to_tag(), + Nip94Tag::Thumb { + url: thumb_url.clone(), + hash: Some(thumb_hash), + } + .to_tag(), + Nip94Tag::Summary(String::from("example summary")).to_tag(), + Nip94Tag::Alt(String::from("example alt")).to_tag(), + Nip94Tag::Fallback(Url::parse("https://fallback.example.com/file.jpg").unwrap()) + .to_tag(), + Nip94Tag::Service(String::from("nip96")).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap(); - let expected = FileMetadata::new(url, "image/jpeg", hash).dimensions(dim); + let expected = FileMetadata::new(url, "image/jpeg", hash) + .original_hash(hash) + .dimensions(dim) + .torrent_infohash("abc123") + .thumb(thumb_url) + .thumb_hash(thumb_hash) + .summary("example summary") + .alt("example alt") + .add_fallback(Url::parse("https://fallback.example.com/file.jpg").unwrap()) + .service("nip96"); assert_eq!(expected, got); } @@ -315,9 +804,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Sha256(hash)), - Tag::from_standardized_without_cell(TagStandard::MimeType(String::from("image/jpeg"))), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Sha256(hash).to_tag(), + Nip94Tag::MimeType(String::from("image/jpeg")).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap_err(); @@ -333,9 +822,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Sha256(hash)), - Tag::from_standardized_without_cell(TagStandard::Url(url.clone())), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Sha256(hash).to_tag(), + Nip94Tag::Url(url).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap_err(); @@ -350,9 +839,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Url(url.clone())), - Tag::from_standardized_without_cell(TagStandard::MimeType(String::from("image/jpeg"))), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Url(url).to_tag(), + Nip94Tag::MimeType(String::from("image/jpeg")).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap_err(); diff --git a/crates/nostr/src/nips/nip98.rs b/crates/nostr/src/nips/nip98.rs index e8b44d0fb..9ea09a8c1 100644 --- a/crates/nostr/src/nips/nip98.rs +++ b/crates/nostr/src/nips/nip98.rs @@ -2,13 +2,14 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP98: HTTP Auth +//! NIP-98: HTTP Auth //! //! This NIP defines an ephemeral event used to authorize requests to HTTP servers using nostr events. //! This is useful for HTTP services which are build for Nostr and deal with Nostr user accounts. //! //! <https://github.com/nostr-protocol/nips/blob/master/98.md> +use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; use core::str::FromStr; @@ -17,31 +18,38 @@ use core::str::FromStr; use base64::engine::{Engine, general_purpose}; #[cfg(feature = "std")] use hashes::Hash; +use hashes::hex::HexToArrayError; use hashes::sha256::Hash as Sha256Hash; +use super::util::take_and_parse_from_str; +use crate::Url; #[cfg(all(feature = "std", feature = "rand"))] use crate::event::EventBuilder; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; #[cfg(feature = "std")] use crate::event::{self, Event, builder}; #[cfg(all(feature = "std", feature = "rand"))] use crate::signer::{AsyncGetPublicKey, AsyncSignEvent}; +use crate::types::url; #[cfg(feature = "std")] use crate::util::JsonUtil; #[cfg(feature = "std")] -use crate::{Kind, PublicKey, TagKind, Timestamp}; -use crate::{Tag, TagStandard, Url}; +use crate::{Kind, PublicKey, Timestamp}; #[cfg(feature = "std")] const AUTH_HEADER_PREFIX: &str = "Nostr"; +const ABSOLUTE_URL: &str = "u"; +const METHOD: &str = "method"; +const PAYLOAD: &str = "payload"; /// [`HttpData`] required tags #[derive(Debug, PartialEq, Eq)] pub enum RequiredTags { - /// [`TagStandard::AbsoluteURL`] + /// `u` AbsoluteURL, - /// [`TagStandard::Method`] + /// `method` Method, - /// [`TagStandard::Payload`] + /// `payload` Payload, } @@ -67,6 +75,12 @@ pub enum Error { /// Event builder error #[cfg(feature = "std")] EventBuilder(builder::Error), + /// URL parse error + Url(url::ParseError), + /// Hex decoding error + Hex(HexToArrayError), + /// Codec error + Codec(TagCodecError), /// Tag missing when parsing MissingTag(RequiredTags), /// Invalid HTTP Method @@ -116,6 +130,9 @@ impl fmt::Display for Error { Self::Event(e) => e.fmt(f), #[cfg(feature = "std")] Self::EventBuilder(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Hex(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::MissingTag(tag) => write!(f, "missing '{tag}' tag"), Self::UnknownMethod => f.write_str("Unknown HTTP method"), #[cfg(feature = "std")] @@ -150,6 +167,12 @@ impl fmt::Display for Error { } } +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + #[cfg(feature = "std")] impl From<base64::DecodeError> for Error { fn from(e: base64::DecodeError) -> Self { @@ -171,6 +194,18 @@ impl From<builder::Error> for Error { } } +impl From<HexToArrayError> for Error { + fn from(e: HexToArrayError) -> Self { + Self::Hex(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// HTTP Method /// /// <https://github.com/nostr-protocol/nips/blob/master/98.md> @@ -231,6 +266,59 @@ pub struct HttpData { pub payload: Option<Sha256Hash>, } +/// NIP-98 tags +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip98Tag { + /// `u` tag + AbsoluteURL(Url), + /// `method` tag + Method(HttpMethod), + /// `payload` tag + Payload(Sha256Hash), +} + +impl TagCodec for Nip98Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + ABSOLUTE_URL => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "absolute URL")?; + Ok(Self::AbsoluteURL(url)) + } + METHOD => { + let method: HttpMethod = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "method")?; + Ok(Self::Method(method)) + } + PAYLOAD => { + let payload: Sha256Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "payload")?; + Ok(Self::Payload(payload)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::AbsoluteURL(url) => Tag::new(vec![String::from(ABSOLUTE_URL), url.to_string()]), + Self::Method(method) => Tag::new(vec![String::from(METHOD), method.to_string()]), + Self::Payload(payload) => Tag::new(vec![String::from(PAYLOAD), payload.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip98Tag); + impl HttpData { /// New [`HttpData`] #[inline] @@ -272,13 +360,11 @@ impl From<HttpData> for Vec<Tag> { } = data; let mut tags: Vec<Tag> = vec![ - Tag::from_standardized_without_cell(TagStandard::AbsoluteURL(url)), - Tag::from_standardized_without_cell(TagStandard::Method(method)), + Nip98Tag::AbsoluteURL(url).to_tag(), + Nip98Tag::Method(method).to_tag(), ]; if let Some(payload) = payload { - tags.push(Tag::from_standardized_without_cell(TagStandard::Payload( - payload, - ))); + tags.push(Nip98Tag::Payload(payload).to_tag()); } tags @@ -289,33 +375,22 @@ impl TryFrom<Vec<Tag>> for HttpData { type Error = Error; fn try_from(value: Vec<Tag>) -> Result<Self, Self::Error> { - let url = value - .iter() - .find_map(|t| match t.as_standardized() { - Some(TagStandard::AbsoluteURL(u)) => Some(u), - _ => None, - }) - .cloned() - .ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?; - let method = value - .iter() - .find_map(|t| match t.as_standardized() { - Some(TagStandard::Method(m)) => Some(m), - _ => None, - }) - .cloned() - .ok_or(Error::MissingTag(RequiredTags::Method))?; - let payload = value - .iter() - .find_map(|t| match t.as_standardized() { - Some(TagStandard::Payload(p)) => Some(p), - _ => None, - }) - .cloned(); + let mut url: Option<Url> = None; + let mut method: Option<HttpMethod> = None; + let mut payload: Option<Sha256Hash> = None; + + for tag in value.into_iter() { + match Nip98Tag::try_from(tag) { + Ok(Nip98Tag::AbsoluteURL(value)) => url = Some(value), + Ok(Nip98Tag::Method(value)) => method = Some(value), + Ok(Nip98Tag::Payload(value)) => payload = Some(value), + Err(_) => (), + } + } Ok(Self { - url, - method, + url: url.ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?, + method: method.ok_or(Error::MissingTag(RequiredTags::Method))?, payload, }) } @@ -365,28 +440,14 @@ pub fn verify_auth_header( return Err(Error::WrongAuthHeaderKind); } - let authorized_url: &Url = event - .tags - .find_standardized(TagKind::u()) - .and_then(|tag| match tag { - TagStandard::AbsoluteURL(u) => Some(u), - _ => None, - }) - .ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?; - - let authorized_method: &HttpMethod = event - .tags - .find_standardized(TagKind::Method) - .and_then(|tag| match tag { - TagStandard::Method(u) => Some(u), - _ => None, - }) - .ok_or(Error::MissingTag(RequiredTags::Method))?; + let http_data = HttpData::try_from(event.tags.iter().cloned().collect::<Vec<Tag>>())?; + let authorized_url: Url = http_data.url; + let authorized_method: HttpMethod = http_data.method; - if authorized_url != url || authorized_method != &method { + if &authorized_url != url || authorized_method != method { return Err(Error::AuthorizationNotMatchRequest { authorized_url: Box::new(authorized_url.clone()), - authorized_method: *authorized_method, + authorized_method, request_url: Box::new(url.clone()), request_method: method, }); @@ -404,16 +465,16 @@ pub fn verify_auth_header( if let Some(body_data) = body { // Get payload hash - let payload: &Sha256Hash = match event.tags.find_standardized(TagKind::Payload) { - Some(TagStandard::Payload(p)) => p, - _ => return Err(Error::MissingTag(RequiredTags::Payload)), + let payload: Sha256Hash = match http_data.payload { + Some(p) => p, + None => return Err(Error::MissingTag(RequiredTags::Payload)), }; // Hash body data let body_hash: Sha256Hash = Sha256Hash::hash(body_data); // Check if payload and body hash matches - if payload != &body_hash { + if payload != body_hash { return Err(Error::PayloadHashMismatch); } } @@ -453,6 +514,52 @@ impl TimeDelta { mod tests { use super::*; + #[test] + fn test_nip98_tag_codec() { + let url = Nip98Tag::parse(["u", "https://example.com/"]).unwrap(); + assert_eq!( + url, + Nip98Tag::AbsoluteURL(Url::parse("https://example.com/").unwrap()) + ); + assert_eq!( + url.to_tag(), + Tag::parse(["u", "https://example.com/"]).unwrap() + ); + + let method = Nip98Tag::parse(["method", "GET"]).unwrap(); + assert_eq!(method, Nip98Tag::Method(HttpMethod::GET)); + assert_eq!(method.to_tag(), Tag::parse(["method", "GET"]).unwrap()); + + let payload_hash = Sha256Hash::from_str( + "12f8ff0f5f6f023a4ae796a5f5f6d9030434bf2b9bb7a2f4f0f0f971b3e5d79f", + ) + .unwrap(); + let payload = Nip98Tag::parse([ + "payload", + "12f8ff0f5f6f023a4ae796a5f5f6d9030434bf2b9bb7a2f4f0f0f971b3e5d79f", + ]) + .unwrap(); + assert_eq!(payload, Nip98Tag::Payload(payload_hash)); + } + + #[test] + fn test_nip98_http_data_round_trip() { + let payload = Sha256Hash::from_str( + "12f8ff0f5f6f023a4ae796a5f5f6d9030434bf2b9bb7a2f4f0f0f971b3e5d79f", + ) + .unwrap(); + let data = HttpData::new( + Url::parse("https://example.com/").unwrap(), + HttpMethod::POST, + ) + .payload(payload); + + let tags: Vec<Tag> = data.clone().into(); + let parsed = HttpData::try_from(tags).unwrap(); + + assert_eq!(parsed, data); + } + #[test] fn empty_auth_header() { let url = Url::parse("https://example.com/").unwrap(); diff --git a/crates/nostr/src/nips/nipb0.rs b/crates/nostr/src/nips/nipb0.rs index 8699f86bb..753cd4fd1 100644 --- a/crates/nostr/src/nips/nipb0.rs +++ b/crates/nostr/src/nips/nipb0.rs @@ -2,14 +2,111 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIPB0: Web Bookmarks +//! NIP-B0: Web Bookmarks //! //! <https://github.com/nostr-protocol/nips/blob/master/B0.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; -use crate::{EventBuilder, Kind, Tag, TagStandard, Timestamp}; +use super::util::{take_string, take_timestamp}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventBuilder, Kind, Timestamp}; + +const URL: &str = "d"; +const PUBLISHED_AT: &str = "published_at"; +const TITLE: &str = "title"; +const HASHTAG: &str = "t"; + +/// NIP-B0 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-B0 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/B0.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NipB0Tag { + /// `d` tag containing the bookmarked URL without scheme + Url(String), + /// `published_at` tag + PublishedAt(Timestamp), + /// `title` tag + Title(String), + /// `t` tag + Hashtag(String), +} + +impl TagCodec for NipB0Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + URL => Ok(Self::Url(take_string(&mut iter, "URL")?)), + PUBLISHED_AT => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::PublishedAt(timestamp)) + } + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + HASHTAG => Ok(Self::Hashtag( + take_string(&mut iter, "hashtag")?.to_lowercase(), + )), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Url(url) => Tag::new(vec![String::from(URL), url.clone()]), + Self::PublishedAt(timestamp) => { + Tag::new(vec![String::from(PUBLISHED_AT), timestamp.to_string()]) + } + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + Self::Hashtag(hashtag) => Tag::new(vec![String::from(HASHTAG), hashtag.to_lowercase()]), + } + } +} + +impl_tag_codec_conversions!(NipB0Tag); /// Web Bookmark #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -74,21 +171,61 @@ impl WebBookmark { /// Convert the web bookmark to an event builder #[allow(clippy::wrong_self_convention)] pub(crate) fn to_event_builder(self) -> EventBuilder { - let mut tags: Vec<Tag> = vec![TagStandard::Identifier(self.url).into()]; + let mut tags: Vec<Tag> = vec![NipB0Tag::Url(self.url).into()]; - let mut add_if_some = |tag: Option<TagStandard>| { + let mut add_if_some = |tag: Option<NipB0Tag>| { if let Some(tag) = tag { tags.push(tag.into()); } }; - add_if_some(self.published_at.map(TagStandard::PublishedAt)); - add_if_some(self.title.map(TagStandard::Title)); + add_if_some(self.published_at.map(NipB0Tag::PublishedAt)); + add_if_some(self.title.map(NipB0Tag::Title)); for hashtag in self.hashtags.into_iter() { - tags.push(TagStandard::Hashtag(hashtag).into()); + tags.push(NipB0Tag::Hashtag(hashtag).into()); } EventBuilder::new(Kind::WebBookmark, self.description).tags(tags) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_url_tag() { + let tag = vec!["d", "alice.blog/post"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipB0Tag::Url(String::from("alice.blog/post"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_published_at_tag() { + let tag = vec!["published_at", "1738863000"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipB0Tag::PublishedAt(Timestamp::from(1738863000))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_title_tag() { + let tag = vec!["title", "Blog insights by Alice"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + NipB0Tag::Title(String::from("Blog insights by Alice")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_hashtag_tag() { + let tag = vec!["t", "Insight"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipB0Tag::Hashtag(String::from("insight"))); + assert_eq!(parsed.to_tag(), Tag::parse(vec!["t", "insight"]).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nipb7.rs b/crates/nostr/src/nips/nipb7.rs new file mode 100644 index 000000000..1f88b9544 --- /dev/null +++ b/crates/nostr/src/nips/nipb7.rs @@ -0,0 +1,107 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-B7: Blossom media +//! +//! <https://github.com/nostr-protocol/nips/blob/master/B7.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use core::fmt; + +use super::util::take_and_parse_from_str; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; + +const SERVER: &str = "server"; + +/// NIP-B7 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-B7 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/B7.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NipB7Tag { + /// `server` tag + Server(Url), +} + +impl TagCodec for NipB7Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + SERVER => { + let server_url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "server URL")?; + Ok(Self::Server(server_url)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Server(server_url) => { + Tag::new(vec![String::from(SERVER), server_url.to_string()]) + } + } + } +} + +impl_tag_codec_conversions!(NipB7Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_tag() { + let tag = vec!["server", "https://blossom.example.com/"]; + let parsed = NipB7Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + NipB7Tag::Server(Url::parse("https://blossom.example.com").unwrap()) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/nipc0.rs b/crates/nostr/src/nips/nipc0.rs index e99bd9417..2ce1f52e8 100644 --- a/crates/nostr/src/nips/nipc0.rs +++ b/crates/nostr/src/nips/nipc0.rs @@ -2,14 +2,126 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIPC0: Code Snippets +//! NIP-C0: Code Snippets //! //! <https://github.com/nostr-protocol/nips/blob/master/C0.md> use alloc::string::String; +use alloc::vec; use alloc::vec::Vec; +use core::fmt; -use crate::{EventBuilder, Kind, Tag, TagStandard}; +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventBuilder, Kind}; + +const LANGUAGE: &str = "l"; +const NAME: &str = "name"; +const EXTENSION: &str = "extension"; +const DESCRIPTION: &str = "description"; +const RUNTIME: &str = "runtime"; +const LICENSE: &str = "license"; +const DEPENDENCY: &str = "dep"; +const REPOSITORY: &str = "repo"; + +/// NIP-C0 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(err) => err.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(err: TagCodecError) -> Self { + Self::Codec(err) + } +} + +/// Standardized NIP-C0 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/C0.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NipC0Tag { + /// `l` tag used as programming language + Language(String), + /// `name` tag + Name(String), + /// `extension` tag + Extension(String), + /// `description` tag + Description(String), + /// `runtime` tag + Runtime(String), + /// `license` tag + License(String), + /// `dep` tag + Dependency(String), + /// `repo` tag + Repository(String), +} + +impl TagCodec for NipC0Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + LANGUAGE => Ok(Self::Language( + take_string(&mut iter, "language")?.to_lowercase(), + )), + NAME => Ok(Self::Name(take_string(&mut iter, "name")?)), + EXTENSION => Ok(Self::Extension(take_string(&mut iter, "extension")?)), + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + RUNTIME => Ok(Self::Runtime(take_string(&mut iter, "runtime")?)), + LICENSE => Ok(Self::License(take_string(&mut iter, "license")?)), + DEPENDENCY => Ok(Self::Dependency(take_string(&mut iter, "dependency")?)), + REPOSITORY => Ok(Self::Repository(take_string(&mut iter, "repository")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Language(language) => { + Tag::new(vec![String::from(LANGUAGE), language.to_lowercase()]) + } + Self::Name(name) => Tag::new(vec![String::from(NAME), name.clone()]), + Self::Extension(extension) => { + Tag::new(vec![String::from(EXTENSION), extension.clone()]) + } + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::Runtime(runtime) => Tag::new(vec![String::from(RUNTIME), runtime.clone()]), + Self::License(license) => Tag::new(vec![String::from(LICENSE), license.clone()]), + Self::Dependency(dependency) => { + Tag::new(vec![String::from(DEPENDENCY), dependency.clone()]) + } + Self::Repository(repository) => { + Tag::new(vec![String::from(REPOSITORY), repository.clone()]) + } + } + } +} + +impl_tag_codec_conversions!(NipC0Tag); /// Code snippet #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -139,29 +251,99 @@ impl CodeSnippet { pub(crate) fn to_event_builder(self) -> EventBuilder { let mut tags: Vec<Tag> = Vec::new(); - let mut add_if_some = |tag: Option<TagStandard>| { + let mut add_if_some = |tag: Option<NipC0Tag>| { if let Some(tag) = tag { - tags.push(Tag::from_standardized_without_cell(tag)); + tags.push(tag.into()); } }; - // `l` tag used for label in all event kinds except Code Snippets (1337) - // is used as the programming language - add_if_some(self.language.map(|l| TagStandard::Label { - value: l, - namespace: None, - })); - add_if_some(self.name.map(TagStandard::Name)); - add_if_some(self.extension.map(TagStandard::Extension)); - add_if_some(self.description.map(TagStandard::Description)); - add_if_some(self.runtime.map(TagStandard::Runtime)); - add_if_some(self.license.map(TagStandard::License)); - add_if_some(self.repo.map(TagStandard::Repository)); + add_if_some(self.language.map(NipC0Tag::Language)); + add_if_some(self.name.map(NipC0Tag::Name)); + add_if_some(self.extension.map(NipC0Tag::Extension)); + add_if_some(self.description.map(NipC0Tag::Description)); + add_if_some(self.runtime.map(NipC0Tag::Runtime)); + add_if_some(self.license.map(NipC0Tag::License)); + add_if_some(self.repo.map(NipC0Tag::Repository)); for dep in self.dependencies.into_iter() { - tags.push(TagStandard::Dependency(dep).into()); + tags.push(NipC0Tag::Dependency(dep).into()); } EventBuilder::new(Kind::CodeSnippet, self.snippet).tags(tags) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_language_tag() { + let tag = vec!["l", "Rust"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Language(String::from("rust"))); + assert_eq!(parsed.to_tag(), Tag::parse(vec!["l", "rust"]).unwrap()); + } + + #[test] + fn test_parse_name_tag() { + let tag = vec!["name", "hello-world.rs"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Name(String::from("hello-world.rs"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_extension_tag() { + let tag = vec!["extension", "rs"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Extension(String::from("rs"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_description_tag() { + let tag = vec!["description", "Prints Hello, Nostr!"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + NipC0Tag::Description(String::from("Prints Hello, Nostr!")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_runtime_tag() { + let tag = vec!["runtime", "rustc 1.70.0"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Runtime(String::from("rustc 1.70.0"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_license_tag() { + let tag = vec!["license", "MIT"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::License(String::from("MIT"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_dependency_tag() { + let tag = vec!["dep", "serde"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Dependency(String::from("serde"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_repository_tag() { + let tag = vec!["repo", "https://github.com/nostr-protocol/nostr"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + NipC0Tag::Repository(String::from("https://github.com/nostr-protocol/nostr")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/nips/util.rs b/crates/nostr/src/nips/util.rs new file mode 100644 index 000000000..92e3943ed --- /dev/null +++ b/crates/nostr/src/nips/util.rs @@ -0,0 +1,179 @@ +use alloc::string::{String, ToString}; +use core::num::ParseIntError; +use core::str::FromStr; + +use super::nip01::{self, Coordinate}; +use crate::event::tag::TagCodecError; +use crate::event::{self, EventId}; +use crate::key::{self, PublicKey}; +use crate::types::time::Timestamp; +use crate::types::url::{self, RelayUrl}; + +#[inline] +pub(super) fn take_and_parse_optional_public_key<I, S>( + iter: &mut I, +) -> Result<Option<PublicKey>, key::Error> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + take_and_parse_optional(iter, PublicKey::from_hex) +} + +#[inline] +pub(super) fn take_and_parse_optional_coordinate<I, S>( + iter: &mut I, +) -> Result<Option<Coordinate>, nip01::Error> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + take_and_parse_optional(iter, Coordinate::from_kpi_format) +} + +#[inline] +pub(super) fn take_and_parse_optional_relay_url<I, S>( + iter: &mut I, +) -> Result<Option<RelayUrl>, url::Error> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + take_and_parse_optional(iter, RelayUrl::parse) +} + +#[inline] +pub(super) fn take_and_parse_optional_from_str<I, S, T>(iter: &mut I) -> Result<Option<T>, T::Err> +where + I: Iterator<Item = S>, + S: AsRef<str>, + T: FromStr, +{ + take_and_parse_optional(iter, T::from_str) +} + +/// Take and parse an **optional** value with the provided parser. +/// +/// If the value is missing or empty, `None` is returned. +fn take_and_parse_optional<I, S, T, E>( + iter: &mut I, + parse: impl FnOnce(&str) -> Result<T, E>, +) -> Result<Option<T>, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + match iter.next() { + Some(value) => { + let value: &str = value.as_ref(); + + if value.is_empty() { + Ok(None) + } else { + // NOTE: we don't use FromStr::from_str here because some implementations, like PublicKey::from_str, support parsing both of hex and also bech32 or URIs, but tags must use just hex. + parse(value).map(Some) + } + } + None => Ok(None), + } +} + +/// Take an **optional** string +/// +/// If the value is empty, None is returned. +pub(super) fn take_optional_string<I, S>(iter: &mut I) -> Option<String> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + iter.next().and_then(|value| { + let value: &str = value.as_ref(); + + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + }) +} + +/// Take a string +pub(super) fn take_string<I, S>( + iter: &mut I, + missing_error: &'static str, +) -> Result<String, TagCodecError> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + let value: S = iter.next().ok_or(TagCodecError::Missing(missing_error))?; + Ok(value.as_ref().to_string()) +} + +pub(super) fn take_public_key<I, S, E>(iter: &mut I) -> Result<PublicKey, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, + E: From<key::Error> + From<TagCodecError>, +{ + let public_key: S = iter.next().ok_or(TagCodecError::Missing("public key"))?; + let public_key: PublicKey = PublicKey::from_hex(public_key.as_ref())?; + Ok(public_key) +} + +pub(super) fn take_coordinate<I, S, E>(iter: &mut I) -> Result<Coordinate, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, + E: From<nip01::Error> + From<TagCodecError>, +{ + let coordinate: S = iter.next().ok_or(TagCodecError::Missing("coordinate"))?; + let coordinate: Coordinate = Coordinate::from_kpi_format(coordinate.as_ref())?; + Ok(coordinate) +} + +pub(super) fn take_event_id<I, S, E>(iter: &mut I) -> Result<EventId, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, + E: From<event::Error> + From<TagCodecError>, +{ + let event_id: S = iter.next().ok_or(TagCodecError::Missing("event ID"))?; + let event_id: EventId = EventId::from_hex(event_id.as_ref())?; + Ok(event_id) +} + +#[inline] +pub(super) fn take_relay_url<T, S, E>(iter: &mut T) -> Result<RelayUrl, E> +where + T: Iterator<Item = S>, + S: AsRef<str>, + E: From<url::Error> + From<TagCodecError>, +{ + take_and_parse_from_str(iter, "relay URL") +} + +#[inline] +pub(super) fn take_timestamp<T, S, E>(iter: &mut T) -> Result<Timestamp, E> +where + T: Iterator<Item = S>, + S: AsRef<str>, + E: From<ParseIntError> + From<TagCodecError>, +{ + take_and_parse_from_str(iter, "timestamp") +} + +pub(super) fn take_and_parse_from_str<O, T, S, E>( + iter: &mut T, + missing_error: &'static str, +) -> Result<O, E> +where + T: Iterator<Item = S>, + S: AsRef<str>, + O: FromStr, + E: From<O::Err> + From<TagCodecError>, +{ + let value: S = iter.next().ok_or(TagCodecError::Missing(missing_error))?; + let value: O = O::from_str(value.as_ref())?; + Ok(value) +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 9632582fd..8b4982415 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -35,20 +35,27 @@ pub use crate::nips::nip04::{self, *}; pub use crate::nips::nip05::{self, *}; #[cfg(feature = "nip06")] pub use crate::nips::nip06::{self, *}; +pub use crate::nips::nip7d::{self, *}; pub use crate::nips::nip09::{self, *}; pub use crate::nips::nip10::{self, *}; pub use crate::nips::nip11::{self, *}; pub use crate::nips::nip13::{self, *}; pub use crate::nips::nip15::{self, *}; pub use crate::nips::nip17::{self, *}; +pub use crate::nips::nip18::{self, *}; pub use crate::nips::nip19::{self, *}; pub use crate::nips::nip21::{self, *}; pub use crate::nips::nip22::{self, *}; +pub use crate::nips::nip23::{self, *}; pub use crate::nips::nip25::{self, *}; +pub use crate::nips::nip30::{self, *}; +pub use crate::nips::nip31::{self, *}; +pub use crate::nips::nip32::{self, *}; pub use crate::nips::nip34::{self, *}; pub use crate::nips::nip35::{self, *}; pub use crate::nips::nip38::{self, *}; pub use crate::nips::nip39::{self, *}; +pub use crate::nips::nip40::{self, *}; pub use crate::nips::nip42::{self, *}; pub use crate::nips::nip44::{self, *}; #[cfg(all(feature = "std", feature = "nip46"))] @@ -70,13 +77,16 @@ pub use crate::nips::nip59::{self, *}; pub use crate::nips::nip60::{self, *}; pub use crate::nips::nip62::{self, *}; pub use crate::nips::nip65::{self, *}; +pub use crate::nips::nip70::{self, *}; pub use crate::nips::nip73::{self, *}; pub use crate::nips::nip88::{self, *}; +pub use crate::nips::nip89::{self, *}; pub use crate::nips::nip90::{self, *}; pub use crate::nips::nip94::{self, *}; #[cfg(feature = "nip98")] pub use crate::nips::nip98::{self, *}; pub use crate::nips::nipb0::{self, *}; +pub use crate::nips::nipb7::{self, *}; pub use crate::nips::nipc0::{self, *}; pub use crate::parser::{self, *}; pub use crate::signer::{self, *}; diff --git a/database/nostr-database-test-suite/src/lib.rs b/database/nostr-database-test-suite/src/lib.rs index b7197fba5..f97b36bdf 100644 --- a/database/nostr-database-test-suite/src/lib.rs +++ b/database/nostr-database-test-suite/src/lib.rs @@ -401,7 +401,7 @@ macro_rules! database_unit_tests { // Create deletion event let req = EventDeletionRequest::new() - .coordinate(event.coordinate().unwrap().into_owned()); + .coordinate(event.coordinate().unwrap()); let deletion = EventBuilder::delete(req) .sign_with_keys(&keys) @@ -852,7 +852,7 @@ macro_rules! database_unit_tests { assert_eq!(event1.created_at, event2.created_at); assert_eq!(event1.created_at.as_secs(), 1754066538); assert_eq!(event1.tags.identifier(), event2.tags.identifier()); - assert_eq!(event1.tags.identifier(), Some("article-123")); + assert_eq!(event1.tags.identifier(), Some(String::from("article-123"))); // Confirm event1 has the smaller ID (lexicographically first) assert!(event1.id.to_string() < event2.id.to_string()); diff --git a/database/nostr-database/src/ext.rs b/database/nostr-database/src/ext.rs index d5a606493..9c0bad518 100644 --- a/database/nostr-database/src/ext.rs +++ b/database/nostr-database/src/ext.rs @@ -44,7 +44,7 @@ pub trait NostrDatabaseExt: NostrDatabase { .limit(1); let events: Events = self.query(filter).await?; match events.first_owned() { - Some(event) => Ok(event.tags.public_keys().copied().collect()), + Some(event) => Ok(event.tags.public_keys().collect()), None => Ok(HashSet::new()), } }) @@ -65,7 +65,7 @@ pub trait NostrDatabaseExt: NostrDatabase { Some(event) => { // Get contacts metadata let filter = Filter::new() - .authors(event.tags.public_keys().copied()) + .authors(event.tags.public_keys()) .kind(Kind::Metadata); let mut contacts: HashSet<Profile> = self .query(filter) @@ -79,7 +79,7 @@ pub trait NostrDatabaseExt: NostrDatabase { .collect(); // Extend with missing public keys - contacts.extend(event.tags.public_keys().copied().map(Profile::from)); + contacts.extend(event.tags.public_keys().map(Profile::from)); Ok(contacts.into_iter().collect()) } @@ -104,8 +104,8 @@ pub trait NostrDatabaseExt: NostrDatabase { let events: Events = self.query(filter).await?; // Extract relay list (NIP65) - match events.first_owned() { - Some(event) => Ok(nip65::extract_owned_relay_list(event).collect()), + match events.first() { + Some(event) => Ok(nip65::extract_relay_list(event).collect()), None => Ok(HashMap::new()), } }) @@ -128,11 +128,8 @@ pub trait NostrDatabaseExt: NostrDatabase { let mut map = HashMap::with_capacity(events.len()); - for event in events.into_iter() { - map.insert( - event.pubkey, - nip65::extract_owned_relay_list(event).collect(), - ); + for event in events.iter() { + map.insert(event.pubkey, nip65::extract_relay_list(event).collect()); } Ok(map) diff --git a/database/nostr-lmdb/src/store/lmdb/index.rs b/database/nostr-lmdb/src/store/lmdb/index.rs index a75c0ffaa..c9ca2565a 100644 --- a/database/nostr-lmdb/src/store/lmdb/index.rs +++ b/database/nostr-lmdb/src/store/lmdb/index.rs @@ -6,7 +6,7 @@ use core::cmp; use nostr::event::borrow::EventBorrow; -use nostr::nips::nip01::CoordinateBorrow; +use nostr::nips::nip01::Coordinate; use nostr::{EventId, PublicKey, SingleLetterTag, Timestamp}; const CREATED_AT_BE: usize = 8; @@ -262,12 +262,12 @@ pub fn make_ktc_index_key( /// ## Structure /// /// `kind(2)` + `author(32)` + `d_len(1)` + `d(182)` -pub fn make_coordinate_index_key(coordinate: &CoordinateBorrow<'_>) -> Vec<u8> { +pub fn make_coordinate_index_key(coordinate: &Coordinate) -> Vec<u8> { let mut key: Vec<u8> = Vec::with_capacity(KIND_BE + PublicKey::LEN + 1 + TAG_VALUE_PAD_LEN); key.extend(coordinate.kind.as_u16().to_be_bytes()); key.extend(coordinate.public_key.to_bytes()); - let identifier: &str = coordinate.identifier.unwrap_or_default(); + let identifier: &str = &coordinate.identifier; let dlen: usize = cmp::min(identifier.len(), TAG_VALUE_PAD_LEN); key.push(dlen as u8); diff --git a/database/nostr-lmdb/src/store/lmdb/mod.rs b/database/nostr-lmdb/src/store/lmdb/mod.rs index 8662e49ca..b71db3da8 100644 --- a/database/nostr-lmdb/src/store/lmdb/mod.rs +++ b/database/nostr-lmdb/src/store/lmdb/mod.rs @@ -505,15 +505,14 @@ impl Lmdb { } // Handle request to vanish - if self.options.process_nip62 && event.kind == Kind::RequestToVanish { - let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { - matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(relay) if Some(relay) == self.options.relay_url.as_ref()) - }); - - if is_targeted { - self.handle_request_to_vanish(txn, &event.pubkey)?; - } + if self.options.process_nip62 + && event.kind == Kind::RequestToVanish + && nip62::is_valid_vanish_request_for_relay( + event.tags.as_slice(), + self.options.relay_url.as_ref(), + ) + { + self.handle_request_to_vanish(txn, &event.pubkey)?; } self.store(txn, fbb, event)?; @@ -1166,7 +1165,7 @@ impl Lmdb { pub(crate) fn mark_coordinate_deleted( &self, txn: &mut RwTxn, - coordinate: &CoordinateBorrow, + coordinate: &Coordinate, when: Timestamp, ) -> Result<(), Error> { let key: Vec<u8> = index::make_coordinate_index_key(coordinate); @@ -1174,10 +1173,10 @@ impl Lmdb { Ok(()) } - pub(crate) fn when_is_coordinate_deleted<'a>( + pub(crate) fn when_is_coordinate_deleted( &self, txn: &RoTxn, - coordinate: &'a CoordinateBorrow<'a>, + coordinate: &Coordinate, ) -> Result<Option<Timestamp>, Error> { let key: Vec<u8> = index::make_coordinate_index_key(coordinate); Ok(self @@ -1338,7 +1337,7 @@ impl Lmdb { return Ok(true); } - deletions_to_process.push((*id, EventIndexKeys::new(target))); + deletions_to_process.push((id, EventIndexKeys::new(target))); } } @@ -1358,13 +1357,13 @@ impl Lmdb { } // Mark deleted - self.mark_coordinate_deleted(txn, &coordinate.borrow(), event.created_at)?; + self.mark_coordinate_deleted(txn, &coordinate, event.created_at)?; // Remove events (up to the created_at of the deletion event) if coordinate.kind.is_replaceable() { - self.remove_replaceable(txn, coordinate, event.created_at)?; + self.remove_replaceable(txn, &coordinate, event.created_at)?; } else if coordinate.kind.is_addressable() { - self.remove_addressable(txn, coordinate, event.created_at)?; + self.remove_addressable(txn, &coordinate, event.created_at)?; } } diff --git a/database/nostr-memory/src/store.rs b/database/nostr-memory/src/store.rs index 55819af53..f915ca441 100644 --- a/database/nostr-memory/src/store.rs +++ b/database/nostr-memory/src/store.rs @@ -175,7 +175,7 @@ impl MemoryStore { self.vanished_public_keys.contains(pubkey) } - fn internal_index_event(&mut self, event: &Event, now: &Timestamp) -> SaveEventStatus { + fn internal_index_event(&mut self, event: &Event, now: Timestamp) -> SaveEventStatus { // Check if was already added if self.ids.contains_key(&event.id) { return SaveEventStatus::Rejected(RejectedReason::Duplicate); @@ -193,7 +193,6 @@ impl MemoryStore { // Reject event if ADDR was deleted after it's created_at date // (non-parameterized or parameterized) if let Some(coordinate) = event.coordinate() { - let coordinate: Coordinate = coordinate.into_owned(); if let Some(time) = self.deleted_coordinates.get(&coordinate) { if &event.created_at <= time { return SaveEventStatus::Rejected(RejectedReason::Deleted); @@ -227,14 +226,14 @@ impl MemoryStore { match event.tags.identifier() { Some(identifier) => { let coordinate: Coordinate = - Coordinate::new(kind, author).identifier(identifier); + Coordinate::new(kind, author).identifier(identifier.clone()); // Check if coordinate was deleted if self.has_coordinate_been_deleted(&coordinate, &event.created_at) { status = SaveEventStatus::Rejected(RejectedReason::Deleted); } else { let params: QueryByParamReplaceable = - QueryByParamReplaceable::new(kind, author, identifier.to_string()); + QueryByParamReplaceable::new(kind, author, identifier); if let Some(ev) = self.internal_query_param_replaceable(params) { if has_event_been_replaced(ev, event) || ev.id == event.id { status = SaveEventStatus::Rejected(RejectedReason::Replaced); @@ -249,7 +248,7 @@ impl MemoryStore { } else if self.options.process_nip09 && kind == Kind::EventDeletion { // Check `e` tags for id in event.tags.event_ids() { - if let Some(ev) = self.ids.get(id) { + if let Some(ev) = self.ids.get(&id) { if ev.pubkey != author { status = SaveEventStatus::Rejected(RejectedReason::InvalidDelete); break; @@ -299,15 +298,14 @@ impl MemoryStore { to_discard.extend(self.internal_query_by_kind_and_author(params).map(|e| e.id)); } } - } else if self.options.process_nip62 && event.kind == Kind::RequestToVanish { - let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { - matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(relay) if Some(relay) == self.options.relay_url.as_ref()) - }); - - if is_targeted { - self.handle_request_to_vanish(&event.pubkey); - } + } else if self.options.process_nip62 + && event.kind == Kind::RequestToVanish + && nip62::is_valid_vanish_request_for_relay( + event.tags.as_slice(), + self.options.relay_url.as_ref(), + ) + { + self.handle_request_to_vanish(&event.pubkey); } // Remove events @@ -419,7 +417,7 @@ impl MemoryStore { return SaveEventStatus::Rejected(RejectedReason::Ephemeral); } let now = Timestamp::now(); - self.internal_index_event(event, &now) + self.internal_index_event(event, now) } /// Query by public key diff --git a/database/nostr-sqlite/src/store.rs b/database/nostr-sqlite/src/store.rs index 0d91353f9..d4889e5b7 100644 --- a/database/nostr-sqlite/src/store.rs +++ b/database/nostr-sqlite/src/store.rs @@ -142,17 +142,17 @@ impl NostrSqlite { fn handle_deletion_event(tx: &Transaction<'_>, event: &Event) -> Result<bool, Error> { for id in event.tags.event_ids() { - if let Some(pubkey) = Self::get_pubkey_of_event_by_id(tx, id)? { + if let Some(pubkey) = Self::get_pubkey_of_event_by_id(tx, &id)? { // Author must match if pubkey != event.pubkey { return Ok(true); } // Mark the event ID as deleted (for NIP-09 deletion events) - Self::mark_event_as_deleted(tx, id)?; + Self::mark_event_as_deleted(tx, &id)?; // Remove event from store - Self::remove_event(tx, id)?; + Self::remove_event(tx, &id)?; } } @@ -163,13 +163,13 @@ impl NostrSqlite { } // Mark deleted - Self::mark_coordinate_deleted(tx, &coordinate.borrow(), event.created_at)?; + Self::mark_coordinate_deleted(tx, &coordinate, event.created_at)?; // Remove events (up to the created_at of the deletion event) if coordinate.kind.is_replaceable() { - Self::remove_replaceable(tx, coordinate, &event.created_at)?; + Self::remove_replaceable(tx, &coordinate, &event.created_at)?; } else if coordinate.kind.is_addressable() { - Self::remove_addressable(tx, coordinate, event.created_at)?; + Self::remove_addressable(tx, &coordinate, event.created_at)?; } } @@ -279,15 +279,14 @@ impl NostrSqlite { } } - if options.process_nip62 && event.kind == Kind::RequestToVanish { - let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { - matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(relay) if Some(relay) == options.relay_url.as_ref()) - }); - - if is_targeted { - Self::handle_request_to_vanish(&tx, &event.pubkey)?; - } + if options.process_nip62 + && event.kind == Kind::RequestToVanish + && nip62::is_valid_vanish_request_for_relay( + event.tags.as_slice(), + options.relay_url.as_ref(), + ) + { + Self::handle_request_to_vanish(&tx, &event.pubkey)?; } // Insert event first @@ -324,7 +323,7 @@ impl NostrSqlite { fn mark_coordinate_deleted( tx: &Transaction<'_>, - coordinate: &CoordinateBorrow<'_>, + coordinate: &Coordinate, deleted_at: Timestamp, ) -> Result<(), Error> { tx.execute( @@ -332,7 +331,7 @@ impl NostrSqlite { params![ coordinate.public_key.as_bytes().as_slice(), coordinate.kind.as_u16() as i64, - coordinate.identifier.unwrap_or_default(), + &coordinate.identifier, deleted_at.as_secs() as i64 ], )?; @@ -350,7 +349,7 @@ impl NostrSqlite { fn when_is_coordinate_deleted( tx: &Transaction<'_>, - coordinate: &CoordinateBorrow<'_>, + coordinate: &Coordinate, ) -> Result<Option<Timestamp>, Error> { let timestamp: Option<i64> = tx .query_row( @@ -358,7 +357,7 @@ impl NostrSqlite { params![ coordinate.public_key.as_bytes().as_slice(), coordinate.kind.as_u16() as i64, - coordinate.identifier.unwrap_or_default() + &coordinate.identifier ], |row| row.get(0), ) diff --git a/gossip/nostr-gossip-memory/src/store.rs b/gossip/nostr-gossip-memory/src/store.rs index 27729d909..367949645 100644 --- a/gossip/nostr-gossip-memory/src/store.rs +++ b/gossip/nostr-gossip-memory/src/store.rs @@ -6,10 +6,11 @@ use std::num::NonZeroUsize; use indexmap::IndexMap; use lru::LruCache; +use nostr::nips::nip01::Nip01Tag; use nostr::nips::nip17; use nostr::nips::nip65::{self, RelayMetadata}; use nostr::util::BoxedFuture; -use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, TagStandard, Timestamp}; +use nostr::{Event, Kind, PublicKey, RelayUrl, Timestamp}; use nostr_gossip::error::GossipError; use nostr_gossip::flags::GossipFlags; use nostr_gossip::{ @@ -89,7 +90,7 @@ impl NostrGossipMemory { let mut read_write_mask: GossipFlags = GossipFlags::READ; read_write_mask.add(GossipFlags::WRITE); - match pk_data.relays.get_mut(relay_url) { + match pk_data.relays.get_mut(&relay_url) { Some(relay_data) => { // Update the bitflag: remove the previous READ and WRITE values and apply the new bitflag (preserves any other flag) relay_data.bitflags.remove(read_write_mask); @@ -110,7 +111,7 @@ impl NostrGossipMemory { public_keys.get_or_insert_mut(event.pubkey, PkData::default); for relay_url in nip17::extract_relay_list(event).take(MAX_NIP17_SIZE) { - match pk_data.relays.get_mut(relay_url) { + match pk_data.relays.get_mut(&relay_url) { Some(relay_data) => { relay_data.bitflags.add(GossipFlags::PRIVATE_MESSAGE); } @@ -118,23 +119,28 @@ impl NostrGossipMemory { let mut relay_data = PkRelayData::default(); relay_data.bitflags.add(GossipFlags::PRIVATE_MESSAGE); - pk_data.relays.insert(relay_url.clone(), relay_data); + pk_data.relays.insert(relay_url, relay_data); } } } } // Extract hints _ => { - for tag in event.tags.filter_standardized(TagKind::p()) { - if let TagStandard::PublicKey { + for tag in event.tags.iter().filter_map(|t| { + if t.kind() != "p" { + return None; + } + + Nip01Tag::try_from(t).ok() + }) { + if let Nip01Tag::PublicKey { public_key, - relay_url: Some(relay_url), - .. + relay_hint: Some(relay_hint), } = tag { let pk_data: &mut PkData = - public_keys.get_or_insert_mut(*public_key, PkData::default); - update_relay_per_user(pk_data, relay_url.clone(), GossipFlags::HINT); + public_keys.get_or_insert_mut(public_key, PkData::default); + update_relay_per_user(pk_data, relay_hint, GossipFlags::HINT); } } } diff --git a/gossip/nostr-gossip-sqlite/src/store.rs b/gossip/nostr-gossip-sqlite/src/store.rs index 06eec39e8..f11c21217 100644 --- a/gossip/nostr-gossip-sqlite/src/store.rs +++ b/gossip/nostr-gossip-sqlite/src/store.rs @@ -5,10 +5,11 @@ use std::collections::{BTreeSet, HashSet}; use std::num::NonZeroUsize; use std::path::Path; +use nostr::nips::nip01::Nip01Tag; use nostr::nips::nip17; use nostr::nips::nip65::{self, RelayMetadata}; use nostr::util::BoxedFuture; -use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, TagStandard, Timestamp}; +use nostr::{Event, Kind, PublicKey, RelayUrl, Timestamp}; use nostr_gossip::error::GossipError; use nostr_gossip::flags::GossipFlags; use nostr_gossip::{ @@ -490,13 +491,9 @@ fn update_relay_per_user( Ok(()) } -fn update_nip65_relays<'a, I>( - tx: &Transaction<'_>, - public_key_id: i32, - iter: I, -) -> Result<(), Error> +fn update_nip65_relays<I>(tx: &Transaction<'_>, public_key_id: i32, iter: I) -> Result<(), Error> where - I: IntoIterator<Item = (&'a RelayUrl, &'a Option<RelayMetadata>)>, + I: IntoIterator<Item = (RelayUrl, Option<RelayMetadata>)>, { // Remove all READ and WRITE flags from the relays of the public key remove_flag_from_user_relays(tx, public_key_id, READ_WRITE_FLAGS)?; @@ -504,7 +501,7 @@ where // Extract relay list for (relay_url, metadata) in iter { // Save relay and get ID - let relay_id: i32 = get_or_save_relay_url(tx, relay_url)?; + let relay_id: i32 = get_or_save_relay_url(tx, &relay_url)?; // New bitflag for the relay let bitflag: GossipFlags = match metadata { @@ -529,20 +526,16 @@ where Ok(()) } -fn update_nip17_relays<'a, I>( - tx: &Transaction<'_>, - public_key_id: i32, - iter: I, -) -> Result<(), Error> +fn update_nip17_relays<I>(tx: &Transaction<'_>, public_key_id: i32, iter: I) -> Result<(), Error> where - I: IntoIterator<Item = &'a RelayUrl>, + I: IntoIterator<Item = RelayUrl>, { // Remove all PRIVATE_MESSAGE flag from the relays of the public key remove_flag_from_user_relays(tx, public_key_id, GossipFlags::PRIVATE_MESSAGE)?; // Extract relay list for relay_url in iter { - let relay_id: i32 = get_or_save_relay_url(tx, relay_url)?; + let relay_id: i32 = get_or_save_relay_url(tx, &relay_url)?; tx.execute( r#" @@ -564,15 +557,20 @@ where } fn update_hints(tx: &Transaction<'_>, event: &Event) -> Result<(), Error> { - for tag in event.tags.filter_standardized(TagKind::p()) { - if let TagStandard::PublicKey { + for tag in event.tags.iter().filter_map(|t| { + if t.kind() != "p" { + return None; + } + + Nip01Tag::try_from(t).ok() + }) { + if let Nip01Tag::PublicKey { public_key, - relay_url: Some(relay_url), - .. + relay_hint: Some(relay_url), } = tag { - let p_tag_pk_id: i32 = get_or_save_public_key(tx, public_key)?; - update_relay_per_user(tx, p_tag_pk_id, relay_url, GossipFlags::HINT)?; + let p_tag_pk_id: i32 = get_or_save_public_key(tx, &public_key)?; + update_relay_per_user(tx, p_tag_pk_id, &relay_url, GossipFlags::HINT)?; } } diff --git a/gossip/nostr-gossip-test-suite/src/lib.rs b/gossip/nostr-gossip-test-suite/src/lib.rs index cc9e1450f..63f460a3d 100644 --- a/gossip/nostr-gossip-test-suite/src/lib.rs +++ b/gossip/nostr-gossip-test-suite/src/lib.rs @@ -95,12 +95,10 @@ macro_rules! gossip_unit_tests { let keys = Keys::generate(); let event = EventBuilder::text_note("test") - .tag(Tag::from_standardized_without_cell( - TagStandard::PublicKey { + .tag(Tag::from( + Nip01Tag::PublicKey { public_key, - relay_url: Some(relay_url.clone()), - alias: None, - uppercase: false, + relay_hint: Some(relay_url.clone()), }, )) .sign_with_keys(&keys) diff --git a/rfs/nostr-blossom/src/bud01.rs b/rfs/nostr-blossom/src/bud01.rs index cd0a46879..9be3dba7f 100644 --- a/rfs/nostr-blossom/src/bud01.rs +++ b/rfs/nostr-blossom/src/bud01.rs @@ -2,8 +2,10 @@ use std::fmt; +use nostr::event::tag::TagCodec; use nostr::hashes::sha256::Hash as Sha256Hash; -use nostr::{EventBuilder, Kind, Tag, TagStandard, Timestamp, Url}; +use nostr::nips::nipb7::NipB7Tag; +use nostr::{EventBuilder, Kind, Tag, Timestamp, Url}; /// Represents the authorization data for accessing a Blossom server. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -103,11 +105,13 @@ impl From<BlossomAuthorizationScope> for Vec<Tag> { match value { BlossomAuthorizationScope::BlobSha256Hashes(hashes) => { for hash in hashes.into_iter() { - tags.push(Tag::from_standardized(TagStandard::Sha256(hash))); + let tag = + Tag::parse(["x".to_string(), hash.to_string()]).expect("BUG: invalid tag"); + tags.push(tag); } } BlossomAuthorizationScope::ServerUrl(url) => { - tags.push(Tag::from_standardized(TagStandard::Server(url))); + tags.push(NipB7Tag::Server(url).to_tag()); } } tags diff --git a/sdk/src/client/api/send_event.rs b/sdk/src/client/api/send_event.rs index 7430a4eaf..ed94fe38d 100644 --- a/sdk/src/client/api/send_event.rs +++ b/sdk/src/client/api/send_event.rs @@ -251,7 +251,7 @@ async fn gossip_prepare_urls( }; // Get only p tags since the author of a gift wrap is randomized - let public_keys: BTreeSet<PublicKey> = event.tags.public_keys().copied().collect(); + let public_keys: BTreeSet<PublicKey> = event.tags.public_keys().collect(); (public_keys, &[kind]) } else if is_contact_list { @@ -262,7 +262,6 @@ async fn gossip_prepare_urls( let public_keys: BTreeSet<PublicKey> = event .tags .public_keys() - .copied() .chain(iter::once(event.pubkey)) .collect(); (public_keys, &[GossipListKind::Nip65]) diff --git a/sdk/src/client/gossip/resolver.rs b/sdk/src/client/gossip/resolver.rs index 21e9f529b..c038a6353 100644 --- a/sdk/src/client/gossip/resolver.rs +++ b/sdk/src/client/gossip/resolver.rs @@ -38,21 +38,21 @@ impl GossipRelayResolver { self.sync_counter.fetch_add(1, Ordering::SeqCst) } - pub(in crate::client) async fn get_relays<'a, I>( + pub(in crate::client) async fn get_relays<I>( &self, public_keys: I, selection: BestRelaySelection, allowed: GossipAllowedRelays, ) -> Result<HashSet<RelayUrl>, Error> where - I: IntoIterator<Item = &'a PublicKey>, + I: IntoIterator<Item = PublicKey>, { let mut urls: HashSet<RelayUrl> = HashSet::new(); for public_key in public_keys.into_iter() { let relays: HashSet<RelayUrl> = self .gossip - .get_best_relays(public_key, selection, allowed) + .get_best_relays(&public_key, selection, allowed) .await?; urls.extend(relays); } @@ -259,7 +259,7 @@ impl GossipRelayResolver { // Get map of outbox and inbox relays let mut relays: HashSet<RelayUrl> = self .get_relays( - union.iter(), + union.iter().copied(), BestRelaySelection::All { read: limits.read_relays_per_user, write: limits.write_relays_per_user, @@ -275,7 +275,7 @@ impl GossipRelayResolver { // Get map of private message relays let nip17_relays = self .get_relays( - union.iter(), + union.iter().copied(), BestRelaySelection::PrivateMessage { limit: limits.nip17_relays, },