use super::*;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
mod capture;
mod legacy;
mod projection;
mod store;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) enum RecordScope {
History,
State,
}
#[derive(Clone)]
pub(super) enum RecordLocator {
PrivateContactFact(String, String),
PrivateSetting(String, String),
Authorization(String),
Group(String),
MessageRead(String, String),
Head(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub(super) enum DeviceSyncRecord {
Authorization {
event: Event,
},
PrivateEvent {
event: PrivateEvent,
},
PrivateContactFact {
fact: nostr_identity::PrivateContactFact,
},
Message {
message: DeviceSyncMessage,
},
MessageMutation {
mutation: MessageMutation,
},
RecipientReceipt {
receipt: RecipientReceipt,
},
Reaction {
reaction: DeviceSyncReaction,
},
Group {
group: DeviceSyncGroup,
},
GroupSettings {
settings: DeviceSyncGroupSettings,
},
Profile {
event: Event,
},
PrivateMessageDeletion {
event: PrivateEvent,
},
PrivateBlock {
event: PrivateEvent,
},
PrivateNameApproval {
event: PrivateEvent,
},
MessageRead {
#[serde(rename = "chatId")]
chat_id: String,
#[serde(rename = "messageId")]
message_id: String,
read: MessageReadState,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct DeviceSyncReaction {
pub chat_id: String,
pub id: String,
pub author: String,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at_ms: Option<u64>,
pub message_id: String,
pub emoji: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct DeviceSyncGroupSettings {
pub group_id: String,
pub id: String,
pub author: String,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at_ms: Option<u64>,
pub message_ttl_seconds: Option<u64>,
}
impl DeviceSyncRecord {
pub(super) fn id(&self) -> [u8; 32] {
let value = match self {
Self::Authorization { event } => return event.id.to_bytes(),
Self::PrivateContactFact { fact } => {
return nostr::EventId::from_hex(&fact.id)
.map(|id| id.to_bytes())
.unwrap_or_default()
}
Self::MessageRead {
chat_id,
message_id,
read,
} => serde_json::json!([
"messageRead",
chat_id,
message_id,
read.created_at_secs,
read.expires_at_secs
]),
Self::PrivateEvent { event }
| Self::PrivateMessageDeletion { event }
| Self::PrivateBlock { event }
| Self::PrivateNameApproval { event } => return event.id.to_bytes(),
Self::RecipientReceipt { receipt } => return receipt.record_id(),
Self::Message { message } => serde_json::json!([message.chat_id, message.id]),
Self::MessageMutation { mutation: m } => {
serde_json::json!(["messageMutation", m.chat_id, m.id])
}
Self::Reaction { reaction: r } => serde_json::json!(["reaction", r.chat_id, r.id]),
Self::Profile { event } => {
serde_json::json!(["profile", event.pubkey.to_hex(), event.id.to_hex()])
}
Self::GroupSettings { settings: s } => {
serde_json::json!(["groupSettings", s.group_id, s.id])
}
Self::Group { group: g } => serde_json::json!([
"group",
g.id,
g.revision,
g.updated_at,
g.name,
g.description,
g.picture,
g.created_by,
g.members.iter().collect::<BTreeSet<_>>(),
g.admins.iter().collect::<BTreeSet<_>>(),
g.protocol.as_deref().unwrap_or("pairwise_fanout_v1"),
g.created_at
]),
};
Sha256::digest(value.to_string().as_bytes()).into()
}
pub(super) fn fits_packet(&self) -> bool {
serde_json::to_vec(&DeviceSyncPacket::HistoryRecords {
v: 1,
session: "0".repeat(32),
records: vec![self.clone()],
requested: Vec::new(),
})
.is_ok_and(|bytes| bytes.len() <= DEVICE_SYNC_MAX_PACKET_BYTES)
}
pub(super) fn timestamp(&self) -> u64 {
match self {
Self::RecipientReceipt { receipt } => receipt.created_at,
Self::Message { message } => message.created_at,
Self::MessageMutation { mutation } => mutation.created_at,
Self::Reaction { reaction } => reaction.created_at,
_ => 0,
}
}
pub(super) fn sync_time(&self, receipt_link_at: Option<u64>) -> u64 {
match self {
Self::RecipientReceipt { receipt } => receipt.sync_time(receipt_link_at),
_ => self.timestamp(),
}
}
pub(super) fn scope(&self) -> RecordScope {
match self {
Self::Message { .. }
| Self::RecipientReceipt { .. }
| Self::Reaction { .. }
| Self::MessageMutation { .. } => RecordScope::History,
_ => RecordScope::State,
}
}
pub(super) fn locator(&self) -> Option<RecordLocator> {
if let Self::Authorization { event } = self {
return Some(RecordLocator::Authorization(event.id.to_hex()));
}
if let Self::PrivateEvent { event } = self {
let (setting, target, _) =
nostr_identity::parse_private_setting(event, event.pubkey).ok()?;
return Some(RecordLocator::PrivateSetting(setting, target));
}
if let Self::PrivateContactFact { fact } = self {
let value = nostr_identity::parse_private_contact_fact(fact, &fact.pubkey).ok()?;
return Some(RecordLocator::PrivateContactFact(
value.contact,
value.field,
));
}
if let Self::MessageRead {
chat_id,
message_id,
..
} = self
{
return Some(RecordLocator::MessageRead(
chat_id.clone(),
message_id.clone(),
));
}
if let Self::Group { group } = self {
return Some(RecordLocator::Group(group.id.clone()));
}
self.storage_key().map(RecordLocator::Head)
}
fn storage_key(&self) -> Option<String> {
Some(match self {
Self::Authorization { event } => {
serde_json::json!(["authorization", event.id.to_hex()]).to_string()
}
Self::PrivateEvent { event } => {
let (setting, target, _) =
nostr_identity::parse_private_setting(event, event.pubkey).ok()?;
serde_json::json!(["privateEvent", setting, target]).to_string()
}
Self::PrivateMessageDeletion { event } => {
serde_json::json!(["privateMessageDeletion", event.id.to_hex()]).to_string()
}
Self::PrivateNameApproval { event } => {
let state = super::super::contact_name_sync::name_approval(event)?;
serde_json::json!(["privateNameApproval", state.contact, event.id.to_hex()])
.to_string()
}
Self::PrivateBlock { event } => {
let state = super::super::block_sync::block_event_state(event)?;
serde_json::json!(["privateBlock", state.target, event.id.to_hex()]).to_string()
}
Self::MessageMutation { mutation: m } => {
serde_json::json!(["messageMutation", m.chat_id, m.message_id, m.id]).to_string()
}
Self::Reaction { reaction: r } => {
serde_json::json!(["reaction", r.chat_id, r.message_id, r.author]).to_string()
}
Self::GroupSettings { settings: s } => {
serde_json::json!(["groupSettings", s.group_id]).to_string()
}
Self::Profile { event } => {
serde_json::json!(["profile", event.pubkey.to_hex()]).to_string()
}
_ => return None,
})
}
fn wins(&self, previous: &Self) -> bool {
match (self, previous) {
(Self::PrivateEvent { event: a }, Self::PrivateEvent { event: b }) => {
super::private_settings::private_setting_version(a)
> super::private_settings::private_setting_version(b)
}
(Self::PrivateBlock { event: a }, Self::PrivateBlock { event: b }) => {
super::super::block_sync::block_event_version(a)
> super::super::block_sync::block_event_version(b)
}
(Self::Reaction { reaction: a }, Self::Reaction { reaction: b }) => {
(effective_ms(a.created_at, a.created_at_ms), &a.id)
> (effective_ms(b.created_at, b.created_at_ms), &b.id)
}
(Self::GroupSettings { settings: a }, Self::GroupSettings { settings: b }) => {
(effective_ms(a.created_at, a.created_at_ms), &a.id)
> (effective_ms(b.created_at, b.created_at_ms), &b.id)
}
(Self::Profile { event: a }, Self::Profile { event: b }) => {
a.created_at > b.created_at || (a.created_at == b.created_at && a.id < b.id)
}
_ => false,
}
}
}
fn effective_ms(seconds: u64, millis: Option<u64>) -> u64 {
millis.unwrap_or(seconds.saturating_mul(1000))
}
fn valid_time(seconds: u64, millis: Option<u64>) -> bool {
seconds <= unix_now().get().saturating_add(300)
&& millis.is_none_or(|ms| ms <= 9_007_199_254_740_991 && ms / 1000 == seconds)
}
impl AppCore {
pub(in crate::core) fn apply_name_approval_event(&mut self, event: PrivateEvent) -> bool {
self.apply_sync_record(DeviceSyncRecord::PrivateNameApproval { event })
}
pub(in crate::core) fn apply_private_block_event(&mut self, event: PrivateEvent) -> bool {
self.apply_sync_record(DeviceSyncRecord::PrivateBlock { event })
}
fn sync_record_author_allowed(&self, chat: &str, author: &str, admin: bool) -> bool {
let Some(local) = self
.logged_in
.as_ref()
.map(|login| login.owner_pubkey.to_hex())
else {
return false;
};
if let Some(group_id) = parse_group_id_from_chat_id(chat) {
self.groups.get(&group_id).is_some_and(|group| {
group.members.iter().any(|key| key.to_hex() == local)
&& (if admin { &group.admins } else { &group.members })
.iter()
.any(|key| key.to_hex() == author)
})
} else {
!admin && self.threads.contains_key(chat) && (author == local || author == chat)
}
}
pub(super) fn sync_record_allowed(&self, record: &DeviceSyncRecord) -> bool {
if !record.fits_packet() {
return false;
}
match record {
DeviceSyncRecord::MessageRead {
chat_id,
message_id,
read,
} => {
valid_device_sync_chat_id(chat_id)
&& !message_id.is_empty()
&& message_id.len() <= 128
&& valid_time(read.created_at_secs, None)
&& read.expires_at_secs.is_none_or(|expiry| {
expiry > unix_now().get() && expiry <= 9_007_199_254_740_991
})
&& !self.chat_activity_is_deleted(chat_id, read.created_at_secs)
&& !self
.app_store
.message_was_locally_deleted(chat_id, Some(message_id), None)
.unwrap_or(true)
}
DeviceSyncRecord::RecipientReceipt { receipt } => receipt.valid(),
DeviceSyncRecord::Message { message } => {
messages::history_message_allowed(self, message)
}
DeviceSyncRecord::MessageMutation { mutation: m } => {
self.block_allows_history(&m.chat_id, &m.author, m.created_at)
&& self.message_mutation_allowed(m).unwrap_or(false)
}
DeviceSyncRecord::Reaction { reaction: r } => {
!r.id.is_empty()
&& r.id.len() <= 128
&& !r.message_id.is_empty()
&& r.message_id.len() <= 128
&& r.emoji.len() <= 256
&& valid_time(r.created_at, r.created_at_ms)
&& self.block_allows_history(&r.chat_id, &r.author, r.created_at)
&& !self.sync_reaction_target_expired(&r.chat_id, &r.message_id)
&& self.sync_record_author_allowed(&r.chat_id, &r.author, false)
&& !self.chat_activity_is_deleted(&r.chat_id, r.created_at)
&& !self
.app_store
.message_was_locally_deleted(&r.chat_id, Some(&r.message_id), None)
.unwrap_or(true)
}
DeviceSyncRecord::GroupSettings { settings: s } => {
!s.id.is_empty()
&& s.id.len() <= 128
&& valid_time(s.created_at, s.created_at_ms)
&& s.message_ttl_seconds
.is_none_or(|ttl| ttl > 0 && ttl <= 9_007_199_254_740_991)
&& self.sync_record_author_allowed(
&format!("group:{}", s.group_id),
&s.author,
true,
)
}
DeviceSyncRecord::PrivateContactFact { fact } => {
self.logged_in.as_ref().is_some_and(|login| {
nostr_identity::parse_private_contact_fact(fact, &login.owner_pubkey.to_hex())
.is_ok()
})
}
DeviceSyncRecord::Authorization { event } => self.authorization_record_allowed(event),
DeviceSyncRecord::PrivateEvent { event } => {
self.private_setting_snapshot(event).is_some()
}
DeviceSyncRecord::PrivateMessageDeletion { event } => {
self.message_deletion_target(event).is_some()
}
DeviceSyncRecord::PrivateBlock { event } => self.private_block_event_allowed(event),
DeviceSyncRecord::PrivateNameApproval { event } => self.name_approval_allowed(event),
DeviceSyncRecord::Profile { event } => {
event.kind == Kind::Metadata
&& event.verify().is_ok()
&& event.content.len() <= 32 * 1024
&& event.tags.len() <= 256
&& event.created_at.as_secs() <= unix_now().get().saturating_add(300)
&& self.profile_sync_owner_allowed(event.pubkey)
}
DeviceSyncRecord::Group { group } => {
group.clone().into_group_snapshot().is_some()
&& self
.logged_in
.as_ref()
.is_some_and(|login| group.members.contains(&login.owner_pubkey.to_hex()))
}
}
}
pub(super) fn history_mutation_target_allowed(
&self,
record: &DeviceSyncRecord,
target_since: u64,
) -> bool {
if let DeviceSyncRecord::RecipientReceipt { receipt } = record {
return receipt.created_at >= target_since;
}
let DeviceSyncRecord::MessageMutation { mutation } = record else {
return true;
};
self.message_for_mutation(&mutation.chat_id, &mutation.message_id)
.is_some_and(|target| {
target.created_at_secs >= target_since
&& target.author_owner_pubkey_hex.as_deref() == Some(mutation.author.as_str())
&& matches!(target.kind, ChatMessageKind::User)
&& target
.expires_at_secs
.is_none_or(|expiry| expiry > unix_now().get())
&& !self.chat_activity_is_deleted(&mutation.chat_id, target.created_at_secs)
&& !self
.app_store
.message_was_locally_deleted(
&mutation.chat_id,
Some(&mutation.message_id),
None,
)
.unwrap_or(true)
})
}
pub(super) fn message_mutation_allowed(&self, m: &MessageMutation) -> anyhow::Result<bool> {
let locally_deleted =
self.app_store
.message_was_locally_deleted(&m.chat_id, Some(&m.message_id), None)?;
let target = self.message_for_mutation_result(&m.chat_id, &m.message_id)?;
Ok(!m.id.is_empty()
&& m.id.len() <= 128
&& !m.message_id.is_empty()
&& m.message_id.len() <= 128
&& m.id != m.message_id
&& valid_time(m.created_at, m.created_at_ms)
&& m.expires_at
.is_none_or(|expiry| expiry > unix_now().get() && expiry <= 9_007_199_254_740_991)
&& ((m.operation == "delete" && m.content.is_empty())
|| (m.operation == "edit"
&& !m.content.trim().is_empty()
&& m.content.len() <= 32 * 1024
&& extract_message_attachments(&m.content).1.is_empty()
&& !m.content.starts_with("iris-direct-file-v1:")))
&& (if is_group_chat_id(&m.chat_id) {
self.sync_record_author_allowed(&m.chat_id, &m.author, false)
} else {
PublicKey::from_hex(&m.chat_id).is_ok()
&& self.logged_in.as_ref().is_some_and(|login| {
m.author == m.chat_id || m.author == login.owner_pubkey.to_hex()
})
})
&& !self.chat_activity_is_deleted(&m.chat_id, m.created_at)
&& !locally_deleted
&& target.is_none_or(|message| {
message
.expires_at_secs
.is_none_or(|expiry| expiry > unix_now().get())
&& message.author_owner_pubkey_hex.as_deref() == Some(m.author.as_str())
&& matches!(message.kind, ChatMessageKind::User)
&& m.created_at >= message.created_at_secs
&& (m.operation == "delete"
|| super::super::message_mutations::editable_message(&message))
}))
}
fn sync_reaction_target_expired(&self, chat: &str, id: &str) -> bool {
let expiry = self
.threads
.get(chat)
.and_then(|thread| thread.messages.iter().find(|m| m.id == id))
.and_then(|m| m.expires_at_secs)
.or_else(|| {
self.app_store
.load_messages_around(chat, id, 0, 0)
.ok()?
.into_iter()
.next()?
.expires_at_secs
});
expiry.is_some_and(|until| until <= unix_now().get())
}
fn profile_sync_owner_allowed(&self, owner: PublicKey) -> bool {
let Some(local) = self
.logged_in
.as_ref()
.map(|login| login.owner_pubkey.to_hex())
else {
return false;
};
let owner = owner.to_hex();
owner == local
|| (self.threads.contains_key(&owner) && !self.chat_deletions.contains_key(&owner))
|| self.groups.values().any(|group| {
group.members.iter().any(|key| key.to_hex() == local)
&& group.members.iter().any(|key| key.to_hex() == owner)
})
}
pub(super) fn apply_sync_record(&mut self, record: DeviceSyncRecord) -> bool {
if let DeviceSyncRecord::MessageRead {
chat_id,
message_id,
read,
} = &record
{
if self.chat_activity_is_deleted(chat_id, read.created_at_secs)
|| read
.expires_at_secs
.is_some_and(|expiry| expiry <= unix_now().get())
|| self
.app_store
.message_was_locally_deleted(chat_id, Some(message_id), None)
.unwrap_or(false)
{
return true;
}
}
if let DeviceSyncRecord::Reaction { reaction } = &record {
if self
.app_store
.message_was_locally_deleted(&reaction.chat_id, Some(&reaction.message_id), None)
.unwrap_or(false)
|| self.chat_activity_is_deleted(&reaction.chat_id, reaction.created_at)
|| self.sync_reaction_target_expired(&reaction.chat_id, &reaction.message_id)
{
return true;
}
}
if let DeviceSyncRecord::MessageMutation { mutation: m } = &record {
if m.operation == "delete"
&& !self.preferences.allow_message_deletion_by_others
&& self
.logged_in
.as_ref()
.is_some_and(|login| login.owner_pubkey.to_hex() != m.author)
{
return true;
}
}
if !self.sync_record_allowed(&record) {
return false;
}
match record {
DeviceSyncRecord::Authorization { event } => self.merge_authorization_event(&event),
DeviceSyncRecord::PrivateEvent { event } => self.merge_private_setting(&event),
DeviceSyncRecord::PrivateMessageDeletion { ref event } => self
.message_deletion_target(event)
.is_some_and(|(chat, id)| {
self.remove_account_message(&chat, &id, Some(&record))
.is_ok()
}),
DeviceSyncRecord::PrivateContactFact { fact } => self.merge_private_contact_fact(&fact),
DeviceSyncRecord::MessageRead {
chat_id,
message_id,
read,
} => {
if self
.app_store
.message_read_state(&chat_id, &message_id)
.ok()
.flatten()
.as_ref()
== Some(&read)
{
return true;
}
let Some(login) = self.logged_in.as_ref() else {
return false;
};
let state = ChatReadState {
updated_at_ms: unix_now().get().saturating_mul(1000),
device_id: login.device_keys.public_key().to_hex(),
seen_messages: [(message_id.clone(), read.clone())].into_iter().collect(),
..Default::default()
};
self.apply_chat_read_state(&chat_id, state);
self.message_was_seen_on_own_device(&chat_id, read.created_at_secs, &message_id)
}
DeviceSyncRecord::RecipientReceipt { receipt } => receipt.apply(self).is_some(),
DeviceSyncRecord::Message { .. } => false,
DeviceSyncRecord::Group { group } => {
let expected = group.clone();
self.apply_device_sync_snapshot(
DeviceSyncSnapshot {
groups: vec![group],
..Default::default()
},
None,
);
self.persist_best_effort_inner();
self.sync_group_is_durable(&expected)
}
DeviceSyncRecord::Profile { event } => {
self.apply_profile_metadata_event(&event);
self.persist_best_effort();
self.sync_record_is_stored(&DeviceSyncRecord::Profile { event })
}
record => {
let Ok(accepted) = self.store_sync_record(&record) else {
return false;
};
if accepted {
match &record {
DeviceSyncRecord::PrivateNameApproval { event } => {
self.project_name_approval(event)
}
DeviceSyncRecord::PrivateBlock { event } => {
if !self.project_private_block(event) {
return false;
}
}
DeviceSyncRecord::MessageMutation { mutation: m } => {
if !self.project_message_mutations(&m.chat_id, &m.message_id) {
return false;
}
self.schedule_next_message_expiry();
}
DeviceSyncRecord::Reaction { reaction: r } => {
if !self.observe_chat_engagement(
&r.chat_id,
&r.author,
effective_ms(r.created_at, r.created_at_ms),
) {
return false;
}
self.apply_incoming_reaction_to_chat(
&r.chat_id,
&r.message_id,
&r.author,
&r.emoji,
);
}
DeviceSyncRecord::GroupSettings { settings: s } => {
let chat = format!("group:{}", s.group_id);
if let Some(ttl) = s.message_ttl_seconds {
self.chat_message_ttl_seconds.insert(chat, ttl);
} else {
self.chat_message_ttl_seconds.remove(&chat);
}
}
_ => {}
}
self.persist_best_effort();
}
self.sync_record_is_stored(&record)
}
}
}
}
#[cfg(test)]
mod tests;