Software /
code /
prosody-modules
Comparison
mod_web_push/mod_web_push.lua @ 3612:2cee9fcb318b
Initial version of mod_web_push. WARNING: Do not use.
author | Maxime “pep” Buquet <pep@bouah.net> |
---|---|
date | Sat, 08 Jun 2019 23:06:44 +0200 |
comparison
equal
deleted
inserted
replaced
3611:235d986ac20f | 3612:2cee9fcb318b |
---|---|
1 -- XEP-XXXX: Web Push (aka: My mobile OS vendor won't let me have persistent TCP connections, take two) | |
2 -- Copyright (C) 2019 Maxime “pep” Buquet | |
3 -- | |
4 -- Heavily based on mod_cloud_notify. | |
5 -- Copyright (C) 2015-2016 Kim Alvefur | |
6 -- Copyright (C) 2017-2018 Thilo Molitor | |
7 | |
8 | |
9 local st = require"util.stanza"; | |
10 local dataform = require "util.dataforms"; | |
11 local http = require "net.http"; | |
12 | |
13 local os_time = os.time; | |
14 local next = next; | |
15 local jid = require"util.jid"; | |
16 local filters = require"util.filters"; | |
17 | |
18 local xmlns_webpush = "urn:xmpp:webpush:0"; | |
19 | |
20 local max_push_devices = module:get_option_number("push_max_devices", 5); | |
21 local dummy_body = module:get_option_string("push_notification_important_body", "New Message!"); | |
22 | |
23 local host_sessions = prosody.hosts[module.host].sessions; | |
24 | |
25 -- TODO: Generate it at setup time. Obviously not to be used other than for | |
26 -- testing purposes, or at all. | |
27 -- ECDH keypair | |
28 local server_pubkey = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhxZpb8yIVc/2hNesGLGAxEakyYy0MqEetjgL7BIOm8ybhVKxapKqNXjXJ+NOO5/b0Z0UuBg/HynGnf0xKKNhBQ=="; | |
29 local server_privkey = "MHcCAQEEIPhZac9pQ8aVTx9a5JyRcqfk3nuQQUFy3PaDcSWleojzoAoGCCqGSM49AwEHoUQDQgAEhxZpb8yIVc/2hNesGLGAxEakyYy0MqEetjgL7BIOm8ybhVKxapKqNXjXJ+NOO5/b0Z0UuBg/HynGnf0xKKNhBQ=="; | |
30 | |
31 -- Advertize disco feature | |
32 local function account_disco_info(event) | |
33 local form = dataform.new { | |
34 { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/webpush#public-key" }; | |
35 { name = "webpush#public-key", value = server_pubkey }; | |
36 }; | |
37 (event.reply or event.stanza):tag("feature", {var=xmlns_webpush}):up() | |
38 :add_child(form:form({}, "result")); | |
39 end | |
40 module:hook("account-disco-info", account_disco_info); | |
41 | |
42 -- ordered table iterator, allow to iterate on the natural order of the keys of a table, | |
43 -- see http://lua-users.org/wiki/SortedIteration | |
44 local function __genOrderedIndex( t ) | |
45 local orderedIndex = {} | |
46 for key in pairs(t) do | |
47 table.insert( orderedIndex, key ) | |
48 end | |
49 -- sort in reverse order (newest one first) | |
50 table.sort( orderedIndex, function(a, b) | |
51 if a == nil or t[a] == nil or b == nil or t[b] == nil then return false end | |
52 -- only one timestamp given, this is the newer one | |
53 if t[a].timestamp ~= nil and t[b].timestamp == nil then return true end | |
54 if t[a].timestamp == nil and t[b].timestamp ~= nil then return false end | |
55 -- both timestamps given, sort normally | |
56 if t[a].timestamp ~= nil and t[b].timestamp ~= nil then return t[a].timestamp > t[b].timestamp end | |
57 return false -- normally not reached | |
58 end) | |
59 return orderedIndex | |
60 end | |
61 local function orderedNext(t, state) | |
62 -- Equivalent of the next function, but returns the keys in timestamp | |
63 -- order. We use a temporary ordered key table that is stored in the | |
64 -- table being iterated. | |
65 | |
66 local key = nil | |
67 --print("orderedNext: state = "..tostring(state) ) | |
68 if state == nil then | |
69 -- the first time, generate the index | |
70 t.__orderedIndex = __genOrderedIndex( t ) | |
71 key = t.__orderedIndex[1] | |
72 else | |
73 -- fetch the next value | |
74 for i = 1, #t.__orderedIndex do | |
75 if t.__orderedIndex[i] == state then | |
76 key = t.__orderedIndex[i+1] | |
77 end | |
78 end | |
79 end | |
80 | |
81 if key then | |
82 return key, t[key] | |
83 end | |
84 | |
85 -- no more value to return, cleanup | |
86 t.__orderedIndex = nil | |
87 return | |
88 end | |
89 local function orderedPairs(t) | |
90 -- Equivalent of the pairs() function on tables. Allows to iterate | |
91 -- in order | |
92 return orderedNext, t, nil | |
93 end | |
94 | |
95 -- small helper function to return new table with only "maximum" elements containing only the newest entries | |
96 local function reduce_table(table, maximum) | |
97 local count = 0; | |
98 local result = {}; | |
99 for key, value in orderedPairs(table) do | |
100 count = count + 1; | |
101 if count > maximum then break end | |
102 result[key] = value; | |
103 end | |
104 return result; | |
105 end | |
106 | |
107 local push_store = (function() | |
108 local store = module:open_store(); | |
109 local push_services = {}; | |
110 local api = {}; | |
111 function api:get(user) | |
112 if not push_services[user] then | |
113 local err; | |
114 push_services[user], err = store:get(user); | |
115 if not push_services[user] and err then | |
116 module:log("warn", "Error reading web push notification storage for user '%s': %s", user, tostring(err)); | |
117 push_services[user] = {}; | |
118 return push_services[user], false; | |
119 end | |
120 end | |
121 if not push_services[user] then push_services[user] = {} end | |
122 return push_services[user], true; | |
123 end | |
124 function api:set(user, data) | |
125 push_services[user] = reduce_table(data, max_push_devices); | |
126 local ok, err = store:set(user, push_services[user]); | |
127 if not ok then | |
128 module:log("error", "Error writing web push notification storage for user '%s': %s", user, tostring(err)); | |
129 return false; | |
130 end | |
131 return true; | |
132 end | |
133 function api:set_identifier(user, push_identifier, data) | |
134 local services = self:get(user); | |
135 services[push_identifier] = data; | |
136 return self:set(user, services); | |
137 end | |
138 return api; | |
139 end)(); | |
140 | |
141 local function push_enable(event) | |
142 local origin, stanza = event.origin, event.stanza; | |
143 local enable = stanza.tags[1]; | |
144 origin.log("debug", "Attempting to enable web push notifications"); | |
145 -- MUST contain a 'href' attribute of the XMPP Push Service being enabled | |
146 local push_endpoint = nil; | |
147 local push_auth = nil; | |
148 local push_p256dh = nil; | |
149 | |
150 local endpoint_tag = enable:get_child('endpoint'); | |
151 if endpoint_tag ~= nil then | |
152 push_endpoint = endpoint_tag:get_text(); | |
153 end | |
154 local auth_tag = enable:get_child('auth'); | |
155 if auth_tag ~= nil then | |
156 push_auth = auth_tag:get_text(); | |
157 end | |
158 local p256dh_tag = enable:get_child('p256dh'); | |
159 if p256dh_tag ~= nil then | |
160 push_p256dh = p256dh_tag:get_text(); | |
161 end | |
162 if not push_endpoint or not push_auth or not push_p256dh then | |
163 origin.log("debug", "Web Push notification enable request missing 'endpoint', 'auth', or 'p256dh' tags"); | |
164 origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing enable child tag")); | |
165 return true; | |
166 end | |
167 local push_identifier = "foo"; | |
168 local push_service = push_endpoint; | |
169 local ok = push_store:set_identifier(origin.username, push_identifier, push_service); | |
170 if not ok then | |
171 origin.send(st.error_reply(stanza, "wait", "internal-server-error")); | |
172 else | |
173 origin.push_identifier = push_identifier; | |
174 origin.push_settings = push_service; | |
175 origin.log("info", "Web Push notifications enabled for %s (%s)", tostring(stanza.attr.from), tostring(origin.push_identifier)); | |
176 origin.send(st.reply(stanza)); | |
177 end | |
178 return true; | |
179 end | |
180 module:hook("iq-set/self/"..xmlns_webpush..":enable", push_enable); | |
181 | |
182 -- module:hook("iq-set/self/"..xmlns_webpush..":disable", push_disable); | |
183 | |
184 -- small helper function to extract relevant push settings | |
185 local function get_push_settings(stanza, session) | |
186 local to = stanza.attr.to; | |
187 local node = to and jid.split(to) or session.username; | |
188 local user_push_services = push_store:get(node); | |
189 return node, user_push_services; | |
190 end | |
191 | |
192 local function log_http_req(response_body, response_code, response) | |
193 module:log("debug", "FOO: response_body: %s; response_code: %s; response: %s", response_body, tostring(response_code), tostring(response)); | |
194 end | |
195 | |
196 local function handle_notify_request(stanza, node, user_push_services, log_push_decline) | |
197 local pushes = 0; | |
198 if not user_push_services or next(user_push_services) == nil then return pushes end | |
199 | |
200 for push_identifier, push_info in pairs(user_push_services) do | |
201 local send_push = true; -- only send push to this node when not already done for this stanza or if no stanza is given at all | |
202 if stanza then | |
203 if not stanza._push_notify then stanza._push_notify = {}; end | |
204 if stanza._push_notify[push_identifier] then | |
205 if log_push_decline then | |
206 module:log("debug", "Already sent push notification for %s@%s to %s", node, module.host, tostring(push_info)); | |
207 end | |
208 send_push = false; | |
209 end | |
210 stanza._push_notify[push_identifier] = true; | |
211 end | |
212 | |
213 if send_push then | |
214 local headers = { TTL = "60" }; | |
215 http.request(push_info, { method = "POST", headers = headers }, log_http_req); | |
216 pushes = pushes + 1; | |
217 end | |
218 end | |
219 return pushes; | |
220 end | |
221 | |
222 -- publish on offline message | |
223 module:hook("message/offline/handle", function(event) | |
224 local node, user_push_services = get_push_settings(event.stanza, event.origin); | |
225 module:log("debug", "Invoking web push handle_notify_request() for offline stanza"); | |
226 handle_notify_request(event.stanza, node, user_push_services, true); | |
227 end, 1); | |
228 | |
229 -- is this push a high priority one (this is needed for ios apps not using voip pushes) | |
230 local function is_important(stanza) | |
231 local st_name = stanza and stanza.name or nil; | |
232 if not st_name then return false; end -- nonzas are never important here | |
233 if st_name == "presence" then | |
234 return false; -- same for presences | |
235 elseif st_name == "message" then | |
236 -- unpack carbon copies | |
237 local stanza_direction = "in"; | |
238 local carbon; | |
239 local st_type; | |
240 -- support carbon copied message stanzas having an arbitrary message-namespace or no message-namespace at all | |
241 if not carbon then carbon = find(stanza, "{urn:xmpp:carbons:2}/forwarded/message"); end | |
242 if not carbon then carbon = find(stanza, "{urn:xmpp:carbons:1}/forwarded/message"); end | |
243 stanza_direction = carbon and stanza:child_with_name("sent") and "out" or "in"; | |
244 if carbon then stanza = carbon; end | |
245 st_type = stanza.attr.type; | |
246 | |
247 -- headline message are always not important | |
248 if st_type == "headline" then return false; end | |
249 | |
250 -- carbon copied outgoing messages are not important | |
251 if carbon and stanza_direction == "out" then return false; end | |
252 | |
253 -- We can't check for body contents in encrypted messages, so let's treat them as important | |
254 -- Some clients don't even set a body or an empty body for encrypted messages | |
255 | |
256 -- check omemo https://xmpp.org/extensions/inbox/omemo.html | |
257 if stanza:get_child("encrypted", "eu.siacs.conversations.axolotl") or stanza:get_child("encrypted", "urn:xmpp:omemo:0") then return true; end | |
258 | |
259 -- check xep27 pgp https://xmpp.org/extensions/xep-0027.html | |
260 if stanza:get_child("x", "jabber:x:encrypted") then return true; end | |
261 | |
262 -- check xep373 pgp (OX) https://xmpp.org/extensions/xep-0373.html | |
263 if stanza:get_child("openpgp", "urn:xmpp:openpgp:0") then return true; end | |
264 | |
265 local body = stanza:get_child_text("body"); | |
266 if st_type == "groupchat" and stanza:get_child_text("subject") then return false; end -- groupchat subjects are not important here | |
267 return body ~= nil and body ~= ""; -- empty bodies are not important | |
268 end | |
269 return false; -- this stanza wasn't one of the above cases --> it is not important, too | |
270 end | |
271 | |
272 -- publish on unacked smacks message | |
273 local function process_smacks_stanza(stanza, session) | |
274 if session.push_identifier then | |
275 session.log("debug", "Invoking web push handle_notify_request() for smacks queued stanza"); | |
276 local user_push_services = {[session.push_identifier] = session.push_settings}; | |
277 local node = get_push_settings(stanza, session); | |
278 if handle_notify_request(stanza, node, user_push_services, true) ~= 0 then | |
279 if session.hibernating and not session.first_hibernated_push then | |
280 -- if important stanzas are treated differently (pushed with last-message-body field set to dummy string) | |
281 -- and the message was important (e.g. had a last-message-body field) OR if we treat all pushes equally, | |
282 -- then record the time of first push in the session for the smack module which will extend its hibernation | |
283 -- timeout based on the value of session.first_hibernated_push | |
284 if not dummy_body or (dummy_body and is_important(stanza)) then | |
285 session.first_hibernated_push = os_time(); | |
286 end | |
287 end | |
288 end | |
289 end | |
290 return stanza; | |
291 end | |
292 | |
293 local function process_smacks_queue(queue, session) | |
294 if not session.push_identifier then return; end | |
295 local user_push_services = {[session.push_identifier] = session.push_settings}; | |
296 local notified = { unimportant = false; important = false } | |
297 for i=1, #queue do | |
298 local stanza = queue[i]; | |
299 local node = get_push_settings(stanza, session); | |
300 local stanza_type = "unimportant" | |
301 if dummy_body and is_important(stanza) then stanza_type = "important"; end | |
302 if not notified[stanza_type] then -- only notify if we didn't try to push for this stanza type already | |
303 -- session.log("debug", "Invoking cloud handle_notify_request() for smacks queued stanza: %d", i); | |
304 if handle_notify_request(stanza, node, user_push_services, false) ~= 0 then | |
305 if session.hibernating and not session.first_hibernated_push then | |
306 -- if important stanzas are treated differently (pushed with last-message-body field set to dummy string) | |
307 -- and the message was important (e.g. had a last-message-body field) OR if we treat all pushes equally, | |
308 -- then record the time of first push in the session for the smack module which will extend its hibernation | |
309 -- timeout based on the value of session.first_hibernated_push | |
310 if not dummy_body or (dummy_body and is_important(stanza)) then | |
311 session.first_hibernated_push = os_time(); | |
312 end | |
313 end | |
314 session.log("debug", "Web Push handle_notify_request() > 0, not notifying for other queued stanzas of type %s", stanza_type); | |
315 notified[stanza_type] = true | |
316 end | |
317 end | |
318 end | |
319 end | |
320 | |
321 -- smacks hibernation is started | |
322 local function hibernate_session(event) | |
323 local session = event.origin; | |
324 local queue = event.queue; | |
325 session.first_hibernated_push = nil; | |
326 -- process unacked stanzas | |
327 process_smacks_queue(queue, session); | |
328 -- process future unacked (hibernated) stanzas | |
329 filters.add_filter(session, "stanzas/out", process_smacks_stanza, -990); | |
330 end | |
331 | |
332 -- smacks hibernation is ended | |
333 local function restore_session(event) | |
334 local session = event.resumed; | |
335 if session then -- older smacks module versions send only the "intermediate" session in event.session and no session.resumed one | |
336 filters.remove_filter(session, "stanzas/out", process_smacks_stanza); | |
337 session.first_hibernated_push = nil; | |
338 end | |
339 end | |
340 | |
341 -- smacks ack is delayed | |
342 local function ack_delayed(event) | |
343 local session = event.origin; | |
344 local queue = event.queue; | |
345 -- process unacked stanzas (handle_notify_request() will only send push requests for new stanzas) | |
346 process_smacks_queue(queue, session); | |
347 end | |
348 | |
349 -- archive message added | |
350 local function archive_message_added(event) | |
351 -- event is: { origin = origin, stanza = stanza, for_user = store_user, id = id } | |
352 -- only notify for new mam messages when at least one device is online | |
353 if not event.for_user or not host_sessions[event.for_user] then return; end | |
354 local stanza = event.stanza; | |
355 local user_session = host_sessions[event.for_user].sessions; | |
356 local to = stanza.attr.to; | |
357 to = to and jid.split(to) or event.origin.username; | |
358 | |
359 -- only notify if the stanza destination is the mam user we store it for | |
360 if event.for_user == to then | |
361 local user_push_services = push_store:get(to); | |
362 if next(user_push_services) == nil then return end | |
363 | |
364 -- only notify nodes with no active sessions (smacks is counted as active and handled separate) | |
365 local notify_push_services = {}; | |
366 for identifier, push_info in pairs(user_push_services) do | |
367 local identifier_found = nil; | |
368 for _, session in pairs(user_session) do | |
369 -- module:log("debug", "searching for '%s': identifier '%s' for session %s", tostring(identifier), tostring(session.push_identifier), tostring(session.full_jid)); | |
370 if session.push_identifier == identifier then | |
371 identifier_found = session; | |
372 break; | |
373 end | |
374 end | |
375 if identifier_found then | |
376 identifier_found.log("debug", "Not web push notifying '%s' of new MAM stanza (session still alive)", identifier); | |
377 else | |
378 notify_push_services[identifier] = push_info; | |
379 end | |
380 end | |
381 | |
382 handle_notify_request(event.stanza, to, notify_push_services, true); | |
383 end | |
384 end | |
385 | |
386 module:hook("smacks-hibernation-start", hibernate_session); | |
387 module:hook("smacks-hibernation-end", restore_session); | |
388 module:hook("smacks-ack-delayed", ack_delayed); | |
389 module:hook("archive-message-added", archive_message_added); | |
390 | |
391 function module.command(arg) | |
392 print("TODO: Generate server keypair") | |
393 end | |
394 | |
395 module:log("info", "Module loaded"); | |
396 function module.unload() | |
397 if module.unhook then | |
398 module:unhook("account-disco-info", account_disco_info); | |
399 module:unhook("iq-set/self/"..xmlns_webpush..":enable", push_enable); | |
400 -- module:unhook("iq-set/self/"..xmlns_webpush..":disable", push_disable); | |
401 end | |
402 | |
403 module:log("info", "Module unloaded"); | |
404 end |