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