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 |