-- Prosody IM -- Copyright (C) 2009-2010 Matthew Wild -- Copyright (C) 2009-2010 Waqas Hussain -- Copyright (C) 2014-2015 Kim Alvefur -- -- This project is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. -- -- This module implements XEP-0191: Blocking Command -- local user_exists = require"prosody.core.usermanager".user_exists; local rostermanager = require"prosody.core.rostermanager"; local is_contact_subscribed = rostermanager.is_contact_subscribed; local is_contact_pending_in = rostermanager.is_contact_pending_in; local load_roster = rostermanager.load_roster; local save_roster = rostermanager.save_roster; local st = require"prosody.util.stanza"; local st_error_reply = st.error_reply; local jid_prep = require"prosody.util.jid".prep; local jid_split = require"prosody.util.jid".split; local storage = module:open_store(); local sessions = prosody.hosts[module.host].sessions; local full_sessions = prosody.full_sessions; -- Cache of blocklists, keeps a fixed number of items. -- -- The size of this affects how often we will need to load a blocklist from -- disk, which we want to avoid during routing. On the other hand, we don't -- want to use too much memory either, so this can be tuned by advanced -- users. TODO use science to figure out a better default, 64 is just a guess. local cache_size = module:get_option_integer("blocklist_cache_size", 256, 1); local blocklist_cache = require"prosody.util.cache".new(cache_size); local null_blocklist = {}; module:add_feature("urn:xmpp:blocking"); local function set_blocklist(username, blocklist) local ok, err = storage:set(username, blocklist); if not ok then return ok, err; end -- Successful save, update the cache blocklist_cache:set(username, blocklist); return true; end -- Migrates from the old mod_privacy storage -- TODO mod_privacy was removed in 0.10.0, this should be phased out local function migrate_privacy_list(username) local legacy_data = module:open_store("privacy"):get(username); if not legacy_data or not legacy_data.lists or not legacy_data.default then return; end local default_list = legacy_data.lists[legacy_data.default]; if not default_list or not default_list.items then return; end local migrated_data = { [false] = { created = os.time(); migrated = "privacy" }}; module:log("info", "Migrating blocklist from mod_privacy storage for user '%s'", username); for _, item in ipairs(default_list.items) do if item.type == "jid" and item.action == "deny" then local jid = jid_prep(item.value); if not jid then module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, item.value); else migrated_data[jid] = true; end end end set_blocklist(username, migrated_data); return migrated_data; end if not module:get_option_boolean("migrate_legacy_blocking", true) then migrate_privacy_list = function (username) module:log("debug", "Migrating from mod_privacy disabled, user '%s' will start with a fresh blocklist", username); return nil; end end local function get_blocklist(username) local blocklist = blocklist_cache:get(username); if not blocklist then if not user_exists(username, module.host) then return null_blocklist; end blocklist = storage:get(username); if not blocklist then blocklist = migrate_privacy_list(username); end if not blocklist then blocklist = { [false] = { created = os.time(); }; }; end blocklist_cache:set(username, blocklist); end return blocklist; end module:hook("iq-get/self/urn:xmpp:blocking:blocklist", function (event) local origin, stanza = event.origin, event.stanza; local username = origin.username; local reply = st.reply(stanza):tag("blocklist", { xmlns = "urn:xmpp:blocking" }); local blocklist = get_blocklist(username); for jid in pairs(blocklist) do if jid then reply:tag("item", { jid = jid }):up(); end end origin.interested_blocklist = true; -- Gets notified about changes origin.send(reply); return true; end, -1); -- Add or remove some jid(s) from the blocklist -- We want this to be atomic and not do a partial update local function edit_blocklist(event) local now = os.time(); local origin, stanza = event.origin, event.stanza; local username = origin.username; local action = stanza.tags[1]; -- "block" or "unblock" local is_blocking = action.name == "block" and now or nil; -- nil if unblocking local new = {}; -- JIDs to block depending or unblock on action -- XEP-0191 sayeth: -- > When the user blocks communications with the contact, the user's -- > server MUST send unavailable presence information to the contact (but -- > only if the contact is allowed to receive presence notifications [...] -- So contacts we need to do that for are added to the set below. local send_unavailable = is_blocking and {}; local send_available = not is_blocking and {}; -- Because blocking someone currently also blocks the ability to reject -- subscription requests, we'll preemptively reject such local remove_pending = is_blocking and {}; for item in action:childtags("item") do local jid = jid_prep(item.attr.jid); if not jid then origin.send(st_error_reply(stanza, "modify", "jid-malformed")); return true; end item.attr.jid = jid; -- echo back prepped new[jid] = true; if is_blocking then if is_contact_subscribed(username, module.host, jid) then send_unavailable[jid] = true; elseif is_contact_pending_in(username, module.host, jid) then remove_pending[jid] = true; end elseif is_contact_subscribed(username, module.host, jid) then send_available[jid] = true; end end if is_blocking and not next(new) then -- element does not contain at least one child element origin.send(st_error_reply(stanza, "modify", "bad-request")); return true; end local blocklist = get_blocklist(username); local new_blocklist = { -- We set the [false] key to something as a signal not to migrate privacy lists [false] = blocklist[false] or { created = now; }; }; if type(blocklist[false]) == "table" then new_blocklist[false].modified = now; end if is_blocking or next(new) then for jid, t in pairs(blocklist) do if jid then new_blocklist[jid] = t; end end for jid in pairs(new) do new_blocklist[jid] = is_blocking; end -- else empty the blocklist end local ok, err = set_blocklist(username, new_blocklist); if ok then origin.send(st.reply(stanza)); else origin.send(st_error_reply(stanza, "wait", "internal-server-error", err)); return true; end if is_blocking then for jid in pairs(send_unavailable) do -- Check that this JID isn't already blocked, i.e. this is not a change if not blocklist[jid] then for _, session in pairs(sessions[username].sessions) do if session.presence then module:send(st.presence({ type = "unavailable", to = jid, from = session.full_jid })); end end end end if next(remove_pending) then local roster = load_roster(username, module.host); for jid in pairs(remove_pending) do roster[false].pending[jid] = nil; end save_roster(username, module.host, roster); -- Not much we can do about save failing here end else local user_bare = username .. "@" .. module.host; for jid in pairs(send_available) do module:send(st.presence({ type = "probe", to = user_bare, from = jid })); end end local blocklist_push = st.iq({ type = "set", id = "blocklist-push" }) :add_child(action); -- I am lazy for _, session in pairs(sessions[username].sessions) do if session.interested_blocklist then blocklist_push.attr.to = session.full_jid; session.send(blocklist_push); end end return true; end module:hook("iq-set/self/urn:xmpp:blocking:block", edit_blocklist, -1); module:hook("iq-set/self/urn:xmpp:blocking:unblock", edit_blocklist, -1); -- Cache invalidation, solved! module:hook_global("user-deleted", function (event) if event.host == module.host then blocklist_cache:set(event.username, nil); end end); -- Buggy clients module:hook("iq-error/self/blocklist-push", function (event) local origin, stanza = event.origin, event.stanza; local _, condition, text = stanza:get_error(); local log = (origin.log or module._log); log("warn", "Client returned an error in response to notification from mod_%s: %s%s%s", module.name, condition, text and ": " or "", text or ""); return true; end); local function is_blocked(user, jid) local blocklist = get_blocklist(user); if blocklist[jid] then return true; end local node, host = jid_split(jid); return blocklist[host] or node and blocklist[node..'@'..host]; end -- Event handlers for bouncing or dropping stanzas local function drop_stanza(event) local stanza = event.stanza; local attr = stanza.attr; local to, from = attr.to, attr.from; to = to and jid_split(to); if to and from then if is_blocked(to, from) then return true; end -- Check mediated MUC inviter if stanza.name == "message" then local invite = stanza:find("{http://jabber.org/protocol/muc#user}x/invite"); if invite then from = jid_prep(invite.attr.from); if is_blocked(to, from) then return true; end end end end end local function bounce_stanza(event) local origin, stanza = event.origin, event.stanza; if drop_stanza(event) then origin.send(st_error_reply(stanza, "cancel", "service-unavailable")); return true; end end local function bounce_iq(event) local type = event.stanza.attr.type; if type == "set" or type == "get" then return bounce_stanza(event); end return drop_stanza(event); -- result or error end local function bounce_message(event) local stanza = event.stanza; local type = stanza.attr.type; if type == "chat" or not type or type == "normal" then if full_sessions[stanza.attr.to] then -- See #690 return drop_stanza(event); end return bounce_stanza(event); end return drop_stanza(event); -- drop headlines, groupchats etc end local function drop_outgoing(event) local origin, stanza = event.origin, event.stanza; local username = origin.username or jid_split(stanza.attr.from); if not username then return end local to = stanza.attr.to; if to then return is_blocked(username, to); end -- nil 'to' means a self event, don't bock those end local function bounce_outgoing(event) local origin, stanza = event.origin, event.stanza; local type = stanza.attr.type; if type == "error" or stanza.name == "iq" and type == "result" then return drop_outgoing(event); end if drop_outgoing(event) then origin.send(st_error_reply(stanza, "cancel", "not-acceptable", "You have blocked this JID") :tag("blocked", { xmlns = "urn:xmpp:blocking:errors" })); return true; end end -- Hook all the events! local prio_in, prio_out = 100, 100; module:hook("presence/bare", drop_stanza, prio_in); module:hook("presence/full", drop_stanza, prio_in); if module:get_option_boolean("bounce_blocked_messages", false) then module:hook("message/bare", bounce_message, prio_in); module:hook("message/full", bounce_message, prio_in); else module:hook("message/bare", drop_stanza, prio_in); module:hook("message/full", drop_stanza, prio_in); end module:hook("iq/bare", bounce_iq, prio_in); module:hook("iq/full", bounce_iq, prio_in); module:hook("pre-message/bare", bounce_outgoing, prio_out); module:hook("pre-message/full", bounce_outgoing, prio_out); module:hook("pre-message/host", bounce_outgoing, prio_out); module:hook("pre-presence/bare", bounce_outgoing, -1); module:hook("pre-presence/host", bounce_outgoing, -1); module:hook("pre-presence/full", bounce_outgoing, prio_out); module:hook("pre-iq/bare", bounce_outgoing, prio_out); module:hook("pre-iq/full", bounce_outgoing, prio_out); module:hook("pre-iq/host", bounce_outgoing, prio_out);