1-- Prosody IM 2-- Copyright (C) 2009-2010 Matthew Wild 3-- Copyright (C) 2009-2010 Waqas Hussain 4-- Copyright (C) 2014-2015 Kim Alvefur 5-- 6-- This project is MIT/X11 licensed. Please see the 7-- COPYING file in the source package for more information. 8-- 9-- This module implements XEP-0191: Blocking Command 10-- 11 12local user_exists = require"core.usermanager".user_exists; 13local rostermanager = require"core.rostermanager"; 14local is_contact_subscribed = rostermanager.is_contact_subscribed; 15local is_contact_pending_in = rostermanager.is_contact_pending_in; 16local load_roster = rostermanager.load_roster; 17local save_roster = rostermanager.save_roster; 18local st = require"util.stanza"; 19local st_error_reply = st.error_reply; 20local jid_prep = require"util.jid".prep; 21local jid_split = require"util.jid".split; 22 23local storage = module:open_store(); 24local sessions = prosody.hosts[module.host].sessions; 25local full_sessions = prosody.full_sessions; 26 27-- First level cache of blocklists by username. 28-- Weak table so may randomly expire at any time. 29local cache = setmetatable({}, { __mode = "v" }); 30 31-- Second level of caching, keeps a fixed number of items, also anchors 32-- items in the above cache. 33-- 34-- The size of this affects how often we will need to load a blocklist from 35-- disk, which we want to avoid during routing. On the other hand, we don't 36-- want to use too much memory either, so this can be tuned by advanced 37-- users. TODO use science to figure out a better default, 64 is just a guess. 38local cache_size = module:get_option_number("blocklist_cache_size", 64); 39local cache2 = require"util.cache".new(cache_size); 40 41local null_blocklist = {}; 42 43module:add_feature("urn:xmpp:blocking"); 44 45local function set_blocklist(username, blocklist) 46 local ok, err = storage:set(username, blocklist); 47 if not ok then 48 return ok, err; 49 end 50 -- Successful save, update the cache 51 cache2:set(username, blocklist); 52 cache[username] = blocklist; 53 return true; 54end 55 56-- Migrates from the old mod_privacy storage 57local function migrate_privacy_list(username) 58 local legacy_data = module:open_store("privacy"):get(username); 59 if not legacy_data or not legacy_data.lists or not legacy_data.default then return; end 60 local default_list = legacy_data.lists[legacy_data.default]; 61 if not default_list or not default_list.items then return; end 62 63 local migrated_data = { [false] = { created = os.time(); migrated = "privacy" }}; 64 65 module:log("info", "Migrating blocklist from mod_privacy storage for user '%s'", username); 66 for _, item in ipairs(default_list.items) do 67 if item.type == "jid" and item.action == "deny" then 68 local jid = jid_prep(item.value); 69 if not jid then 70 module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, tostring(item.value)); 71 else 72 migrated_data[jid] = true; 73 end 74 end 75 end 76 set_blocklist(username, migrated_data); 77 return migrated_data; 78end 79 80local function get_blocklist(username) 81 local blocklist = cache2:get(username); 82 if not blocklist then 83 if not user_exists(username, module.host) then 84 return null_blocklist; 85 end 86 blocklist = storage:get(username); 87 if not blocklist then 88 blocklist = migrate_privacy_list(username); 89 end 90 if not blocklist then 91 blocklist = { [false] = { created = os.time(); }; }; 92 end 93 cache2:set(username, blocklist); 94 end 95 cache[username] = blocklist; 96 return blocklist; 97end 98 99module:hook("iq-get/self/urn:xmpp:blocking:blocklist", function (event) 100 local origin, stanza = event.origin, event.stanza; 101 local username = origin.username; 102 local reply = st.reply(stanza):tag("blocklist", { xmlns = "urn:xmpp:blocking" }); 103 local blocklist = cache[username] or get_blocklist(username); 104 for jid in pairs(blocklist) do 105 if jid then 106 reply:tag("item", { jid = jid }):up(); 107 end 108 end 109 origin.interested_blocklist = true; -- Gets notified about changes 110 origin.send(reply); 111 return true; 112end, -1); 113 114-- Add or remove some jid(s) from the blocklist 115-- We want this to be atomic and not do a partial update 116local function edit_blocklist(event) 117 local now = os.time(); 118 local origin, stanza = event.origin, event.stanza; 119 local username = origin.username; 120 local action = stanza.tags[1]; -- "block" or "unblock" 121 local is_blocking = action.name == "block" and now or nil; -- nil if unblocking 122 local new = {}; -- JIDs to block depending or unblock on action 123 124 125 -- XEP-0191 sayeth: 126 -- > When the user blocks communications with the contact, the user's 127 -- > server MUST send unavailable presence information to the contact (but 128 -- > only if the contact is allowed to receive presence notifications [...] 129 -- So contacts we need to do that for are added to the set below. 130 local send_unavailable = is_blocking and {}; 131 local send_available = not is_blocking and {}; 132 133 -- Because blocking someone currently also blocks the ability to reject 134 -- subscription requests, we'll preemptively reject such 135 local remove_pending = is_blocking and {}; 136 137 for item in action:childtags("item") do 138 local jid = jid_prep(item.attr.jid); 139 if not jid then 140 origin.send(st_error_reply(stanza, "modify", "jid-malformed")); 141 return true; 142 end 143 item.attr.jid = jid; -- echo back prepped 144 new[jid] = true; 145 if is_blocking then 146 if is_contact_subscribed(username, module.host, jid) then 147 send_unavailable[jid] = true; 148 elseif is_contact_pending_in(username, module.host, jid) then 149 remove_pending[jid] = true; 150 end 151 elseif is_contact_subscribed(username, module.host, jid) then 152 send_available[jid] = true; 153 end 154 end 155 156 if is_blocking and not next(new) then 157 -- <block/> element does not contain at least one <item/> child element 158 origin.send(st_error_reply(stanza, "modify", "bad-request")); 159 return true; 160 end 161 162 local blocklist = cache[username] or get_blocklist(username); 163 164 local new_blocklist = { 165 -- We set the [false] key to someting as a signal not to migrate privacy lists 166 [false] = blocklist[false] or { created = now; }; 167 }; 168 if type(blocklist[false]) == "table" then 169 new_blocklist[false].modified = now; 170 end 171 172 if is_blocking or next(new) then 173 for jid, t in pairs(blocklist) do 174 if jid then new_blocklist[jid] = t; end 175 end 176 for jid in pairs(new) do 177 new_blocklist[jid] = is_blocking; 178 end 179 -- else empty the blocklist 180 end 181 182 local ok, err = set_blocklist(username, new_blocklist); 183 if ok then 184 origin.send(st.reply(stanza)); 185 else 186 origin.send(st_error_reply(stanza, "wait", "internal-server-error", err)); 187 return true; 188 end 189 190 if is_blocking then 191 for jid in pairs(send_unavailable) do 192 if not blocklist[jid] then 193 for _, session in pairs(sessions[username].sessions) do 194 if session.presence then 195 module:send(st.presence({ type = "unavailable", to = jid, from = session.full_jid })); 196 end 197 end 198 end 199 end 200 201 if next(remove_pending) then 202 local roster = load_roster(username, module.host); 203 for jid in pairs(remove_pending) do 204 roster[false].pending[jid] = nil; 205 end 206 save_roster(username, module.host, roster); 207 -- Not much we can do about save failing here 208 end 209 else 210 local user_bare = username .. "@" .. module.host; 211 for jid in pairs(send_available) do 212 module:send(st.presence({ type = "probe", to = user_bare, from = jid })); 213 end 214 end 215 216 local blocklist_push = st.iq({ type = "set", id = "blocklist-push" }) 217 :add_child(action); -- I am lazy 218 219 for _, session in pairs(sessions[username].sessions) do 220 if session.interested_blocklist then 221 blocklist_push.attr.to = session.full_jid; 222 session.send(blocklist_push); 223 end 224 end 225 226 return true; 227end 228 229module:hook("iq-set/self/urn:xmpp:blocking:block", edit_blocklist, -1); 230module:hook("iq-set/self/urn:xmpp:blocking:unblock", edit_blocklist, -1); 231 232-- Cache invalidation, solved! 233module:hook_global("user-deleted", function (event) 234 if event.host == module.host then 235 cache2:set(event.username, nil); 236 cache[event.username] = nil; 237 end 238end); 239 240-- Buggy clients 241module:hook("iq-error/self/blocklist-push", function (event) 242 local origin, stanza = event.origin, event.stanza; 243 local _, condition, text = stanza:get_error(); 244 local log = (origin.log or module._log); 245 log("warn", "Client returned an error in response to notification from mod_%s: %s%s%s", 246 module.name, condition, text and ": " or "", text or ""); 247 return true; 248end); 249 250local function is_blocked(user, jid) 251 local blocklist = cache[user] or get_blocklist(user); 252 if blocklist[jid] then return true; end 253 local node, host = jid_split(jid); 254 return blocklist[host] or node and blocklist[node..'@'..host]; 255end 256 257-- Event handlers for bouncing or dropping stanzas 258local function drop_stanza(event) 259 local stanza = event.stanza; 260 local attr = stanza.attr; 261 local to, from = attr.to, attr.from; 262 to = to and jid_split(to); 263 if to and from then 264 return is_blocked(to, from); 265 end 266end 267 268local function bounce_stanza(event) 269 local origin, stanza = event.origin, event.stanza; 270 if drop_stanza(event) then 271 origin.send(st_error_reply(stanza, "cancel", "service-unavailable")); 272 return true; 273 end 274end 275 276local function bounce_iq(event) 277 local type = event.stanza.attr.type; 278 if type == "set" or type == "get" then 279 return bounce_stanza(event); 280 end 281 return drop_stanza(event); -- result or error 282end 283 284local function bounce_message(event) 285 local stanza = event.stanza; 286 local type = stanza.attr.type; 287 if type == "chat" or not type or type == "normal" then 288 if full_sessions[stanza.attr.to] then 289 -- See #690 290 return drop_stanza(event); 291 end 292 return bounce_stanza(event); 293 end 294 return drop_stanza(event); -- drop headlines, groupchats etc 295end 296 297local function drop_outgoing(event) 298 local origin, stanza = event.origin, event.stanza; 299 local username = origin.username or jid_split(stanza.attr.from); 300 if not username then return end 301 local to = stanza.attr.to; 302 if to then return is_blocked(username, to); end 303 -- nil 'to' means a self event, don't bock those 304end 305 306local function bounce_outgoing(event) 307 local origin, stanza = event.origin, event.stanza; 308 local type = stanza.attr.type; 309 if type == "error" or stanza.name == "iq" and type == "result" then 310 return drop_outgoing(event); 311 end 312 if drop_outgoing(event) then 313 origin.send(st_error_reply(stanza, "cancel", "not-acceptable", "You have blocked this JID") 314 :tag("blocked", { xmlns = "urn:xmpp:blocking:errors" })); 315 return true; 316 end 317end 318 319-- Hook all the events! 320local prio_in, prio_out = 100, 100; 321module:hook("presence/bare", drop_stanza, prio_in); 322module:hook("presence/full", drop_stanza, prio_in); 323 324module:hook("message/bare", bounce_message, prio_in); 325module:hook("message/full", bounce_message, prio_in); 326 327module:hook("iq/bare", bounce_iq, prio_in); 328module:hook("iq/full", bounce_iq, prio_in); 329 330module:hook("pre-message/bare", bounce_outgoing, prio_out); 331module:hook("pre-message/full", bounce_outgoing, prio_out); 332module:hook("pre-message/host", bounce_outgoing, prio_out); 333 334module:hook("pre-presence/bare", bounce_outgoing, -1); 335module:hook("pre-presence/host", bounce_outgoing, -1); 336module:hook("pre-presence/full", bounce_outgoing, prio_out); 337 338module:hook("pre-iq/bare", bounce_outgoing, prio_out); 339module:hook("pre-iq/full", bounce_outgoing, prio_out); 340module:hook("pre-iq/host", bounce_outgoing, prio_out); 341 342