//! XEP-0357 registrations and notifications in Prosody's cloud_notify store. use crate::{ config::Config, protocol::{self, ModuleResult}, storage::{self, LuaValue as V}, wire::escape, }; use anyhow::{Context, Result, ensure}; use std::{ collections::BTreeMap, path::Path, time::{Duration, Instant}, }; fn text(v: Option<&V>) -> Option { match v { Some(V::String(b)) => String::from_utf8(b.clone()).ok(), _ => None, } } fn s(v: &str) -> V { V::String(v.as_bytes().to_vec()) } // Prosody get_child_text() selects direct children in the stanza's namespace // and rejects elements containing child tags, rather than flattening extensions. fn stanza_child_text(stanza: roxmltree::Node<'_, '_>, name: &str) -> Option { let child = stanza.children().find(|n| { n.is_element() && n.tag_name().name() == name && n.tag_name().namespace() == stanza.tag_name().namespace() })?; if child.children().any(|n| n.is_element()) { return None; } Some( child .children() .filter(|n| n.is_text()) .filter_map(|n| n.text()) .collect(), ) } #[derive(Default)] pub struct Push { pending: BTreeMap, errors: BTreeMap<(String, String), u64>, removed: Vec<(String, String)>, } impl Push { /// Registration removals which must be reflected in live SM session watchdogs. pub fn take_removed(&mut self) -> Vec<(String, String)> { std::mem::take(&mut self.removed) } #[allow(clippy::too_many_arguments)] pub fn notify_queue( &mut self, root: &Path, domain: &str, user: &str, messages: &[String], config: &Config, identifier: &str, ) -> Result { let mut result = QueueNotification::default(); let mut sent = [false; 2]; let equal = config.option(domain, "push_notification_important_body") == Some(&serde_json::Value::Bool(false)); for xml in messages { let Ok(doc) = roxmltree::Document::parse(xml) else { continue; }; let stanza = doc.root_element(); let important = is_important(stanza); let index = usize::from(important && !equal); if sent[index] { continue; } let deliveries = self.notify_for_identifier(root, domain, user, stanza, config, Some(identifier))?; if !deliveries.is_empty() { sent[index] = true; result.reset_watchdog |= important || equal; result.deliveries.extend(deliveries); } if sent[0] && sent[1] { break; } } Ok(result) } pub fn handle( &mut self, root: &Path, domain: &str, jid: &str, iq: roxmltree::Node<'_, '_>, max_devices: usize, ) -> Result> { self.handle_with_client(root, domain, jid, iq, max_devices, None) } #[allow(clippy::too_many_arguments)] pub fn handle_with_client( &mut self, root: &Path, domain: &str, jid: &str, iq: roxmltree::Node<'_, '_>, max_devices: usize, client_id: Option<&str>, ) -> Result> { let Some(q) = iq.first_element_child() else { return Ok(None); }; if q.tag_name().namespace() != Some("urn:xmpp:push:0") { return Ok(None); } let owner = protocol::bare(jid); let (user, h) = owner.split_once('@').context("invalid account")?; if h != domain || iq.attribute("type") != Some("set") || iq .attribute("to") .is_some_and(|to| to != domain && protocol::bare(to) != owner) { return Ok(Some(ModuleResult::reply(protocol::iq_error( iq, "forbidden", )))); } let Some(service) = q.attribute("jid").filter(|v| !v.is_empty() && *v != jid) else { return Ok(Some(ModuleResult::reply(protocol::iq_error( iq, "bad-request", )))); }; let node = q.attribute("node"); if !matches!(q.tag_name().name(), "enable" | "disable") { return Ok(Some(ModuleResult::reply(protocol::iq_error( iq, "bad-request", )))); } storage::update(root, domain, Some(user), "cloud_notify", |table| { if q.tag_name().name() == "enable" { let key = format!("{service}<{}", node.unwrap_or("")); let mut record = V::default(); record.set("jid", s(service)); if let Some(node) = node { record.set("node", s(node)); } if let Some(p) = q.attribute("include_payload") { record.set("include_payload", s(p)); } record.set("timestamp", V::Integer(chrono::Utc::now().timestamp())); if let Some(client_id) = client_id { record.set("client_id", s(client_id)); } else { record.set( "resource", s(jid.split_once('/').map(|(_, r)| r).unwrap_or("")), ); } if let Some(language) = iq.attribute(("http://www.w3.org/XML/1998/namespace", "lang")) { record.set("language", s(language)); } if let Some(options) = q .children() .find(|n| n.is_element() && n.tag_name().namespace() == Some("jabber:x:data")) { record.set("options", storage::stanza_value(options)); } table.set(&key, record); if let V::Table(entries) = table { while entries.len() > max_devices.max(1) { let i = entries .iter() .enumerate() .min_by_key(|(_, (_, v))| match v.get("timestamp") { Some(V::Integer(n)) => *n, _ => 0, }) .map(|(i, _)| i) .unwrap(); entries.remove(i); } } } else if let V::Table(entries) = table { entries.retain(|(_, v)| { !(text(v.get("jid")).as_deref() == Some(service) && node.is_none_or(|n| text(v.get("node")).as_deref() == Some(n))) }); } Ok(()) })?; // A fresh registration must not inherit failures or responses for its previous incarnation. self.pending.retain(|_, (pending_user, key, _, _)| { !(pending_user == user && (key == &format!("{service}<{}", node.unwrap_or("")) || (q.tag_name().name() == "disable" && node.is_none() && key.split_once('<').is_some_and(|(s, _)| s == service)))) }); self.errors.retain(|(pending_user, key), _| { !(pending_user == user && (key == &format!("{service}<{}", node.unwrap_or("")) || (q.tag_name().name() == "disable" && node.is_none() && key.split_once('<').is_some_and(|(s, _)| s == service)))) }); Ok(Some(ModuleResult::reply(protocol::iq_result(iq, "")))) } pub fn notify( &mut self, root: &Path, domain: &str, user: &str, message: roxmltree::Node<'_, '_>, config: &Config, ) -> Result> { self.notify_for_identifier(root, domain, user, message, config, None) } #[allow(clippy::too_many_arguments)] pub fn notify_for_identifier( &mut self, root: &Path, domain: &str, user: &str, message: roxmltree::Node<'_, '_>, config: &Config, only: Option<&str>, ) -> Result> { self.pending .retain(|_, (_, _, _, at)| at.elapsed() < Duration::from_secs(120)); ensure!(self.pending.len() < 8192, "too many pending pushes"); let table = storage::load(root, domain, Some(user), "cloud_notify")?.unwrap_or_default(); let V::Table(entries) = table else { return Ok(vec![]); }; let body = stanza_child_text(message, "body"); let include_body = config .option(domain, "push_notification_with_body") .and_then(|v| v.as_bool()) .unwrap_or(false); let include_sender = config .option(domain, "push_notification_with_sender") .and_then(|v| v.as_bool()) .unwrap_or(false); let mut outputs = vec![]; for (key, record) in entries { let Some(service) = text(record.get("jid")) else { continue; }; let node = text(record.get("node")); let Some(identifier) = text(Some(&key)) else { continue; }; if only.is_some_and(|only| only != identifier) { continue; } let id = uuid::Uuid::new_v4().to_string(); let mut form="urn:xmpp:push:summary1".to_owned(); if include_sender { form.push_str(&format!( "{}", escape(message.attribute("from").unwrap_or("")) )); } let summary = if include_body { body.as_deref() } else if is_important(message) { match config.option(domain, "push_notification_important_body") { Some(serde_json::Value::Bool(false)) => None, Some(serde_json::Value::String(s)) => Some(s.as_str()), _ => Some("New Message!"), } } else { None }; if let Some(body) = summary { form.push_str(&format!( "{}", escape(body) )); } form.push_str(""); let node_attr = node .map(|n| format!(" node='{}'", escape(&n))) .unwrap_or_default(); let mut payload = format!( "{form}", escape(domain), escape(&service) ); if let Some(options) = record.get("options") { payload.push_str(&format!( "{}", storage::stanza_xml(options)? )); } payload.push_str(""); self.pending.insert( id, (user.into(), identifier, service.clone(), Instant::now()), ); outputs.push((service, payload)); } Ok(outputs) } pub fn result( &mut self, root: &Path, domain: &str, iq: roxmltree::Node<'_, '_>, max_errors: u64, ) -> Result { let Some((user, key, service, sent_at)) = self.pending.get(iq.attribute("id").unwrap_or("")).cloned() else { return Ok(false); }; if sent_at.elapsed() >= Duration::from_secs(120) { self.pending.remove(iq.attribute("id").unwrap_or("")); return Ok(false); } if iq.attribute("from") != Some(service.as_str()) { return Ok(false); } if !matches!(iq.attribute("type"), Some("result" | "error")) { return Ok(false); } self.pending.remove(iq.attribute("id").unwrap_or("")); let error_key = (user.clone(), key.clone()); if iq.attribute("type") == Some("result") { self.errors.remove(&error_key); return Ok(true); } if iq.attribute("type") == Some("error") && !iq.children().any(|n| { n.is_element() && n.tag_name().name() == "error" && n.attribute("type") == Some("wait") }) { let count = self.errors.entry(error_key.clone()).or_default(); *count += 1; if *count >= max_errors { storage::update(root, domain, Some(&user), "cloud_notify", |table| { table.set(&key, V::Nil); Ok(()) })?; self.errors.remove(&error_key); self.removed.push((user.clone(), key.clone())); } } Ok(true) } } /// Locate the most recent registration for a stable client identity or a legacy resource. /// A resource match never overrides a stored stable identity belonging to another device. pub fn client_device( root: &Path, domain: &str, user: &str, client_id: Option<&str>, resource: &str, ) -> Result> { let table = storage::load(root, domain, Some(user), "cloud_notify")?.unwrap_or_default(); let V::Table(entries) = table else { return Ok(None); }; Ok(entries .into_iter() .filter_map(|(key, record)| { let matches = match text(record.get("client_id")) { Some(stored) => client_id == Some(stored.as_str()), None => text(record.get("resource")).as_deref() == Some(resource), }; if !matches || text(record.get("jid")).is_none() { return None; } let timestamp = match record.get("timestamp") { Some(V::Integer(n)) => *n, _ => 0, }; text(Some(&key)).map(|key| (timestamp, key)) }) .max() .map(|(_, key)| key)) } #[derive(Default, Debug)] pub struct QueueNotification { pub deliveries: Vec<(String, String)>, pub reset_watchdog: bool, } /// Prosody cloud_notify importance classification, including incoming carbon wrappers. pub fn is_important(message: roxmltree::Node<'_, '_>) -> bool { if message.tag_name().name() != "message" { return false; } let mut inner = message; if let Some(carbon) = message.children().find(|n| { n.is_element() && n.tag_name().namespace() == Some("urn:xmpp:carbons:2") && matches!(n.tag_name().name(), "sent" | "received") }) { if carbon.tag_name().name() == "sent" { return false; } if let Some(stanza) = carbon .children() .find(|n| n.has_tag_name(("urn:xmpp:forward:0", "forwarded"))) .and_then(|f| { f.children() .find(|n| n.has_tag_name(("jabber:client", "message"))) }) { inner = stanza; } } if inner.attribute("type") == Some("headline") { return false; } if inner.children().any(|n| { matches!( (n.tag_name().namespace(), n.tag_name().name()), ( Some("eu.siacs.conversations.axolotl" | "urn:xmpp:omemo:0" | "urn:xmpp:omemo:2"), "encrypted" ) | (Some("jabber:x:encrypted"), "x") | (Some("urn:xmpp:openpgp:0"), "openpgp") | (Some("urn:xmpp:jingle-message:0"), "propose") ) }) { return true; } if inner.attribute("type") == Some("groupchat") && stanza_child_text(inner, "subject").is_some() { return false; } stanza_child_text(inner, "body").is_some_and(|body| !body.is_empty()) } /// Native first-important-push rule; caller invokes only after a push was emitted. pub fn resets_watchdog(message: roxmltree::Node<'_, '_>, config: &Config, domain: &str) -> bool { is_important(message) || config.option(domain, "push_notification_important_body") == Some(&serde_json::Value::Bool(false)) }