//! Persistent PEP and PubSub subset. Access checks precede item reads and writes. use crate::{ protocol::{ModuleResult, bare, iq_error, iq_result}, storage::{self, LuaValue as V}, wire::escape, }; use anyhow::Result; use std::{path::Path, sync::Mutex}; const NS: &str = "http://jabber.org/protocol/pubsub"; const OWNER: &str = "http://jabber.org/protocol/pubsub#owner"; static LOCK: Mutex<()> = Mutex::new(()); fn text<'a>(v: &'a V, k: &str, default: &'a str) -> &'a str { v.get(k).and_then(V::as_str).unwrap_or(default) } fn tab(v: &V, k: &str) -> V { v.get(k).cloned().unwrap_or_default() } fn defaults(name: &str, owner: &str, pep: bool) -> V { let mut v = V::table(); v.set("name", name.into()); let mut c = V::table(); c.set("access_model", if pep { "presence" } else { "open" }.into()); c.set("max_items", V::Integer(if pep { 1 } else { 20 })); c.set("publish_model", "publishers".into()); c.set("notification_type", "headline".into()); c.set("itemreply", "none".into()); c.set( "send_last_published_item", if pep { "on_sub_and_presence" } else { "never" }.into(), ); c.set("persist_items", V::Bool(true)); // These options default to true in delivery as well. Materialize them so // publish preconditions compare against effective defaults, including nodes // loaded from older stores which did not persist these fields. for key in [ "notify_items", "include_payload", "notify_delete", "notify_retract", ] { c.set(key, V::Bool(true)); } v.set("config", c); v.set("subscribers", V::table()); let mut a = V::table(); a.set(owner, "owner".into()); v.set("affiliations", a); v } fn publish_denied( root: &Path, host: &str, user: Option<&str>, owner: &str, actor: &str, node: &V, ) -> Result { let actor_bare = bare(actor); if let Some(user) = user { if crate::account_modules::is_blocked(root, host, user, actor)? { return Ok(true); } if let Some((peer, peer_host)) = actor_bare.split_once('@') && peer_host == host && crate::account_modules::is_blocked(root, host, peer, owner)? { return Ok(true); } } let aff = tab(node, "affiliations"); let a = text(&aff, actor_bare, ""); if a == "outcast" { return Ok(true); } Ok(false) } fn allowed( root: &Path, host: &str, user: Option<&str>, owner: &str, actor: &str, node: &V, ) -> Result { let actor_bare = bare(actor); if actor_bare == owner { return Ok(true); } if publish_denied(root, host, user, owner, actor, node)? { return Ok(false); } let aff = tab(node, "affiliations"); let a = text(&aff, actor_bare, ""); if matches!(a, "owner" | "publisher" | "member") { return Ok(true); } let c = tab(node, "config"); Ok( match text( &c, "access_model", if user.is_some() { "presence" } else { "open" }, ) { "open" => true, "presence" => { if let Some(u) = user { let r = storage::load(root, host, Some(u), "roster")?.unwrap_or_default(); matches!( text(&tab(&r, actor_bare), "subscription", ""), "both" | "from" ) } else { false } } "roster" => { if let Some(u) = user { let r = storage::load(root, host, Some(u), "roster")?.unwrap_or_default(); let g = tab(&tab(&r, actor_bare), "groups"); tab(&c, "roster_groups_allowed") .entries() .iter() .any(|(_, v)| v.as_str().is_some_and(|s| g.get(s) == Some(&V::Bool(true)))) } else { false } } _ => false, }, ) } fn form(action: roxmltree::Node<'_, '_>, maximum: usize) -> std::result::Result { let Some(x) = action .children() .find(|n| n.has_tag_name(("jabber:x:data", "x"))) else { return Err("bad-request"); }; if x.attribute("type") != Some("submit") { return Err("bad-request"); } let mut out = V::table(); for f in x .children() .filter(|n| n.is_element() && n.tag_name().name() == "field") { let Some(k) = f.attribute("var") else { return Err("bad-request"); }; let vals: Vec<_> = f .children() .filter(|n| n.is_element() && n.tag_name().name() == "value") .map(|n| n.text().unwrap_or("")) .collect(); if k == "FORM_TYPE" { if !matches!( vals.first().copied(), Some( "http://jabber.org/protocol/pubsub#node_config" | "http://jabber.org/protocol/pubsub#publish-options" ) ) { return Err("bad-request"); } continue; } let Some(k) = k.strip_prefix("pubsub#") else { return Err("feature-not-implemented"); }; // XEP-0060 wire field names map to Prosody's internal config keys. let k = match k { "deliver_notifications" => "notify_items", "deliver_payloads" => "include_payload", "type" => "payload_type", _ => k, }; let value = vals.first().copied().unwrap_or(""); let v = match k { "access_model" if matches!(value, "open" | "presence" | "roster" | "whitelist") => { value.into() } "max_items" => V::Integer(if value == "max" { maximum as i64 } else { value .parse::() .ok() .filter(|n| (1..=maximum as i64).contains(n)) .ok_or("not-acceptable")? }), "persist_items" | "notify_items" | "notify_retract" | "notify_delete" | "include_payload" => V::Bool(match value { "1" | "true" => true, "0" | "false" => false, _ => return Err("bad-request"), }), "title" | "description" | "payload_type" => value.into(), "publish_model" if matches!(value, "publishers" | "open" | "subscribers") => { value.into() } "notification_type" if matches!(value, "normal" | "headline") => value.into(), "itemreply" if matches!(value, "none" | "owner" | "publisher") => value.into(), "publish_model" | "notification_type" | "itemreply" => return Err("not-acceptable"), "send_last_published_item" if matches!(value, "never" | "on_sub" | "on_sub_and_presence") => { value.into() } "roster_groups_allowed" => V::Table( vals.iter() .enumerate() .map(|(i, s)| (V::Integer(i as i64 + 1), (*s).into())) .collect(), ), _ => return Err("feature-not-implemented"), }; out.set(k, v) } Ok(out) } fn merge(a: &mut V, b: &V) { for (k, v) in b.entries() { a.set_key(k.clone(), v.clone()) } } fn form_xml(c: &V) -> String { let mut s="http://jabber.org/protocol/pubsub#node_config".to_string(); for (k, v) in c.entries() { let Some(k) = k.as_str() else { continue }; let field = match k { "notify_items" => "deliver_notifications", "include_payload" => "deliver_payloads", "payload_type" => "type", _ => k, }; s.push_str(&format!("", escape(field))); match v { V::Table(t) => { for (_, v) in t { if let Some(v) = v.as_str() { s.push_str(&format!("{}", escape(v))) } } } _ => { let value = match v { V::Bool(b) => { if *b { "1".into() } else { "0".into() } } V::Integer(n) => n.to_string(), _ => v.as_str().unwrap_or("").into(), }; s.push_str(&format!("{}", escape(&value))) } } s.push_str("") } s.push_str(""); s } #[derive(Clone, Debug)] pub struct Options { pub max_items: usize, pub restrict_node_creation: bool, pub autocreate_on_publish: bool, } impl Default for Options { fn default() -> Self { Self { max_items: 256, restrict_node_creation: true, autocreate_on_publish: false, } } } pub fn handle( root: &Path, domain: &str, jid: &str, iq: roxmltree::Node<'_, '_>, ) -> Result> { handle_with_options( root, domain, jid, iq, &Options { autocreate_on_publish: true, restrict_node_creation: false, ..Options::default() }, ) } pub fn handle_with_options( root: &Path, domain: &str, jid: &str, iq: roxmltree::Node<'_, '_>, options: &Options, ) -> Result> { // Server and resource disco describe those entities, not the virtual PEP service. if iq.children().any(|n| { n.has_tag_name(("http://jabber.org/protocol/disco#info", "query")) || n.has_tag_name(("http://jabber.org/protocol/disco#items", "query")) }) && iq.attribute("to").is_none_or(|to| to.contains('/')) { return Ok(None); } let target = bare(iq.attribute("to").unwrap_or(bare(jid))); let Some((u, h)) = target.split_once('@') else { return Ok(None); }; if h != domain { return Ok(None); } handle_inner(root, domain, Some(u), target, jid, false, iq, options) } pub fn handle_component( root: &Path, component: &str, jid: &str, admins: &[String], iq: roxmltree::Node<'_, '_>, ) -> Result> { handle_component_with_options(root, component, jid, admins, iq, &Options::default()) } pub fn handle_component_with_options( root: &Path, component: &str, jid: &str, admins: &[String], iq: roxmltree::Node<'_, '_>, options: &Options, ) -> Result> { handle_inner( root, component, None, component, jid, admins.iter().any(|a| a == bare(jid)), iq, options, ) } // Explicit storage coordinates match both PEP and component backends. #[allow(clippy::too_many_arguments)] fn handle_inner( root: &Path, host: &str, user: Option<&str>, owner: &str, jid: &str, admin: bool, iq: roxmltree::Node<'_, '_>, options: &Options, ) -> Result> { let Some(p) = iq.children().find(|n| { n.is_element() && ((n.tag_name().name() == "pubsub" && matches!(n.tag_name().namespace(), Some(NS | OWNER))) || n.has_tag_name(("http://jabber.org/protocol/disco#items", "query")) || n.has_tag_name(("http://jabber.org/protocol/disco#info", "query"))) }) else { return Ok(None); }; let _lock = LOCK .lock() .map_err(|_| anyhow::anyhow!("pubsub lock poisoned"))?; let actor = bare(jid); let own = actor == owner || admin; let fail = |s| Ok(Some(ModuleResult::reply(iq_error(iq, s)))); if let Some(u) = user && storage::load(root, host, Some(u), "accounts")?.is_none() { return fail("item-not-found"); } let pep = user.is_some(); let mut nodes = if pep { storage::load(root, host, user, "pep")?.unwrap_or_default() } else { V::table() }; // Component metadata is one keyval file per node; enumerate only its dedicated store. if !pep { let dir = storage::path(root, host, Some("dummy"), "pubsub_nodes") .parent() .unwrap() .to_owned(); if dir.exists() { for e in std::fs::read_dir(dir)? { let e = e?; if e.path().extension().and_then(|s| s.to_str()) != Some("dat") { continue; } let v = storage::parse(&std::fs::read(e.path())?)?; if let Some(name) = v.get("name").and_then(V::as_str) { nodes.set(name, v.clone()) } } } } if p.has_tag_name(("http://jabber.org/protocol/disco#info", "query")) { if iq.attribute("type") != Some("get") { return fail("bad-request"); } let node_attr = p .attribute("node") .map(|n| format!(" node='{}'", escape(n))) .unwrap_or_default(); let mut body = format!( "", if p.attribute("node").is_some() { "leaf" } else if pep { "pep" } else { "service" } ); if pep && p.attribute("node").is_none() { body.push_str(""); } if let Some(requested) = p.attribute("node") { let Some(n) = nodes.get(requested) else { return fail("item-not-found"); }; if !own && !allowed(root, host, user, owner, jid, n)? { return fail("forbidden"); } body.push_str( &form_xml(&tab(n, "config")) .replace("type='form'", "type='result'") .replace("pubsub#node_config", "pubsub#meta-data"), ); } body.push_str(""); body.push_str(&if pep { pep_features() } else { features() }); body.push_str(""); return Ok(Some(ModuleResult::reply(iq_result(iq, &body)))); } if p.tag_name().name() == "query" { if iq.attribute("type") != Some("get") { return fail("bad-request"); } if let Some(requested) = p.attribute("node") { let Some(n) = nodes.get(requested) else { return fail("item-not-found"); }; if !own && !allowed(root, host, user, owner, jid, n)? { return fail("forbidden"); } let mut s = format!( "", escape(requested) ); for entry in read_items(root, host, user, requested)? { s.push_str(&format!( "", escape(owner), escape(&entry.id) )); } s.push_str(""); return Ok(Some(ModuleResult::reply(iq_result(iq, &s)))); } let mut s = "".to_string(); for (k, v) in nodes.entries() { if (own || allowed(root, host, user, owner, jid, v)?) && k.as_str().is_some() { s.push_str(&format!( "", escape(owner), escape(k.as_str().unwrap()) )) } } s.push_str(""); return Ok(Some(ModuleResult::reply(iq_result(iq, &s)))); } let Some(a) = p.children().find(|n| n.is_element()) else { return fail("bad-request"); }; if a.tag_name().namespace() != p.tag_name().namespace() { return fail("bad-request"); } let action = a.tag_name().name(); let typ = iq.attribute("type").unwrap_or(""); let owner_ns = p.tag_name().namespace() == Some(OWNER); let node_name = a.attribute("node").map(str::to_owned).unwrap_or_else(|| { if action == "create" { uuid::Uuid::new_v4().to_string() } else { String::new() } }); let mut node = nodes.get(&node_name).cloned(); if node == Some(V::Bool(true)) { node = Some(defaults(&node_name, owner, pep)) } if let Some(n) = node.as_mut() { let mut effective = tab(&defaults(&node_name, owner, pep), "config"); merge(&mut effective, &tab(n, "config")); // Early Rust releases stored the wire alias as `type`. Canonicalize it // so a later native-form submission cannot produce duplicate fields. if let Some(legacy) = effective.get("type").cloned() { if effective.get("payload_type").is_none() { effective.set("payload_type", legacy); } effective.set("type", V::Nil); } n.set("config", effective); } let node_owner = own || node .as_ref() .is_some_and(|n| text(&tab(n, "affiliations"), actor, "") == "owner"); if owner_ns && !node_owner && !(action == "default" && typ == "get") { return fail("forbidden"); } let mut payload = String::new(); let mut notify: Option<(String, String)> = None; let mut changed = false; if action == "affiliations" && typ == "get" && !owner_ns { let mut out = String::new(); for (k, n) in nodes.entries() { let affiliation = if own { "owner" } else { n.get("affiliations") .and_then(|a| a.get(actor)) .and_then(V::as_str) .unwrap_or("none") }; if affiliation != "none" { out.push_str(&format!( "", escape(k.as_str().unwrap_or("")), escape(affiliation) )); } } payload = format!("{out}"); } else if action == "default" && typ == "get" && owner_ns { payload = format!( "{}", form_xml(&tab(&defaults("", owner, pep), "config")) ); } else if action == "subscriptions" && typ == "get" { let mut s = String::new(); for (k, n) in nodes.entries() { if !node_name.is_empty() && k.as_str() != Some(&node_name) { continue; } if owner_ns && !own && text(&tab(n, "affiliations"), actor, "") != "owner" { return fail("forbidden"); } for (j, _) in tab(n, "subscribers").entries() { if let Some(j) = j.as_str() && (owner_ns || bare(j) == actor) { s.push_str(&format!( "", escape(k.as_str().unwrap_or("")), escape(j) )) } } } payload = format!("{s}"); } else { if node_name.is_empty() { return fail("bad-request"); } match (typ, action, owner_ns) { ("set", "create", false) | ("set", "publish", false) => { let publisher = if let Some(n) = node.as_ref() { let explicit = text(&tab(n, "affiliations"), actor, "") == "publisher"; let model = tab(n, "config"); let eligible = text(&model, "publish_model", "publishers") == "open" || (text(&model, "publish_model", "publishers") == "subscribers" && tab(n, "subscribers") .entries() .iter() .any(|(j, _)| j.as_str().is_some_and(|j| bare(j) == actor))); explicit || (eligible && !publish_denied(root, host, user, owner, jid, n)?) } else { false }; let may_create = own || (!pep && !options.restrict_node_creation); if (action == "create" && !may_create) || (action == "publish" && !(node_owner || publisher || (node.is_none() && may_create))) { return fail("forbidden"); } if action == "create" && node.is_some() { return fail("conflict"); } if node.is_none() && !pep && !options.autocreate_on_publish && action == "publish" { return fail("item-not-found"); } let was_new = node.is_none(); let n = node.get_or_insert_with(|| defaults(&node_name, actor, pep)); let mut c = tab(n, "config"); if let Some(f) = p.children().find(|n| { n.is_element() && n.tag_name().name() == if action == "create" { "configure" } else { "publish-options" } }) { let opts = match form(f, options.max_items) { Ok(v) => v, Err(e) => return fail(e), }; if !was_new && opts.entries().iter().any(|(k, v)| { // Prosody persists max_items as either a number or a form string. if k.as_str() == Some("max_items") { item_limit(&c, options.max_items) != item_limit(&opts, options.max_items) } else { c.get_key(k) != Some(v) } }) { let reply = iq_error(iq, "conflict").replace( "", "", ); return Ok(Some(ModuleResult::reply(reply))); } merge(&mut c, &opts) } n.set("config", c.clone()); if action == "publish" { let items: Vec<_> = a.children().filter(|n| n.is_element()).collect(); if items.len() != 1 || !items[0].has_tag_name((NS, "item")) { return fail("bad-request"); } let i = items[0]; let children: Vec<_> = i.children().filter(|n| n.is_element()).collect(); if children.len() != 1 { return fail("bad-request"); } let id = i .attribute("id") .map(str::to_owned) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let xml = crate::protocol::xml(children[0]); write_item( root, host, user, &node_name, &id, &xml, actor, item_limit(&c, options.max_items), c.get("persist_items") != Some(&V::Bool(false)), )?; payload = format!( "", escape(&node_name), escape(&id) ); notify = Some(("items".into(), item_xml(&c, &id, &xml, actor))); } else { payload = format!("", escape(&node_name)) } changed = true; } ("get", "items", false) => { let Some(n) = node.as_ref() else { return fail("item-not-found"); }; if !own && !allowed(root, host, user, owner, jid, n)? { return fail("forbidden"); }; let wanted: Vec<_> = a .children() .filter(|n| n.is_element()) .filter_map(|n| n.attribute("id")) .collect(); let max = match a.attribute("max_items") { Some(s) => match s.parse::() { Ok(n) => n, Err(_) => return fail("bad-request"), }, None => options.max_items, }; let items = read_items(root, host, user, &node_name)?; let chosen: Vec<_> = items .iter() .rev() .filter(|i| wanted.is_empty() || wanted.contains(&i.id.as_str())) .take(max) .collect(); if !wanted.is_empty() && wanted.iter().any(|id| !chosen.iter().any(|i| i.id == *id)) { return fail("item-not-found"); } // Native Prosody returns newest first; clients commonly read // only the first item (notably OMEMO device-list parsers). let s = chosen .iter() .map(|i| item_xml(&tab(n, "config"), &i.id, &i.xml, &i.peer)) .collect::(); payload = format!("{s}", escape(&node_name)); } ("get", "configure", true) => { let Some(n) = node.as_ref() else { return fail("item-not-found"); }; payload = format!( "{}", escape(&node_name), form_xml(&tab(n, "config")) ); } ("set", "configure", true) => { let Some(n) = node.as_mut() else { return fail("item-not-found"); }; let opts = match form(a, options.max_items) { Ok(v) => v, Err(e) => return fail(e), }; let mut c = tab(n, "config"); merge(&mut c, &opts); let max = item_limit(&c, options.max_items); let mut entries = read_items(root, host, user, &node_name)?; if entries.len() > max { entries.drain(..entries.len() - max); } save_items( root, host, user, &node_name, &entries, c.get("persist_items") != Some(&V::Bool(false)), )?; n.set("config", c); changed = true; } ("set", "subscribe", false) | ("set", "unsubscribe", false) => { let Some(n) = node.as_mut() else { return fail("item-not-found"); }; let Some(sub) = a.attribute("jid") else { return fail("bad-request"); }; if bare(sub) != actor { return fail("forbidden"); } if action == "subscribe" && !own && (!allowed(root, host, user, owner, jid, n)? || !allowed(root, host, user, owner, sub, n)?) { return fail("forbidden"); }; let mut subs = tab(n, "subscribers"); let sub_options = if let Some(o) = p .children() .find(|n| n.is_element() && n.tag_name().name() == "options") { match subscription_form(o) { Ok(v) => v, Err(e) => return fail(e), } } else { V::Bool(true) }; subs.set( sub, if action == "subscribe" { sub_options } else { V::Nil }, ); n.set("subscribers", subs); changed = true; payload = format!( "", escape(&node_name), escape(sub), if action == "subscribe" { "subscribed" } else { "none" } ); } ("get", "affiliations", true) => { let Some(n) = node.as_ref() else { return fail("item-not-found"); }; let mut out = String::new(); for (j, a) in tab(n, "affiliations").entries() { if let (Some(j), Some(a)) = (j.as_str(), a.as_str()) { out.push_str(&format!( "", escape(j), escape(a) )) } } payload = format!( "{out}", escape(&node_name) ); } ("set", "affiliations", true) => { let Some(n) = node.as_mut() else { return fail("item-not-found"); }; let mut aff = tab(n, "affiliations"); for a in a.children().filter(|n| n.is_element()) { let (Some(j), Some(v)) = (a.attribute("jid"), a.attribute("affiliation")) else { return fail("bad-request"); }; if !j.contains('@') || j.contains('/') || !matches!(v, "owner" | "publisher" | "member" | "outcast" | "none") { return fail("bad-request"); } if pep && j == owner && v != "owner" { return fail("forbidden"); } aff.set(j, if v == "none" { V::Nil } else { v.into() }); } if !aff .entries() .iter() .any(|(_, v)| v.as_str() == Some("owner")) { return fail("not-acceptable"); } n.set("affiliations", aff); changed = true; } ("set", "subscriptions", true) => { let Some(n) = node.as_mut() else { return fail("item-not-found"); }; let mut subs = tab(n, "subscribers"); for a in a.children().filter(|n| n.is_element()) { let (Some(j), Some(v)) = (a.attribute("jid"), a.attribute("subscription")) else { return fail("bad-request"); }; if !j.contains('@') || !matches!(v, "subscribed" | "none") { return fail("bad-request"); } subs.set(j, if v == "none" { V::Nil } else { V::Bool(true) }); } n.set("subscribers", subs); changed = true; } ("get", "options", false) | ("set", "options", false) => { let Some(n) = node.as_mut() else { return fail("item-not-found"); }; let Some(sub) = a.attribute("jid") else { return fail("bad-request"); }; if bare(sub) != actor { return fail("forbidden"); } let mut subs = tab(n, "subscribers"); let Some(current) = subs.get(sub).cloned() else { return fail("item-not-found"); }; if typ == "get" { let deliver = current.get("pubsub#deliver") != Some(&V::Bool(false)); payload = format!( "http://jabber.org/protocol/pubsub#subscribe_options{}", escape(&node_name), escape(sub), if deliver { "1" } else { "0" } ); } else { let opts = match subscription_form(a) { Ok(v) => v, Err(e) => return fail(e), }; subs.set(sub, opts); n.set("subscribers", subs); changed = true; } } ("set", "retract", false) => { if !node_owner { return fail("forbidden"); } if node.is_none() { return fail("item-not-found"); } let Some(id) = a .children() .find(|n| n.is_element()) .and_then(|n| n.attribute("id")) else { return fail("bad-request"); }; delete_items(root, host, user, &node_name, Some(id))?; if matches!(a.attribute("notify"), Some("1" | "true")) { notify = Some(("retract".into(), format!("", escape(id)))) } } ("set", "purge", true) | ("set", "delete", true) => { if node.is_none() { return fail("item-not-found"); } delete_items(root, host, user, &node_name, None)?; notify = Some((action.into(), String::new())); if action == "delete" { changed = true; } } _ => return fail("feature-not-implemented"), } } let wrapped = if payload.is_empty() { String::new() } else { format!( "{payload}", if owner_ns { OWNER } else { NS } ) }; let mut result = ModuleResult::reply(iq_result(iq, &wrapped)); if let (Some((kind, item)), Some(n)) = (notify, node.as_ref()) { let c = tab(n, "config"); if c.get(&format!("notify_{kind}")) != Some(&V::Bool(false)) { // Check notify_retract before mapping the event to its wire container. let kind = if kind == "retract" { "items" } else { &kind }; let mut recipients = tab(n, "subscribers"); if pep { for recipient in interested(root, owner, &node_name)? { if recipients.get(&recipient).is_none() { recipients.set(&recipient, V::Bool(true)); } } } for (j, opts) in recipients.entries() { if opts.get("pubsub#deliver") == Some(&V::Bool(false)) { continue; } if let Some(j) = j.as_str() && allowed(root, host, user, owner, j, n)? { let item = notification_payload(&c, &item); result.deliveries.push((j.into(),format!("<{} node='{}'>{}",escape(owner),escape(j),notification_type(&c, pep),kind,escape(&node_name),item,kind))) } } } } if pep && node_name == "urn:xmpp:bookmarks:1" && typ == "set" && matches!(action, "publish" | "retract" | "purge" | "delete") && let Some(meta) = node.as_ref() { let entries = read_items(root, host, user, &node_name)?; let items = entries .iter() .map(|e| (e.id.clone(), format!("{}", e.xml))) .collect::>(); let legacy = crate::account_modules::legacy_bookmark_storage(&items)?; for recipient in interested(root, owner, "storage:bookmarks")? { if allowed(root, host, user, owner, &recipient, meta)? { let item = notification_payload( &tab(meta, "config"), &format!("{legacy}"), ); result.deliveries.push((recipient.clone(),format!("{item}",escape(owner),escape(&recipient)))); } } } if action == "subscribe" && typ == "set" && node .as_ref() .and_then(|n| n.get("subscribers")) .and_then(|s| s.get(a.attribute("jid").unwrap_or(""))) .and_then(|o| o.get("pubsub#deliver")) != Some(&V::Bool(false)) && let Some(n) = node.as_ref() && text( &tab(n, "config"), "send_last_published_item", "on_sub_and_presence", ) != "never" && let Some(last) = read_items(root, host, user, &node_name)?.last() { let to = a.attribute("jid").unwrap(); let item = notification_payload( &tab(n, "config"), &item_xml(&tab(n, "config"), &last.id, &last.xml, &last.peer), ); result.deliveries.push((to.into(),format!("{item}",escape(owner),escape(to),notification_type(&tab(n, "config"), pep),escape(&node_name)))); } if changed { if action == "delete" { EPHEMERAL .lock() .unwrap() .remove(&ephemeral_key(root, host, user, &node_name)); node = None } if pep { nodes.set(&node_name, node.clone().unwrap_or(V::Nil)); storage::save(root, host, user, "pep", &nodes)? } else if let Some(n) = node { storage::save(root, host, Some(&node_name), "pubsub_nodes", &n)? } else { let path = storage::path(root, host, Some(&node_name), "pubsub_nodes"); if path.exists() { std::fs::remove_file(path)? } } } Ok(Some(result)) } // Native mod_pep uses headline messages, while mod_pubsub honors this option. fn notification_type(config: &V, pep: bool) -> &str { if !pep && text(config, "notification_type", "headline") == "normal" { "normal" } else { "headline" } } // Native handlers expose item publisher JIDs only for itemreply=publisher; // owner and none both keep them private. Publishers are stored bare in Entry::peer. fn item_xml(config: &V, id: &str, payload: &str, publisher: &str) -> String { let publisher = if text(config, "itemreply", "none") == "publisher" && !publisher.is_empty() { format!(" publisher='{}'", escape(bare(publisher))) } else { String::new() }; format!("{payload}", escape(id)) } // Apply the same notification policy to live events and last-item replays. fn notification_payload(config: &V, item: &str) -> String { if config.get("include_payload") == Some(&V::Bool(false)) && let Ok(doc) = roxmltree::Document::parse(item) && doc.root_element().tag_name().name() == "item" { let item = doc.root_element(); let publisher = item .attribute("publisher") .map(|j| format!(" publisher='{}'", escape(j))) .unwrap_or_default(); return format!( "", escape(item.attribute("id").unwrap_or("")) ); } item.to_owned() } fn store(user: Option<&str>, node: &str) -> String { format!("{}_{}", if user.is_some() { "pep" } else { "pubsub" }, node) } fn read_items( root: &Path, host: &str, user: Option<&str>, node: &str, ) -> Result> { if let Some(entries) = EPHEMERAL .lock() .unwrap() .get(&ephemeral_key(root, host, user, node)) { return Ok(entries.clone()); } if !persistent(root, host, user, node)? { return Ok(vec![]); } crate::archive::load_store(root, host, user, &store(user, node)) } // Explicit storage coordinates match both PEP and component backends. #[allow(clippy::too_many_arguments)] fn write_item( root: &Path, host: &str, user: Option<&str>, node: &str, id: &str, xml: &str, actor: &str, max: usize, persist: bool, ) -> Result<()> { let mut entries = read_items(root, host, user, node)?; entries.retain(|e| e.id != id); entries.push(crate::archive::Entry { id: id.into(), when: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_secs_f64(), peer: actor.into(), xml: xml.into(), }); if entries.len() > max { entries.drain(..entries.len() - max); } save_items(root, host, user, node, &entries, persist) } fn delete_items( root: &Path, host: &str, user: Option<&str>, node: &str, id: Option<&str>, ) -> Result<()> { let mut entries = read_items(root, host, user, node)?; entries.retain(|e| id.is_some_and(|id| id != e.id)); save_items( root, host, user, node, &entries, persistent(root, host, user, node)?, ) } use std::collections::{BTreeMap, BTreeSet}; use std::sync::LazyLock; #[derive(Default)] struct Interests { subscriptions: BTreeMap<(String, String, String), BTreeSet>, pending: BTreeMap<(String, String, String), Vec>, } static INTERESTS: LazyLock> = LazyLock::new(|| Mutex::new(Interests::default())); fn interested(root: &Path, owner: &str, node: &str) -> Result> { let root_key = root.to_string_lossy(); let candidates: Vec<_> = INTERESTS .lock() .unwrap() .subscriptions .iter() .filter(|((r, o, _), nodes)| r == root_key.as_ref() && o == owner && nodes.contains(node)) .map(|((_, _, j), _)| j.clone()) .collect(); let mut out = vec![]; for jid in candidates { if presence_authorized(root, owner, &jid)? { out.push(jid) } } Ok(out) } fn presence_authorized(root: &Path, owner: &str, recipient: &str) -> Result { if bare(recipient) == owner { return Ok(true); } let Some((user, host)) = owner.split_once('@') else { return Ok(false); }; if crate::account_modules::is_blocked(root, host, user, recipient)? { return Ok(false); } let roster = storage::load(root, host, Some(user), "roster")?.unwrap_or_default(); Ok(matches!( text(&tab(&roster, bare(recipient)), "subscription", ""), "from" | "both" )) } /// Call with the authenticated full JID, never a client-supplied from attribute. pub fn on_presence( root: &Path, domain: &str, jid: &str, presence: roxmltree::Node<'_, '_>, ) -> Result { if presence.attribute("to").is_some() || presence .attribute("type") .is_some_and(|t| t != "unavailable" && t != "error") { return Ok(ModuleResult::default()); } if presence.attribute("type").is_none() && !presence .children() .any(|n| n.has_tag_name(("http://jabber.org/protocol/caps", "c"))) { return Ok(ModuleResult::default()); } let root_key = root.to_string_lossy().into_owned(); let mut state = INTERESTS.lock().unwrap(); state .subscriptions .retain(|(r, _, j), _| r != &root_key || j != jid); state .pending .retain(|(r, j, _), _| r != &root_key || j != jid); if matches!(presence.attribute("type"), Some("unavailable" | "error")) { return Ok(ModuleResult::default()); } if presence.attribute("type").is_some() || presence.attribute("to").is_some() { return Ok(ModuleResult::default()); } if !presence .children() .any(|n| n.has_tag_name(("http://jabber.org/protocol/caps", "c"))) { return Ok(ModuleResult::default()); } let Some((user, host)) = bare(jid).split_once('@') else { return Ok(ModuleResult::default()); }; if host != domain { return Ok(ModuleResult::default()); } let mut owners = vec![bare(jid).to_owned()]; let roster = storage::load(root, domain, Some(user), "roster")?.unwrap_or_default(); for (k, v) in roster.entries() { if matches!(text(v, "subscription", ""), "to" | "both") && let Some(j) = k.as_str() && j.rsplit_once('@').is_some_and(|(_, h)| h == domain) { owners.push(j.into()) } } if state.pending.len() > 10000 || state.subscriptions.len() > 50000 { return Ok(ModuleResult::default()); } let id = format!("pep-caps-{}", uuid::Uuid::new_v4()); state .pending .insert((root_key, jid.into(), id.clone()), owners); let query = format!( "", escape(domain), escape(jid), escape(&id) ); Ok(ModuleResult { replies: vec![], deliveries: vec![(jid.into(), query)], }) } /// Consume only server-issued capability requests correlated to the authenticated resource. pub fn on_disco_result( root: &Path, domain: &str, jid: &str, iq: roxmltree::Node<'_, '_>, ) -> Result> { if !matches!(iq.attribute("type"), Some("result" | "error")) { return Ok(None); } let root_key = root.to_string_lossy().into_owned(); let key = ( root_key.clone(), jid.into(), iq.attribute("id").unwrap_or("").into(), ); let owners = { INTERESTS.lock().unwrap().pending.remove(&key) }; let Some(owners) = owners else { return Ok(None); }; let mut result = ModuleResult::default(); if iq.attribute("type") == Some("error") { return Ok(Some(result)); } let Some(query) = iq .children() .find(|n| n.has_tag_name(("http://jabber.org/protocol/disco#info", "query"))) else { return Ok(Some(result)); }; let nodes: BTreeSet = query .children() .filter(|n| n.has_tag_name(("http://jabber.org/protocol/disco#info", "feature"))) .filter_map(|n| n.attribute("var")) .filter_map(|v| v.strip_suffix("+notify").map(str::to_owned)) .take(1024) .collect(); let _lock = LOCK.lock().unwrap(); for owner in owners { let Some((user, host)) = owner.split_once('@') else { continue; }; if host != domain { continue; } if !presence_authorized(root, &owner, jid)? { continue; } let metadata = storage::load(root, domain, Some(user), "pep")?.unwrap_or_default(); let mut permitted = BTreeSet::new(); for node in &nodes { // Retain interests for nodes which are created after discovery; delivery rechecks ACLs. permitted.insert(node.clone()); if node == "storage:bookmarks" { if let Some(meta) = metadata.get("urn:xmpp:bookmarks:1") && allowed(root, domain, Some(user), &owner, jid, meta)? { let entries = read_items(root, domain, Some(user), "urn:xmpp:bookmarks:1")?; let items = entries .iter() .map(|e| (e.id.clone(), format!("{}", e.xml))) .collect::>(); let legacy = crate::account_modules::legacy_bookmark_storage(&items)?; let item = notification_payload( &tab(meta, "config"), &format!("{legacy}"), ); result.deliveries.push((jid.into(),format!("{item}",escape(&owner),escape(jid)))); } continue; } let Some(meta) = metadata.get(node) else { continue; }; if !allowed(root, domain, Some(user), &owner, jid, meta)? { continue; } if text( &tab(meta, "config"), "send_last_published_item", "on_sub_and_presence", ) != "on_sub_and_presence" { continue; } if let Some(entry) = read_items(root, domain, Some(user), node)?.last() { let item = notification_payload( &tab(meta, "config"), &item_xml(&tab(meta, "config"), &entry.id, &entry.xml, &entry.peer), ); result.deliveries.push((jid.into(),format!("{item}",escape(&owner),escape(jid),escape(node)))); } } INTERESTS .lock() .unwrap() .subscriptions .insert((root_key.clone(), owner, jid.into()), permitted); } Ok(Some(result)) } /// Common features implemented by both the component and account PEP services. pub fn features() -> String { let mut s = format!(""); for feature in [ "access-open", "access-presence", "access-whitelist", "access-roster", "config-node", "config-node-max", "create-and-configure", "create-nodes", "delete-items", "delete-nodes", "item-ids", "manage-subscriptions", "modify-affiliations", "multi-items", "persistent-items", "publish", "publish-options", "purge-nodes", "retrieve-affiliations", "retrieve-items", "retrieve-subscriptions", "subscribe", "last-published", ] { s.push_str(&format!("")); } s } /// Account capabilities backed by automatic creation and presence/caps handling. /// Advertise these on the account service rather than on the server or component. pub fn pep_features() -> String { let mut s = features(); for feature in ["auto-create", "auto-subscribe", "filtered-notifications"] { s.push_str(&format!("")); } s } fn item_limit(config: &V, maximum: usize) -> usize { config .get("max_items") .and_then(|v| { v.as_i64() .and_then(|n| usize::try_from(n).ok()) .or_else(|| { v.as_str().and_then(|s| { if s == "max" { Some(maximum) } else { s.parse().ok() } }) }) }) .unwrap_or(1) .min(maximum) } fn subscription_form(action: roxmltree::Node<'_, '_>) -> std::result::Result { let Some(x) = action .children() .find(|n| n.has_tag_name(("jabber:x:data", "x"))) else { return Err("bad-request"); }; if x.attribute("type") != Some("submit") { return Err("bad-request"); } let mut out = V::table(); for f in x.children().filter(|n| n.is_element()) { let Some(var) = f.attribute("var") else { return Err("bad-request"); }; let value = f .children() .find(|n| n.is_element() && n.tag_name().name() == "value") .and_then(|n| n.text()) .unwrap_or(""); if var == "FORM_TYPE" { if value != "http://jabber.org/protocol/pubsub#subscribe_options" { return Err("bad-request"); } continue; } if var != "pubsub#deliver" { return Err("feature-not-implemented"); } out.set( var, V::Bool(match value { "true" | "1" => true, "false" | "0" => false, _ => return Err("bad-request"), }), ); } Ok(out) } type EphemeralKey = (String, String, Option, String); static EPHEMERAL: LazyLock>>> = LazyLock::new(|| Mutex::new(BTreeMap::new())); fn ephemeral_key(root: &Path, host: &str, user: Option<&str>, node: &str) -> EphemeralKey { ( root.to_string_lossy().into_owned(), host.into(), user.map(str::to_owned), node.into(), ) } fn persistent(root: &Path, host: &str, user: Option<&str>, node: &str) -> Result { let meta = if user.is_some() { storage::load(root, host, user, "pep")?.and_then(|v| v.get(node).cloned()) } else { storage::load(root, host, Some(node), "pubsub_nodes")? }; Ok(meta .and_then(|v| v.get("config").cloned()) .and_then(|v| v.get("persist_items").cloned()) != Some(V::Bool(false))) } fn save_items( root: &Path, host: &str, user: Option<&str>, node: &str, entries: &[crate::archive::Entry], persist: bool, ) -> Result<()> { let key = ephemeral_key(root, host, user, node); if persist { crate::archive::save_store(root, host, user, &store(user, node), entries)?; EPHEMERAL.lock().unwrap().remove(&key); } else { let mut cache = EPHEMERAL.lock().unwrap(); anyhow::ensure!( cache.contains_key(&key) || cache.len() < 10000, "ephemeral node limit" ); let total = entries.iter().map(|e| e.xml.len()).sum::(); anyhow::ensure!(total <= 16 * 1024 * 1024, "ephemeral payload limit"); let existing = cache .iter() .filter(|(k, _)| *k != &key) .flat_map(|(_, items)| items) .map(|e| e.xml.len()) .sum::(); anyhow::ensure!( existing.saturating_add(total) <= 64 * 1024 * 1024, "ephemeral global payload limit" ); cache.insert(key, entries.to_vec()); crate::archive::save_store(root, host, user, &store(user, node), &[])?; } Ok(()) } /// Federated presence callback. Caller must authenticate `verified_from` against the s2s stream domain. /// Only the addressed account's interests are changed; presence for another contact is independent. pub fn on_remote_presence( root: &Path, domain: &str, verified_from: &str, presence: roxmltree::Node<'_, '_>, ) -> Result { let Some(owner) = presence.attribute("to").map(bare) else { return Ok(ModuleResult::default()); }; let Some((user, host)) = owner.split_once('@') else { return Ok(ModuleResult::default()); }; let Some((_, remote_host)) = bare(verified_from).split_once('@') else { return Ok(ModuleResult::default()); }; if host != domain || remote_host == domain || presence .attribute("from") .is_some_and(|f| f != verified_from) { return Ok(ModuleResult::default()); } let unavailable = matches!(presence.attribute("type"), Some("unavailable" | "error")); if !verified_from.contains('/') && !unavailable { return Ok(ModuleResult::default()); } if presence.attribute("type").is_some() && !unavailable { return Ok(ModuleResult::default()); } let has_caps = presence .children() .any(|n| n.has_tag_name(("http://jabber.org/protocol/caps", "c"))); if !unavailable && !has_caps { return Ok(ModuleResult::default()); } let root_key = root.to_string_lossy().into_owned(); { let mut state = INTERESTS.lock().unwrap(); state.subscriptions.retain(|(r, o, j), _| { !(r == &root_key && o == owner && (j == verified_from || (!verified_from.contains('/') && bare(j) == verified_from))) }); state.pending.retain(|(r, j, _), owners| { if r == &root_key && (j == verified_from || (!verified_from.contains('/') && bare(j) == verified_from)) { owners.retain(|o| o != owner) } !owners.is_empty() }); } if unavailable || !presence_authorized(root, owner, verified_from)? || storage::load(root, domain, Some(user), "accounts")?.is_none() { return Ok(ModuleResult::default()); } let id = format!("pep-caps-{}", uuid::Uuid::new_v4()); let mut state = INTERESTS.lock().unwrap(); if state.pending.len() >= 10000 || state.subscriptions.len() >= 50000 { return Ok(ModuleResult::default()); } state.pending.insert( (root_key, verified_from.into(), id.clone()), vec![owner.into()], ); Ok(ModuleResult { replies: vec![], deliveries: vec![( verified_from.into(), format!( "", escape(owner), escape(verified_from), escape(&id) ), )], }) }