1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7use url::Url;
8
9use crate::{
10 LocalIdentity, Nip44EncryptedMessage, Nip44Payload, NostrEventDraft, NostrEventKind,
11 NostrPublicKey, NostrRumor, NostrTag, RumorEvent, SignedEvent, SignedNostrEvent, SoftchatError,
12};
13
14pub const MAX_CONTACTS: usize = 10_000;
16pub const MAX_APP_DATA_JSON_BYTES: usize = 256 * 1024;
18
19const METADATA_KEYS: [&str; 7] = [
20 "name",
21 "display_name",
22 "about",
23 "picture",
24 "website",
25 "banner",
26 "bot",
27];
28
29#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "camelCase")]
32#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
33pub struct UserMetadataView {
34 pub rumor: RumorEvent,
36 pub name: Option<String>,
38 pub display_name: Option<String>,
40 pub about: Option<String>,
42 pub picture: Option<String>,
44 pub website: Option<String>,
46 pub banner: Option<String>,
48 pub bot: Option<bool>,
50 pub unknown_json: String,
52}
53
54#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
58pub struct Contact {
59 pub public_key: String,
61 pub relay_hint: String,
63 pub local_name: String,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "camelCase")]
70#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
71pub struct FollowListView {
72 pub rumor: RumorEvent,
74 pub contacts: Vec<Contact>,
76 pub contained_duplicates: bool,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "camelCase")]
83#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
84pub struct ApplicationDataView {
85 pub event: SignedEvent,
87 pub identifier: String,
89 pub content: String,
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
95#[serde(rename_all = "camelCase")]
96#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
97pub enum AppDataContext {
98 ReadState,
100 AppSettings,
102}
103
104impl AppDataContext {
105 #[must_use]
107 pub const fn as_str(self) -> &'static str {
108 match self {
109 Self::ReadState => "read-state",
110 Self::AppSettings => "app-settings",
111 }
112 }
113
114 fn parse(value: &str) -> Result<Self, SoftchatError> {
115 match value {
116 "read-state" => Ok(Self::ReadState),
117 "app-settings" => Ok(Self::AppSettings),
118 _ => Err(SoftchatError::InvalidApplicationData),
119 }
120 }
121}
122
123#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
125#[serde(rename_all = "camelCase")]
126#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
127pub struct AppDataSyncView {
128 pub event: SignedEvent,
130 pub context: AppDataContext,
132 pub json: String,
134}
135
136impl LocalIdentity {
137 #[allow(clippy::too_many_arguments)]
146 pub fn create_user_metadata(
147 &self,
148 created_at: u64,
149 name: Option<String>,
150 display_name: Option<String>,
151 about: Option<String>,
152 picture: Option<String>,
153 website: Option<String>,
154 banner: Option<String>,
155 bot: Option<bool>,
156 unknown_json: &str,
157 ) -> Result<UserMetadataView, SoftchatError> {
158 for value in [&picture, &website, &banner].into_iter().flatten() {
159 validate_https(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
160 }
161 let mut object = parse_unknown_object(unknown_json)?;
162 for key in METADATA_KEYS {
163 if object.contains_key(key) {
164 return Err(SoftchatError::InvalidUserMetadata);
165 }
166 }
167 insert_optional(&mut object, "name", name);
168 insert_optional(&mut object, "display_name", display_name);
169 insert_optional(&mut object, "about", about);
170 insert_optional(&mut object, "picture", picture);
171 insert_optional(&mut object, "website", website);
172 insert_optional(&mut object, "banner", banner);
173 if let Some(value) = bot {
174 object.insert("bot".to_owned(), Value::Bool(value));
175 }
176 let content =
177 serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidUserMetadata)?;
178 if content.len() > MAX_APP_DATA_JSON_BYTES {
179 return Err(SoftchatError::InvalidUserMetadata);
180 }
181 let rumor = self
182 .create_rumor(
183 NostrEventDraft::new(
184 created_at,
185 NostrEventKind::USER_METADATA,
186 Vec::new(),
187 content,
188 )
189 .map_err(|_| SoftchatError::InvalidUserMetadata)?,
190 )
191 .map_err(|_| SoftchatError::InvalidUserMetadata)?;
192 parse_user_metadata(&rumor)
193 }
194
195 pub fn create_follow_list(
202 &self,
203 created_at: u64,
204 contacts: Vec<Contact>,
205 ) -> Result<FollowListView, SoftchatError> {
206 if contacts.len() > MAX_CONTACTS {
207 return Err(SoftchatError::InvalidContactList);
208 }
209 let mut canonical = contacts
210 .into_iter()
211 .map(validate_contact)
212 .collect::<Result<Vec<_>, _>>()?;
213 canonical.sort();
214 let original_len = canonical.len();
215 canonical.dedup_by(|left, right| left.public_key == right.public_key);
216 if canonical.len() != original_len {
217 return Err(SoftchatError::InvalidContactList);
218 }
219 let tags = canonical
220 .iter()
221 .map(|contact| {
222 NostrTag::new(vec![
223 "p".to_owned(),
224 contact.public_key.clone(),
225 contact.relay_hint.clone(),
226 contact.local_name.clone(),
227 ])
228 .map_err(|_| SoftchatError::InvalidContactList)
229 })
230 .collect::<Result<Vec<_>, _>>()?;
231 let rumor = self
232 .create_rumor(
233 NostrEventDraft::new(created_at, NostrEventKind::FOLLOW_LIST, tags, "")
234 .map_err(|_| SoftchatError::InvalidContactList)?,
235 )
236 .map_err(|_| SoftchatError::InvalidContactList)?;
237 parse_follow_list(&rumor)
238 }
239
240 pub fn create_application_data(
247 &self,
248 created_at: u64,
249 identifier: String,
250 content: String,
251 ) -> Result<ApplicationDataView, SoftchatError> {
252 validate_identifier(&identifier)?;
253 let event = self
254 .sign_event(
255 NostrEventDraft::new(
256 created_at,
257 NostrEventKind::APPLICATION_DATA,
258 vec![NostrTag::new(vec!["d".to_owned(), identifier])?],
259 content,
260 )
261 .map_err(|_| SoftchatError::InvalidApplicationData)?,
262 )
263 .map_err(|_| SoftchatError::InvalidApplicationData)?;
264 parse_application_data(&event)
265 }
266
267 pub fn create_app_data_sync(
274 &self,
275 created_at: u64,
276 context: AppDataContext,
277 json: &str,
278 ) -> Result<AppDataSyncView, SoftchatError> {
279 let json = validate_json_object(json)?;
280 let encrypted = self
281 .encrypt_utf8(&self.public_key(), &json)
282 .map_err(|_| SoftchatError::InvalidApplicationData)?;
283 let event = self
284 .sign_event(
285 NostrEventDraft::new(
286 created_at,
287 NostrEventKind::APPLICATION_DATA_SYNC,
288 vec![NostrTag::new(vec!["d", context.as_str()])?],
289 encrypted.payload().as_str(),
290 )
291 .map_err(|_| SoftchatError::InvalidApplicationData)?,
292 )
293 .map_err(|_| SoftchatError::InvalidApplicationData)?;
294 self.decrypt_app_data_sync(&event)
295 }
296
297 pub fn decrypt_app_data_sync(
304 &self,
305 event: &SignedNostrEvent,
306 ) -> Result<AppDataSyncView, SoftchatError> {
307 if event.kind() != NostrEventKind::APPLICATION_DATA_SYNC
308 || event.public_key() != self.public_key()
309 {
310 return Err(SoftchatError::InvalidApplicationData);
311 }
312 let identifier = exactly_one_d(event.tags())?;
313 let context = AppDataContext::parse(&identifier)?;
314 let encrypted = Nip44EncryptedMessage::new(
315 self.public_key(),
316 Nip44Payload::from_encoded(event.content())
317 .map_err(|_| SoftchatError::InvalidApplicationData)?,
318 );
319 let json = self
320 .decrypt_utf8(&encrypted)
321 .map_err(|_| SoftchatError::InvalidApplicationData)?;
322 let json = validate_json_object(&json)?;
323 Ok(AppDataSyncView {
324 event: SignedEvent::from(event),
325 context,
326 json,
327 })
328 }
329}
330
331#[cfg_attr(feature = "native-bindings", uniffi::export)]
333pub fn classify_user_metadata(rumor: RumorEvent) -> Result<UserMetadataView, SoftchatError> {
334 let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidUserMetadata)?;
335 parse_user_metadata(&rumor)
336}
337
338#[cfg_attr(feature = "native-bindings", uniffi::export)]
344pub fn latest_user_metadata(
345 candidates: Vec<RumorEvent>,
346) -> Result<UserMetadataView, SoftchatError> {
347 let candidates = candidates
348 .into_iter()
349 .map(|candidate| {
350 let rumor =
351 NostrRumor::try_from(candidate).map_err(|_| SoftchatError::InvalidUserMetadata)?;
352 let view = parse_user_metadata(&rumor)?;
353 Ok((rumor.public_key(), rumor.created_at(), rumor.id(), view))
354 })
355 .collect::<Result<Vec<_>, SoftchatError>>()?;
356 let author = candidates
357 .first()
358 .map(|(author, _, _, _)| author.clone())
359 .ok_or(SoftchatError::InvalidUserMetadata)?;
360 if candidates
361 .iter()
362 .any(|(candidate, _, _, _)| *candidate != author)
363 {
364 return Err(SoftchatError::InvalidUserMetadata);
365 }
366 candidates
367 .into_iter()
368 .max_by_key(|(_, created_at, id, _)| (*created_at, *id))
369 .map(|(_, _, _, view)| view)
370 .ok_or(SoftchatError::InvalidUserMetadata)
371}
372
373#[cfg_attr(feature = "native-bindings", uniffi::export)]
375pub fn classify_follow_list(rumor: RumorEvent) -> Result<FollowListView, SoftchatError> {
376 let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidContactList)?;
377 parse_follow_list(&rumor)
378}
379
380#[cfg_attr(feature = "native-bindings", uniffi::export)]
382pub fn classify_application_data(event: SignedEvent) -> Result<ApplicationDataView, SoftchatError> {
383 let event =
384 SignedNostrEvent::try_from(event).map_err(|_| SoftchatError::InvalidApplicationData)?;
385 parse_application_data(&event)
386}
387
388#[cfg_attr(feature = "native-bindings", uniffi::export)]
394pub fn latest_application_data(
395 candidates: Vec<SignedEvent>,
396) -> Result<ApplicationDataView, SoftchatError> {
397 let candidates = candidates
398 .into_iter()
399 .map(|candidate| {
400 let event = SignedNostrEvent::try_from(candidate)
401 .map_err(|_| SoftchatError::InvalidApplicationData)?;
402 let view = parse_application_data(&event)?;
403 Ok((
404 event.public_key(),
405 event.kind(),
406 view.identifier.clone(),
407 event.created_at(),
408 event.id(),
409 view,
410 ))
411 })
412 .collect::<Result<Vec<_>, SoftchatError>>()?;
413 let identity = candidates
414 .first()
415 .map(|(author, kind, identifier, _, _, _)| (author.clone(), *kind, identifier.clone()))
416 .ok_or(SoftchatError::InvalidApplicationData)?;
417 if candidates
418 .iter()
419 .any(|candidate| (candidate.0.clone(), candidate.1, candidate.2.clone()) != identity)
420 {
421 return Err(SoftchatError::InvalidApplicationData);
422 }
423 candidates
424 .into_iter()
425 .max_by_key(|(_, _, _, created_at, id, _)| (*created_at, *id))
426 .map(|(_, _, _, _, _, view)| view)
427 .ok_or(SoftchatError::InvalidApplicationData)
428}
429
430fn parse_user_metadata(rumor: &NostrRumor) -> Result<UserMetadataView, SoftchatError> {
431 if rumor.kind() != NostrEventKind::USER_METADATA || rumor.tags().len() != 0 {
432 return Err(SoftchatError::InvalidUserMetadata);
433 }
434 if rumor.content().len() > MAX_APP_DATA_JSON_BYTES {
435 return Err(SoftchatError::InvalidUserMetadata);
436 }
437 let mut object: Map<String, Value> =
438 serde_json::from_str(rumor.content()).map_err(|_| SoftchatError::InvalidUserMetadata)?;
439 let name = take_string(&mut object, "name")?;
440 let display_name = take_string(&mut object, "display_name")?;
441 let about = take_string(&mut object, "about")?;
442 let picture = take_string(&mut object, "picture")?;
443 let website = take_string(&mut object, "website")?;
444 let banner = take_string(&mut object, "banner")?;
445 for value in [&picture, &website, &banner].into_iter().flatten() {
446 validate_https(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
447 }
448 let bot = match object.remove("bot") {
449 Some(Value::Bool(value)) => Some(value),
450 Some(_) => return Err(SoftchatError::InvalidUserMetadata),
451 None => None,
452 };
453 let unknown_json =
454 serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidUserMetadata)?;
455 Ok(UserMetadataView {
456 rumor: RumorEvent::from(rumor),
457 name,
458 display_name,
459 about,
460 picture,
461 website,
462 banner,
463 bot,
464 unknown_json,
465 })
466}
467
468fn parse_follow_list(rumor: &NostrRumor) -> Result<FollowListView, SoftchatError> {
469 if rumor.kind() != NostrEventKind::FOLLOW_LIST
470 || !rumor.content().is_empty()
471 || rumor.tags().len() > MAX_CONTACTS
472 {
473 return Err(SoftchatError::InvalidContactList);
474 }
475 let mut contacts = BTreeMap::new();
476 let mut seen = BTreeSet::new();
477 let mut contained_duplicates = false;
478 for tag in rumor.tags() {
479 if tag.first().map(String::as_str) != Some("p") {
480 continue;
481 }
482 let contact = validate_contact(Contact {
483 public_key: tag
484 .get(1)
485 .cloned()
486 .ok_or(SoftchatError::InvalidContactList)?,
487 relay_hint: tag.get(2).cloned().unwrap_or_default(),
488 local_name: tag.get(3).cloned().unwrap_or_default(),
489 })?;
490 if !seen.insert(contact.public_key.clone()) {
491 contained_duplicates = true;
492 continue;
493 }
494 contacts.insert(contact.public_key.clone(), contact);
495 }
496 Ok(FollowListView {
497 rumor: RumorEvent::from(rumor),
498 contacts: contacts.into_values().collect(),
499 contained_duplicates,
500 })
501}
502
503fn parse_application_data(event: &SignedNostrEvent) -> Result<ApplicationDataView, SoftchatError> {
504 if event.kind() != NostrEventKind::APPLICATION_DATA {
505 return Err(SoftchatError::InvalidApplicationData);
506 }
507 let identifier = exactly_one_d(event.tags())?;
508 validate_identifier(&identifier)?;
509 Ok(ApplicationDataView {
510 event: SignedEvent::from(event),
511 identifier,
512 content: event.content().to_owned(),
513 })
514}
515
516fn exactly_one_d<'a>(tags: impl Iterator<Item = &'a [String]>) -> Result<String, SoftchatError> {
517 let identifiers = tags
518 .filter(|tag| tag.first().map(String::as_str) == Some("d"))
519 .map(|tag| {
520 tag.get(1)
521 .cloned()
522 .ok_or(SoftchatError::InvalidApplicationData)
523 })
524 .collect::<Result<Vec<_>, _>>()?;
525 match identifiers.as_slice() {
526 [identifier] => Ok(identifier.clone()),
527 _ => Err(SoftchatError::InvalidApplicationData),
528 }
529}
530
531fn parse_unknown_object(json: &str) -> Result<Map<String, Value>, SoftchatError> {
532 if json.len() > MAX_APP_DATA_JSON_BYTES {
533 return Err(SoftchatError::InvalidUserMetadata);
534 }
535 serde_json::from_str(json).map_err(|_| SoftchatError::InvalidUserMetadata)
536}
537
538fn validate_json_object(json: &str) -> Result<String, SoftchatError> {
539 if json.len() > MAX_APP_DATA_JSON_BYTES {
540 return Err(SoftchatError::InvalidApplicationData);
541 }
542 let object: Map<String, Value> =
543 serde_json::from_str(json).map_err(|_| SoftchatError::InvalidApplicationData)?;
544 serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidApplicationData)
545}
546
547fn take_string(
548 object: &mut Map<String, Value>,
549 key: &str,
550) -> Result<Option<String>, SoftchatError> {
551 match object.remove(key) {
552 Some(Value::String(value)) => Ok(Some(value)),
553 Some(_) => Err(SoftchatError::InvalidUserMetadata),
554 None => Ok(None),
555 }
556}
557
558fn insert_optional(object: &mut Map<String, Value>, key: &str, value: Option<String>) {
559 if let Some(value) = value {
560 object.insert(key.to_owned(), Value::String(value));
561 }
562}
563
564fn validate_contact(mut contact: Contact) -> Result<Contact, SoftchatError> {
565 contact.public_key = NostrPublicKey::from_hex(&contact.public_key)
566 .map_err(|_| SoftchatError::InvalidContactList)?
567 .to_hex();
568 if !contact.relay_hint.is_empty() {
569 let url = Url::parse(&contact.relay_hint).map_err(|_| SoftchatError::InvalidContactList)?;
570 if !matches!(url.scheme(), "ws" | "wss")
571 || url.host_str().is_none()
572 || url.fragment().is_some()
573 {
574 return Err(SoftchatError::InvalidContactList);
575 }
576 }
577 if contact.local_name.chars().count() > 256 {
578 return Err(SoftchatError::InvalidContactList);
579 }
580 Ok(contact)
581}
582
583fn validate_identifier(identifier: &str) -> Result<(), SoftchatError> {
584 if identifier.is_empty() || identifier.chars().count() > 256 {
585 return Err(SoftchatError::InvalidApplicationData);
586 }
587 Ok(())
588}
589
590fn validate_https(value: &str) -> Result<(), SoftchatError> {
591 let url = Url::parse(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
592 if url.scheme() != "https" || url.host_str().is_none() || url.fragment().is_some() {
593 return Err(SoftchatError::InvalidUserMetadata);
594 }
595 Ok(())
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
603 const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
604
605 #[test]
606 fn metadata_retains_unknown_fields_and_orders_replacements() -> Result<(), SoftchatError> {
607 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
608 let first = alice.create_user_metadata(
609 1,
610 Some("Alice".to_owned()),
611 None,
612 None,
613 Some("https://cdn.example/a.webp".to_owned()),
614 None,
615 None,
616 Some(false),
617 r#"{"future":{"retained":true}}"#,
618 )?;
619 assert_eq!(first.name.as_deref(), Some("Alice"));
620 assert_eq!(first.unknown_json, r#"{"future":{"retained":true}}"#);
621 let second =
622 alice.create_user_metadata(2, None, None, None, None, None, None, None, "{}")?;
623 assert_eq!(
624 latest_user_metadata(vec![first.rumor, second.rumor.clone()])?
625 .rumor
626 .id,
627 second.rumor.id
628 );
629 Ok(())
630 }
631
632 #[test]
633 fn contact_writer_is_four_field_sorted_and_reader_keeps_first_duplicate()
634 -> Result<(), SoftchatError> {
635 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
636 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
637 let bob_hex = bob.public_key().to_hex();
638 let contact = Contact {
639 public_key: bob_hex.clone(),
640 relay_hint: String::new(),
641 local_name: "Bob".to_owned(),
642 };
643 let view = alice.create_follow_list(1, vec![contact.clone()])?;
644 assert_eq!(view.rumor.tags[0].len(), 4);
645 assert_eq!(view.contacts, vec![contact]);
646
647 let duplicate = alice.create_rumor(NostrEventDraft::new(
648 2,
649 NostrEventKind::FOLLOW_LIST,
650 vec![
651 NostrTag::new(vec!["p", &bob_hex, "", "First"])?,
652 NostrTag::new(vec!["p", &bob_hex, "", "Second"])?,
653 ],
654 "",
655 )?)?;
656 let read = parse_follow_list(&duplicate)?;
657 assert!(read.contained_duplicates);
658 assert_eq!(read.contacts[0].local_name, "First");
659 Ok(())
660 }
661
662 #[test]
663 fn app_data_sync_is_direct_self_encrypted_and_deterministically_replaceable()
664 -> Result<(), SoftchatError> {
665 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
666 let first = alice.create_app_data_sync(
667 1,
668 AppDataContext::ReadState,
669 r#"{"version":1,"read":1}"#,
670 )?;
671 let second = alice.create_app_data_sync(
672 2,
673 AppDataContext::ReadState,
674 r#"{"version":1,"read":2}"#,
675 )?;
676 assert_ne!(first.event.content, first.json);
677 assert_eq!(
678 alice
679 .decrypt_app_data_sync(&SignedNostrEvent::try_from(second.event.clone())?)?
680 .json,
681 r#"{"read":2,"version":1}"#
682 );
683
684 let generic_first =
685 alice.create_application_data(1, "drafts".to_owned(), "one".to_owned())?;
686 let generic_second =
687 alice.create_application_data(2, "drafts".to_owned(), "two".to_owned())?;
688 assert_eq!(
689 latest_application_data(vec![generic_first.event, generic_second.event.clone()])?
690 .content,
691 "two"
692 );
693 Ok(())
694 }
695}