diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index b0ac1fe680f..76adcdea10e 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -130,6 +130,12 @@ + + + + + + diff --git a/addons/mail/controllers/__init__.py b/addons/mail/controllers/__init__.py index d45073201db..17a0f967a9d 100644 --- a/addons/mail/controllers/__init__.py +++ b/addons/mail/controllers/__init__.py @@ -8,6 +8,7 @@ from . import mailbox from . import message_reaction from . import thread from . import webclient +from . import webmanifest # after mail specifically as discuss module depends on mail from . import discuss diff --git a/addons/mail/controllers/webmanifest.py b/addons/mail/controllers/webmanifest.py new file mode 100644 index 00000000000..0a849deb0a9 --- /dev/null +++ b/addons/mail/controllers/webmanifest.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.http import request +from odoo.tools import file_open +from odoo.addons.web.controllers.webmanifest import WebManifest as WebWebManifest + + +class WebManifest(WebWebManifest): + + def _get_service_worker_content(self): + body = super()._get_service_worker_content() + + # Add notification support to the service worker if user but no public + if request.env.user.has_group('base.group_user'): + with file_open('mail/static/src/service_worker.js') as f: + body += f.read() + + return body diff --git a/addons/mail/data/ir_cron_data.xml b/addons/mail/data/ir_cron_data.xml index 5bc687622f1..9ca540b6f9b 100644 --- a/addons/mail/data/ir_cron_data.xml +++ b/addons/mail/data/ir_cron_data.xml @@ -65,5 +65,17 @@ model._send_notifications_cron() code + + + Mail: send web push notification + + code + model._push_notification_to_endpoint() + + 1 + days + -1 + + diff --git a/addons/mail/data/neutralize.sql b/addons/mail/data/neutralize.sql index c40a0156507..f0bc18bdf6e 100644 --- a/addons/mail/data/neutralize.sql +++ b/addons/mail/data/neutralize.sql @@ -4,3 +4,12 @@ UPDATE mail_template -- deactivate fetchmail server UPDATE fetchmail_server SET active = false; + +-- reset WEB Push Notification: +-- * delete VAPID keys +DELETE FROM ir_config_parameter + WHERE key IN ('mail.web_push_vapid_private_key', 'mail.web_push_vapid_public_key'); +-- * delete delayed messages (CRON) +TRUNCATE mail_notification_web_push; +-- * delete Devices for each partners +TRUNCATE mail_partner_device CASCADE; diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index 92146602a08..71bbe20e3ab 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -57,6 +57,8 @@ from . import res_company from . import res_config_settings from . import res_users from . import update +from . import web_push +from . import partner_devices # after mail specifically as discuss module depends on mail from . import discuss diff --git a/addons/mail/models/discuss/discuss_channel.py b/addons/mail/models/discuss/discuss_channel.py index 992e242dc2e..4e7d0afab43 100644 --- a/addons/mail/models/discuss/discuss_channel.py +++ b/addons/mail/models/discuss/discuss_channel.py @@ -591,6 +591,8 @@ class Channel(models.Model): 'last_interest_dt': member.last_interest_dt, }]) self.env['bus.bus'].sudo()._sendmany(bus_notifications) + if self.is_chat or self.channel_type == 'group': + self._notify_thread_by_web_push(message, rdata, msg_vals, **kwargs) return rdata def _message_receive_bounce(self, email, partner): @@ -1296,3 +1298,41 @@ class Channel(models.Model): msg = _("Users in this channel: %(members)s %(dots)s and you.", members=", ".join(members), dots=dots) self._send_transient_message(self.env.user.partner_id, msg) + + def _notify_thread_by_web_push(self, message, recipients_data, msg_vals=False, **kwargs): + """ Specifically handle channel members. """ + chat_channels = self.filtered(lambda channel: channel.channel_type == 'chat') + if chat_channels: + # modify rdata only for calling super. Do not deep copy as we only + # add data into list but we do not modify item content + channel_rdata = recipients_data.copy() + channel_rdata += [ + {'id': partner.id, + 'share': partner.partner_share, + 'active': partner.active, + 'notif': 'web_push', + 'type': 'customer', + 'groups': [], + } + for partner in chat_channels.mapped("channel_partner_ids") + ] + else: + channel_rdata = recipients_data + + return super()._notify_thread_by_web_push(message, channel_rdata, msg_vals=msg_vals, **kwargs) + + def _notify_by_web_push_prepare_payload(self, message, msg_vals=False): + payload = super()._notify_by_web_push_prepare_payload(message, msg_vals=msg_vals) + payload['options']['data']['action'] = 'mail.action_discuss' + record_name = msg_vals.get('record_name') if msg_vals and 'record_name' in msg_vals else message.record_name + if self.channel_type == 'chat': + author_id = [msg_vals.get('author_id')] if 'author_id' in msg_vals else message.author_id.ids + payload['title'] = self.env['res.partner'].browse(author_id).name + payload['options']['icon'] = '/discuss/channel/%d/partner/%d/avatar_128' % (message.res_id, author_id[0]) + elif self.channel_type == 'channel': + author_id = [msg_vals.get('author_id')] if 'author_id' in msg_vals else message.author_id.ids + author_name = self.env['res.partner'].browse(author_id).name + payload['title'] = "#%s - %s" % (record_name, author_name) + else: + payload['title'] = "#%s" % (record_name) + return payload diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index e6dcd73c5a5..991f5bdb956 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -31,6 +31,11 @@ from odoo.osv import expression from odoo.tools import is_html_empty, html_escape, html2plaintext, parse_contact_from_email from odoo.tools.misc import clean_context, split_every +from requests import Session +from ..web_push import push_to_end_point, DeviceUnreachableError + +MAX_DIRECT_PUSH = 5 + _logger = logging.getLogger(__name__) @@ -2961,6 +2966,7 @@ class MailThread(models.AbstractModel): self._notify_thread_by_inbox(message, recipients_data, msg_vals=msg_vals, **kwargs) self._notify_thread_by_email(message, recipients_data, msg_vals=msg_vals, **kwargs) + self._notify_thread_by_web_push(message, recipients_data, msg_vals, **kwargs) return recipients_data def _notify_thread_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs): @@ -4179,3 +4185,205 @@ class MailThread(models.AbstractModel): if 'suggestedRecipients' in request_list: res['suggestedRecipients'] = self._message_get_suggested_recipients()[self.id] return res + + def _extract_partner_ids_for_notifications(self, message, msg_vals, recipients_data): + notif_pids = [] + no_inbox_pids = [] + for recipient in recipients_data: + if recipient['active']: + notif_pids.append(recipient['id']) + if recipient['notif'] != 'inbox': + no_inbox_pids.append(recipient['id']) + + if not notif_pids: + return [] + + msg_sudo = message.sudo() + msg_type = msg_vals.get('message_type') or msg_sudo.message_type + author_id = [msg_vals.get('author_id')] if 'author_id' in msg_vals else msg_sudo.author_id.ids + # never send to author and to people outside Odoo (email), except comments + pids = set() + if msg_type == 'comment': + pids = set(notif_pids) - set(author_id) + elif msg_type in ('notification', 'user_notification', 'email'): + pids = (set(notif_pids) - set(author_id) - set(no_inbox_pids)) + return list(pids) + + def _truncate_payload(self, payload): + """ + Check the payload limit of 4096 bytes to avoid 413 error return code. + If the payload is too big, we trunc the body value. + :param dict payload: Current payload to trunc + :return: The truncate payload; + """ + payload_length = len(str(payload).encode()) + body = payload['options']['body'] + body_length = len(body) + if payload_length > 4096: + body_max_length = 4096 - payload_length - body_length + payload['options']['body'] = body.encode()[:body_max_length].decode(errors="ignore") + return payload + + def _notify_thread_by_web_push(self, message, recipients_data, msg_vals=False, **kwargs): + """ Method to send cloud notifications for every mention of a partner + and every direct message. We have to take into account the risk of + duplicated notifications in case of a mention in a channel of `chat` type. + + :param message: ``mail.message`` record to notify; + :param recipients_data: list of recipients information (based on res.partner + records), formatted like + [{'active': partner.active; + 'id': id of the res.partner being recipient to notify; + 'groups': res.group IDs if linked to a user; + 'notif': 'inbox', 'email', 'sms' (SMS App); + 'share': partner.partner_share; + 'type': 'customer', 'portal', 'user;' + }, {...}]. + See ``MailThread._notify_get_recipients``; + :param msg_vals: dictionary of values used to create the message. If given it + may be used to access values related to ``message`` without accessing it + directly. It lessens query count in some optimized use cases by avoiding + access message content in db; + """ + + msg_vals = dict(msg_vals or {}) + partner_ids = self._extract_partner_ids_for_notifications(message, msg_vals, recipients_data) + if not partner_ids: + return + + partner_devices_sudo = self.env['mail.partner.device'].sudo() + devices = partner_devices_sudo.search([ + ('partner_id', 'in', partner_ids) + ]) + if not devices: + return + + ir_parameter_sudo = self.env['ir.config_parameter'].sudo() + vapid_private_key = ir_parameter_sudo.get_param('mail.web_push_vapid_private_key') + vapid_public_key = ir_parameter_sudo.get_param('mail.web_push_vapid_public_key') + if not vapid_private_key or not vapid_public_key: + _logger.warning("Missing web push vapid keys !") + return + + payload = self._notify_by_web_push_prepare_payload(message, msg_vals=msg_vals) + payload = self._truncate_payload(payload) + if len(devices) < MAX_DIRECT_PUSH: + session = Session() + devices_to_unlink = set() + for device in devices: + try: + push_to_end_point( + base_url=self.get_base_url(), + device={ + 'id': device.id, + 'endpoint': device.endpoint, + 'keys': device.keys + }, + payload=json.dumps(payload), + vapid_private_key=vapid_private_key, + vapid_public_key=vapid_public_key, + session=session, + ) + except DeviceUnreachableError: + devices_to_unlink.add(device.id) + except Exception as e: # pylint: disable=broad-except + # Avoid blocking the whole request just for a notification + _logger.error('An error occurred while contacting the endpoint: %s', e) + + # clean up obsolete devices + if devices_to_unlink: + devices_list = list(devices_to_unlink) + self.env['mail.partner.device'].sudo().browse(devices_list).unlink() + + else: + self.env['mail.notification.web.push'].sudo().create([{ + 'user_device': device.id, + 'payload': json.dumps(payload), + } for device in devices]) + self.env.ref('mail.ir_cron_web_push_notification')._trigger() + + def _notify_by_web_push_prepare_payload(self, message, msg_vals=False): + """ Returns dictionary containing message information for a browser device. + This info will be delivered to a browser device via its recorded endpoint. + REM: It is having a limit of 4000 bytes (4kb) + """ + if msg_vals: + author_id = [msg_vals.get('author_id')] + author_name = self.env['res.partner'].browse(author_id).name + model = msg_vals.get('model') + title = msg_vals.get('record_name') or msg_vals.get('subject') + res_id = msg_vals.get('res_id') + body = msg_vals.get('body') + if not model and body: + model, res_id = self._extract_model_and_id(msg_vals) + else: + author_id = [message.author_id.ids] + author_name = self.env['res.partner'].browse(author_id).name + model = message.model + title = message.record_name or message.subject + res_id = message.res_id + body = message.body + + icon = '/web/static/img/odoo-icon-192x192.png' + + if author_name: + title = "%s: %s" % (author_name, title) + icon = "/web/image/res.users/%d/avatar_128" % author_id[0] + + payload = { + 'title': title, + 'options': { + 'icon': icon, + 'data': { + 'model': model if model else '', + 'res_id': res_id if res_id else '', + } + } + } + payload['options']['body'] = tools.html2plaintext(body) + payload['options']['body'] += self._generate_tracking_message(message) + + return payload + + @api.model + def _extract_model_and_id(self, msg_vals): + """ + Return the model and the id when is present in a link (HTML) + + :param msg_vals: see :meth:`._notify_thread_by_web_push` + + :return: a dict empty if no matches and a dict with these keys if match : model and res_id + """ + regex = r"[\w.]+).+res_id=(?P\d+).+>[\s\w\/\\.]+<\/a>" + matches = re.finditer(regex, msg_vals['body']) + + for match in matches: + return match['model'], match['id'] + return None, None + + @api.model + def _generate_tracking_message(self, message, return_line='\n'): + """ + Format the tracking values like in the chatter + :param message: current mail.message record + :param return_line: type of return line + :return: a string with the new text if there is one or more tracking value + """ + tracking_message = '' + if message.subtype_id and message.subtype_id.description: + tracking_message = return_line + message.subtype_id.description + return_line + + for value in message.sudo().tracking_value_ids.filtered(lambda tracking: not tracking.field_groups): + if value.field_type == 'boolean': + old_value = str(bool(value.old_value_integer)) + new_value = str(bool(value.new_value_integer)) + else: + old_value = value.old_value_char if value.old_value_char else str(value.old_value_integer) + new_value = value.new_value_char if value.new_value_char else str(value.new_value_integer) + + tracking_message += value.field_desc + ': ' + old_value + if old_value != new_value: + tracking_message += ' → ' + new_value + tracking_message += return_line + + return tracking_message diff --git a/addons/mail/models/partner_devices.py b/addons/mail/models/partner_devices.py new file mode 100644 index 00000000000..e739181289c --- /dev/null +++ b/addons/mail/models/partner_devices.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import json +import logging as logger + +from odoo import api, fields, models +from ..web_push import generate_web_push_vapid_key + +_logger = logger.getLogger(__name__) + + +class InvalidVapidError(Exception): + pass + + +class PartnerDevice(models.Model): + _name = 'mail.partner.device' + _description = 'Partner Web Push Device' + + partner_id = fields.Many2one('res.partner', string='Partner', index=True, required=True, + default=lambda self: self.env.user.partner_id) + endpoint = fields.Char(string='Browser endpoint', required=True) + keys = fields.Char(string='Browser keys', required=True, + help=("It's refer to browser keys used by the notification: \n" + "- p256dh: It's the subscription public key generated by the browser. The browser will \n" + " keep the private key secret and use it for decrypting the payload\n" + "- auth: The auth value should be treated as a secret and not shared outside of Odoo")) + expiration_time = fields.Datetime(string='Expiration Token Date') + + _sql_constraints = [('endpoint_unique', 'unique(endpoint)', 'The endpoint must be unique !')] + + @api.model + def get_web_push_vapid_public_key(self): + ir_params_sudo = self.env['ir.config_parameter'].sudo() + public_key = 'mail.web_push_vapid_public_key' + public_key_value = ir_params_sudo.get_param(public_key) + # Regenerate new Keys if public key not present + if not public_key_value: + self.sudo().search([]).unlink() # Reset all devices (ServiceWorker) + private_key_value, public_key_value = generate_web_push_vapid_key() + ir_params_sudo.set_param('mail.web_push_vapid_private_key', private_key_value) + ir_params_sudo.set_param(public_key, public_key_value) + _logger.info("WebPush: missing public key, new VAPID keys generated") + return public_key_value + + @api.model + def register_devices(self, **kw): + sw_vapid_public_key = kw.get('vapid_public_key') + valid_sub = self._verify_vapid_public_key(sw_vapid_public_key) + if not valid_sub: + raise InvalidVapidError("Invalid VAPID public key") + endpoint = kw.get('endpoint') + browser_keys = kw.get('keys') + if not endpoint or not browser_keys: + return + search_endpoint = kw.get('previousEndpoint', endpoint) + user_device = self.sudo().search([('endpoint', '=', search_endpoint)]) + if user_device: + if user_device.partner_id is not self.env.user.partner_id: + user_device.write({ + 'endpoint': endpoint, + 'expiration_time': kw.get('expirationTime'), + 'keys': json.dumps(browser_keys), + 'partner_id': self.env.user.partner_id, + }) + else: + self.sudo().create([{ + 'endpoint': endpoint, + 'expiration_time': kw.get('expirationTime'), + 'keys': json.dumps(browser_keys), + 'partner_id': self.env.user.partner_id.id, + }]) + + @api.model + def unregister_devices(self, **kw): + endpoint = kw.get('endpoint') + if not endpoint: + return + user_device = self.sudo().search([ + ('endpoint', '=', endpoint) + ]) + if user_device: + user_device.unlink() + + def _verify_vapid_public_key(self, sw_public_key): + ir_params_sudo = self.env['ir.config_parameter'].sudo() + db_public_key = ir_params_sudo.get_param('mail.web_push_vapid_public_key') + return db_public_key == sw_public_key diff --git a/addons/mail/models/web_push.py b/addons/mail/models/web_push.py new file mode 100644 index 00000000000..aedeaf989ca --- /dev/null +++ b/addons/mail/models/web_push.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +from requests import Session + +from ..web_push import push_to_end_point, DeviceUnreachableError + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class WebPush(models.Model): + _name = 'mail.notification.web.push' + _description = 'Cron data used for web push notification' + + user_device = fields.Many2one('mail.partner.device', string='devices', required=True, ondelete="cascade") + payload = fields.Text() + + @api.model + def _push_notification_to_endpoint(self, batch_size=50): + """Send to web browser endpoint computed notification""" + web_push_notifications_sudo = self.sudo().search_fetch([], ['user_device', 'payload'], limit=batch_size) + if not web_push_notifications_sudo: + return + + ir_parameter_sudo = self.env['ir.config_parameter'].sudo() + vapid_private_key = ir_parameter_sudo.get_param('mail.web_push_vapid_private_key') + vapid_public_key = ir_parameter_sudo.get_param('mail.web_push_vapid_public_key') + if not vapid_private_key or not vapid_public_key: + return + + session = Session() + devices_to_unlink = set() + + # process send notif + devices = web_push_notifications_sudo.user_device.grouped('id') + for web_push_notification_sudo in web_push_notifications_sudo: + device = devices.get(web_push_notification_sudo.user_device.id) + if device.id in devices_to_unlink: + continue + try: + push_to_end_point( + base_url=self.get_base_url(), + device={ + 'id': device.id, + 'endpoint': device.endpoint, + 'keys': device.keys + }, + payload=web_push_notification_sudo.payload, + vapid_private_key=vapid_private_key, + vapid_public_key=vapid_public_key, + session=session, + ) + except DeviceUnreachableError: + devices_to_unlink.add(device.id) + + # clean up notif + web_push_notifications_sudo.unlink() + + # clean up obsolete devices + if devices_to_unlink: + self.env['mail.partner.device'].sudo().browse(devices_to_unlink).unlink() + + # restart the cron if needed + if self.search_count([]) > 0: + self.env.ref('mail.ir_cron_web_push_notification')._trigger() diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index 6b8fd1b9e50..60fd3cef3c8 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -69,3 +69,5 @@ ir_actions_report_access_user,ir.actions.report.access.user,base.model_ir_action access_mail_link_preview_admin,mail.link.preview.admin,model_mail_link_preview,base.group_erp_manager,1,1,1,1 access_discuss_gif_favorite,discuss.gif.favorite,model_discuss_gif_favorite,base.group_user,1,1,1,1 access_discuss_voice_metadata_user,discuss.voice.metadata.user,model_discuss_voice_metadata,,0,0,0,0 +access_mail_partner_device,access_mail_partner_device,mail.model_mail_partner_device,base.group_user,0,0,0,0 +access_mail_notification_web_push,access_mail_notification_web_push,mail.model_mail_notification_web_push,base.group_user,0,0,0,0 diff --git a/addons/mail/static/src/core/common/out_of_focus_service.js b/addons/mail/static/src/core/common/out_of_focus_service.js index dc57092bad2..c28303f127a 100644 --- a/addons/mail/static/src/core/common/out_of_focus_service.js +++ b/addons/mail/static/src/core/common/out_of_focus_service.js @@ -35,7 +35,14 @@ export class OutOfFocusService { }); } - notify(message, channel) { + async notify(message, channel) { + const modelsHandleByPush = ["mail.thread", "discuss.channel"]; + if ( + modelsHandleByPush.includes(message.resModel) && + (await this.hasServiceWorkInstalledAndPushSubscriptionActive()) + ) { + return; + } const author = message.author; let notificationTitle; if (!author) { @@ -66,6 +73,18 @@ export class OutOfFocusService { }); } + async hasServiceWorkInstalledAndPushSubscriptionActive() { + const registration = await browser.navigator.serviceWorker?.getRegistration(); + if (registration) { + const pushManager = await registration.pushManager; + if (pushManager) { + const subscription = await pushManager.getSubscription(); + return !!subscription; + } + } + return false; + } + /** * Send a notification, preferably a native one. If native * notifications are disable or unavailable on the current diff --git a/addons/mail/static/src/service_worker.js b/addons/mail/static/src/service_worker.js new file mode 100644 index 00000000000..a1631a3effd --- /dev/null +++ b/addons/mail/static/src/service_worker.js @@ -0,0 +1,45 @@ +/* eslint-env serviceworker */ +/* eslint-disable no-restricted-globals */ +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + if (event.notification.data) { + const { action, model, res_id } = event.notification.data; + if (model === "discuss.channel") { + clients.openWindow(`/web#action=${action}&active_id=${res_id}`); + } else { + clients.openWindow(`/web#model=${model}&id=${res_id}`); + } + } +}); +self.addEventListener("push", (event) => { + const notification = event.data.json(); + self.registration.showNotification(notification.title, notification.options || {}); +}); +self.addEventListener("pushsubscriptionchange", async (event) => { + const subscription = await self.registration.pushManager.subscribe( + event.oldSubscription.options + ); + await fetch("/web/dataset/call_kw/mail.partner.device/register_devices", { + headers: { + "Content-type": "application/json", + }, + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "call", + params: { + model: "mail.partner.device", + method: "register_devices", + args: [], + kwargs: { + ...subscription.toJSON(), + previousEndpoint: event.oldSubscription.endpoint, + }, + context: {}, + }, + }), + method: "POST", + mode: "cors", + credentials: "include", + }); +}); diff --git a/addons/mail/static/src/webclient/web/webclient.js b/addons/mail/static/src/webclient/web/webclient.js new file mode 100644 index 00000000000..fecbd25ad37 --- /dev/null +++ b/addons/mail/static/src/webclient/web/webclient.js @@ -0,0 +1,169 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { useService } from "@web/core/utils/hooks"; +import { patch } from "@web/core/utils/patch"; +import { WebClient } from "@web/webclient/webclient"; +import { onWillDestroy } from "@odoo/owl"; + +const USER_DEVICES_MODEL = "mail.partner.device"; + +patch(WebClient.prototype, { + /** + * @override + */ + setup() { + super.setup(); + this.rpc = useService("rpc"); + this.orm = useService("orm"); + if (this._canSendNativeNotification) { + this._subscribePush(); + } + if (browser.navigator.permissions) { + let notificationPerm; + const onPermissionChange = () => { + if (this._canSendNativeNotification) { + this._subscribePush(); + } else { + this._unsubscribePush(); + } + }; + browser.navigator.permissions.query({ name: "notifications" }).then((perm) => { + notificationPerm = perm; + notificationPerm.addEventListener("change", onPermissionChange); + }); + onWillDestroy(() => { + notificationPerm?.removeEventListener("change", onPermissionChange); + }); + } + }, + /** + * + * @returns {boolean} + * @private + */ + get _canSendNativeNotification() { + return browser.Notification?.permission === "granted"; + }, + + /** + * Subscribe device from push notification + * + * @private + * @return {Promise} + */ + async _subscribePush(numberTry = 1) { + const pushManager = await this.pushManager(); + if (!pushManager) { + return; + } + let subscription = await pushManager.getSubscription(); + const previousEndpoint = browser.localStorage.getItem(`${USER_DEVICES_MODEL}_endpoint`); + // This may occur if the subscription was refreshed by the browser, + // but it may also happen if the subscription has been revoked or lost. + if (!subscription) { + subscription = await pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: await this._getApplicationServerKey(), + }); + browser.localStorage.setItem(`${USER_DEVICES_MODEL}_endpoint`, subscription.endpoint); + } + const kwargs = subscription.toJSON(); + if (previousEndpoint && subscription.endpoint !== previousEndpoint) { + kwargs.previous_endpoint = previousEndpoint; + } + try { + kwargs.vapid_public_key = this._arrayBufferToBase64( + subscription.options.applicationServerKey + ); + await this.orm.call(USER_DEVICES_MODEL, "register_devices", [], kwargs); + } catch (e) { + const invalidVapidErrorClass = + "odoo.addons.mail.models.partner_devices.InvalidVapidError"; + const warningMessage = "Error sending subscription information to the server"; + if (e.data?.name === invalidVapidErrorClass) { + const MAX_TRIES = 2; + if (numberTry < MAX_TRIES) { + await subscription.unsubscribe(); + this._subscribePush(numberTry + 1); + } else { + console.warn(warningMessage); + } + } else { + console.warn(`${warningMessage}: ${e.data?.debug}`); + } + } + }, + + /** + * Unsubscribe device from push notification + * + * @private + * @return {Promise} + */ + async _unsubscribePush() { + const pushManager = await this.pushManager(); + if (!pushManager) { + return; + } + const subscription = await pushManager.getSubscription(); + if (!subscription) { + return; + } + await this.orm.call(USER_DEVICES_MODEL, "unregister_devices", [], { + endpoint: subscription.endpoint, + }); + await subscription.unsubscribe(); + browser.localStorage.removeItem(`${USER_DEVICES_MODEL}_endpoint`); + }, + + /** + * Retrieve the PushManager interface of the Push API provides a way to receive notifications from third-party + * servers as well as request URLs for push notifications. + * + * @return {Promise} + */ + async pushManager() { + const registration = await browser.navigator.serviceWorker?.getRegistration(); + return registration?.pushManager; + }, + + /** + * + * The Application Server Key is need to be an Uint8Array. + * This format is used when the exchanging secret key between client and server. + * This base64 to Uint8Array implementation is inspired by https://github.com/gbhasha/base64-to-uint8array + * + * @private + * @return {Uint8Array} + */ + async _getApplicationServerKey() { + const vapid_public_key_base64 = await this.orm.call( + USER_DEVICES_MODEL, + "get_web_push_vapid_public_key" + ); + const padding = "=".repeat((4 - (vapid_public_key_base64.length % 4)) % 4); + const base64 = (vapid_public_key_base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + }, + + /** + * Convert an ArrayBuffer to a base64 string without padding + * @param buffer {ArrayBuffer} + * @return {string} + * @private + */ + _arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + }, +}); diff --git a/addons/mail/web_push.py b/addons/mail/web_push.py new file mode 100644 index 00000000000..417b99f0196 --- /dev/null +++ b/addons/mail/web_push.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import binascii +import json +import logging as logger +import os +import struct +import textwrap +import time + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, utils +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from urllib.parse import urlparse + +MAX_PAYLOAD_SIZE = 4096 + +_logger = logger.getLogger(__name__) + +def _base64_decode_with_padding(value): + return base64.urlsafe_b64decode(value + '==') + +def generate_web_push_vapid_key(): + """ + Generate the VAPID (Voluntary Application Server Identification) used for the Web Push + This function generates a signing key pair usable with the Elliptic Curve Digital + Signature Algorithm (ECDSA) over the P-256 curve. + These keys will be used during communication with the endpoint/browser + https://www.rfc-editor.org/rfc/rfc8292 + """ + private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + private_int = private_key.private_numbers().private_value + private = private_int.to_bytes(32, 'big') + private_string = base64.urlsafe_b64encode(private).decode('ascii').strip('=') + + public_key = private_key.public_key() + public = public_key.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + public_string = base64.urlsafe_b64encode(public).decode('ascii').strip('=') + return private_string, public_string + +def _generate_jwt(endpoint, base_url, vapid_private_key): + """ + JWT are a pair of JSON objects, turned into base64 strings, and signed with the private ECDH key + https://www.rfc-editor.org/rfc/rfc7519 + https://www.rfc-editor.org/rfc/rfc8291 + :param endpoint: the browser endpoint + :param base_url: the base url + :param vapid_private_key: the private ECDH key generate at mail_entreprise install + :return: + """ + url = urlparse(endpoint) + + jwt_info = base64.urlsafe_b64encode(json.dumps({ + 'typ': 'JWT', + 'alg': 'ES256' + }).encode()) + + # The expiration is a timestamp in seconds and must be no longer 12 hours. + token_validity = 12 * 60 * 60 + + jwt_data = base64.urlsafe_b64encode(json.dumps({ + # aud: The “Audience” is a JWT construct that indicates the recipient scheme and host + # e.g. for an endpoint like https://updates.push.services.mozilla.com/wpush/v2/gAAAAABY..., + # the “aud” would be https://updates.push.services.mozilla.com + 'aud': '{}://{}'.format(url.scheme, url.netloc), + # sub: the sub value needs to be either a URL address. This is so that if a push service needed to reach out + # to sender, it can find contact information from the JWT. + 'sub': base_url, + # exp: It's the expiration of the JWT, this prevents snoopers from being able to re-use a JWT if they intercept it. + 'exp': int(time.time()) + token_validity + }).encode()) + + unsigned_token = '{}.{}'.format(jwt_info.decode().strip('='), jwt_data.decode().strip('=')) + + # Retrieve the private key using a P256 elliptic curve + vapid_private_key_decoded = _base64_decode_with_padding(vapid_private_key) + private_key = ec.derive_private_key(int(binascii.hexlify(vapid_private_key_decoded), 16), ec.SECP256R1(), default_backend()) + + # sign with ECDSA SHA-256 + signature = private_key.sign(unsigned_token.encode(), ec.ECDSA(hashes.SHA256())) + (r, s) = utils.decode_dss_signature(signature) + sig = base64.urlsafe_b64encode(r.to_bytes(32, 'big') + s.to_bytes(32, 'big')) + + return '{}.{}'.format(unsigned_token, sig.decode().strip('=')) + +def _iv(base, counter): + mask = int.from_bytes(base[4:], 'big') + return base[:4] + (counter ^ mask).to_bytes(8, 'big') + +def _derive_key(salt, private_key, device): + # browser keys + device_keys = json.loads(device["keys"]) + p256dh = _base64_decode_with_padding(device_keys.get('p256dh')) + auth = _base64_decode_with_padding(device_keys.get('auth')) + + # generate a public key derived from the browser public key + pub_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), p256dh) + sender_pub_key = private_key.public_key().public_bytes( + Encoding.X962, PublicFormat.UncompressedPoint + ) + + context = b"WebPush: info\x00" + p256dh + sender_pub_key + key_info = b"Content-Encoding: aes128gcm\x00" + nonce_info = b"Content-Encoding: nonce\x00" + + # Create the 3 HKDF keys needed to encrypt the message (auth, key, nonce) + hkdf_auth = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=auth, + info=context, + backend=default_backend(), + ) + hkdf_key = HKDF( + algorithm=hashes.SHA256(), + length=16, + salt=salt, + info=key_info, + backend=default_backend(), + ) + hkdf_nonce = HKDF( + algorithm=hashes.SHA256(), + length=12, + salt=salt, + info=nonce_info, + backend=default_backend(), + ) + secret = hkdf_auth.derive(private_key.exchange(ec.ECDH(), pub_key)) + return hkdf_key.derive(secret), hkdf_nonce.derive(secret) + +def _encrypt_payload(content, device, record_size=MAX_PAYLOAD_SIZE): + """ + Encrypt a payload for Push Notification Endpoint using AES128GCM + + https://www.rfc-editor.org/rfc/rfc7516 + https://www.rfc-editor.org/rfc/rfc8188 + :param content: the unencrypted payload + :param device: the web push user browser information + :param record_size: record size must be bigger than 18 + :return: the encrypted payload + """ + # The private_key is an ephemeral ECDH key used only for a transaction + private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + salt = os.urandom(16) + # generate key + (key, nonce) = _derive_key(salt=salt, private_key=private_key, device=device) + # AEAD_AES_128_GCM produces ciphertext 16 octets longer than its input plaintext. + # Therefore, the unencrypted content of each record is shorter than the record size by 16 octets. + # Valid records always contain at least a padding delimiter octet and a 16-octet authentication tag. + overhead = 1 + 16 + chunk_size = record_size - overhead + + body = b"" + end = len(content) + aesgcm = AESGCM(key) + for i in range(0, end, chunk_size): + padding = b"\x02" if (i + chunk_size) >= end else b"\x01" + body += aesgcm.encrypt(nonce, content[i: i + chunk_size] + padding, None) + + sender_public_key = private_key.public_key().public_bytes( + Encoding.X962, PublicFormat.UncompressedPoint + ) + + # +-----------+-----------------+---------------------------+-------------------------------------------+ + # | salt (16) | record_size (4) | sender_public_key.len (1) | sender_public_key (sender_public_key.len) | + # +-----------+-----------------+---------------------------+-------------------------------------------+ + header = struct.pack("!16sLB", salt, record_size, len(sender_public_key)) + header += sender_public_key + return header + body + +def push_to_end_point(base_url, device, payload, vapid_private_key, vapid_public_key, session): + endpoint = device["endpoint"] + jwt = _generate_jwt(endpoint, base_url, vapid_private_key) + body_payload = payload.encode() + payload = _encrypt_payload(body_payload, device) + headers = { + # Authorization header field contains these parameters: + # - "t" is the JWT; + # - "k" the base64url-encoded key that signed that token. + 'Authorization': 'vapid t={}, k={}'.format(jwt, vapid_public_key), + 'Content-Encoding': 'aes128gcm', + 'TTL': '0', + } + + response = session.post(endpoint, headers=headers, data=payload, timeout=5) + if response.status_code == 201: + _logger.debug('Sent push notification %s', endpoint) + else: + error_message_shorten = textwrap.shorten(response.text, 100) + _logger.warning('Failed push notification %s %d - %s', + endpoint, response.status_code, error_message_shorten) + + # Invalid subscription + if response.status_code == 404 or response.status_code == 410: + raise DeviceUnreachableError("Device Unreachable") + + +class DeviceUnreachableError(Exception): + pass diff --git a/addons/test_mail_full/tests/__init__.py b/addons/test_mail_full/tests/__init__.py index 283b523e43c..589c9e20c5d 100644 --- a/addons/test_mail_full/tests/__init__.py +++ b/addons/test_mail_full/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_mass_mailing from . import test_portal from . import test_rating from . import test_res_users +from . import test_web_push diff --git a/addons/test_mail_full/tests/test_web_push.py b/addons/test_mail_full/tests/test_web_push.py new file mode 100644 index 00000000000..daff60ed6e6 --- /dev/null +++ b/addons/test_mail_full/tests/test_web_push.py @@ -0,0 +1,326 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import json +import socket + +import odoo +from odoo.tools.misc import mute_logger +from odoo.addons.mail.models.partner_devices import InvalidVapidError +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.sms.tests.common import SMSCommon +from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE +from odoo.tests import tagged +from markupsafe import Markup +from unittest.mock import patch +from types import SimpleNamespace + + +@tagged('post_install', '-at_install') +class TestWebPushNotification(SMSCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + channel = cls.env['discuss.channel'].with_context(cls._test_context) + + cls.user_email = cls.user_employee + cls.user_email.notification_type = 'email' + + cls.user_inbox = mail_new_test_user( + cls.env, login='user_inbox', groups='base.group_user', name='User Inbox', + notification_type='inbox' + ) + + cls.record_simple = cls.env['mail.test.simple'].with_context(cls._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com' + }) + cls.record_simple.message_subscribe(partner_ids=[ + cls.user_email.partner_id.id, + cls.user_inbox.partner_id.id, + ]) + + cls.direct_message_channel = channel.with_user(cls.user_email).create({ + 'channel_partner_ids': [ + (4, cls.user_email.partner_id.id), + (4, cls.user_inbox.partner_id.id), + ], + 'channel_type': 'chat', + 'name': 'Direct Message', + }) + + cls.group_channel = cls.env['discuss.channel'].browse(cls.env['discuss.channel'].channel_create(name='Channel', group_id=None)['id']) + cls.group_channel.add_members((cls.user_email + cls.user_inbox).partner_id.ids) + + cls.env['mail.partner.device'].get_web_push_vapid_public_key() + + cls.vapid_public_key = cls.env['mail.partner.device'].get_web_push_vapid_public_key() + + cls.env['mail.partner.device'].sudo().create([{ + 'endpoint': 'https://test.odoo.com/webpush/user1', + 'expiration_time': None, + 'keys': json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + 'partner_id': cls.user_email.partner_id.id, + }]) + + cls.env['mail.partner.device'].sudo().create([{ + 'endpoint': 'https://test.odoo.com/webpush/user2', + 'expiration_time': None, + 'keys': json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + 'partner_id': cls.user_inbox.partner_id.id, + }]) + + def _trigger_cron_job(self): + self.env.ref('mail.ir_cron_web_push_notification').method_direct_trigger() + + def _assert_notification_count_for_cron(self, number_of_notification): + notification_count = self.env['mail.notification.web.push'].search_count([]) + self.assertEqual(notification_count, number_of_notification) + + @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') + def test_push_notifications(self, push_to_end_point): + # Test No Inbox Condition + self.record_simple.with_user(self.user_inbox).message_notify( + partner_ids=self.user_email.partner_id.ids, + body='Test', + subject='Test Activity', + record_name=self.record_simple._name, + ) + + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_not_called() + + + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + record_name=self.record_simple._name, + ) + + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) + self.assertIn(self.record_simple._name, payload_value['title']) + self.assertIn(self.user_email.name, payload_value['title']) + self.assertEqual(payload_value['options']['body'], 'Test message send via Web Push') + self.assertEqual(payload_value['options']['data']['res_id'], self.record_simple.id) + self.assertEqual(payload_value['options']['data']['model'], self.record_simple._name) + self.assertIn('icon', payload_value['options']) + self.assertEqual(push_to_end_point.call_args.kwargs['device']['endpoint'], 'https://test.odoo.com/webpush/user2') + self.assertIn('vapid_private_key', push_to_end_point.call_args.kwargs) + self.assertIn('vapid_public_key', push_to_end_point.call_args.kwargs) + + # Reset the mock counter + push_to_end_point.reset_mock() + + # Test Tracking Message + mail_test_ticket = self.env['mail.test.ticket'].with_context(self._test_context) + record_full = mail_test_ticket.with_user(self.user_email).create({ + 'name': 'Test', + }) + record_full = record_full.with_context(mail_notrack=False) + + container = self.env['mail.test.container'].create({'name': 'Container'}) + record_full.message_subscribe( + partner_ids=[self.user_email.partner_id.id], + subtype_ids=[self.env.ref('test_mail.st_mail_test_ticket_container_upd').id], + ) + record_full.write({ + 'name': 'Test2', + 'email_from': 'noone@example.com', + 'container_id': container.id, + }) + self.flush_tracking() + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_not_called() + + container2 = self.env['mail.test.container'].create({'name': 'Container Two'}) + record_full.message_subscribe( + partner_ids=[self.user_inbox.partner_id.id], + subtype_ids=[self.env.ref('test_mail.st_mail_test_ticket_container_upd').id], + ) + record_full.write({ + 'name': 'Test3', + 'email_from': 'noone@example.com', + 'container_id': container2.id, + }) + self.flush_tracking() + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) + # As the tracking values are converted to text. We check the '→' added by ocn_client. + self.assertIn('→', payload_value['options']['body'], 'No Tracking Message found') + + @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') + def test_push_notifications_all_type(self, push_to_end_point): + # Test Direct Message + self.direct_message_channel.with_user(self.user_email).message_post( + body='Test', message_type='comment', subtype_xmlid='mail.mt_comment') + + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + + + # Reset the mock counter + push_to_end_point.reset_mock() + + # Test Following Message + self.record_simple.with_user(self.user_email).message_post( + body='Test', message_type='comment', subtype_xmlid='mail.mt_comment' + ) + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + + # Reset the mock counter + push_to_end_point.reset_mock() + + # Test Channel Message + self.group_channel.with_user(self.user_email).message_post( + body='Test', partner_ids=self.user_inbox.partner_id.ids, + message_type='comment', subtype_xmlid='mail.mt_comment') + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + + # Reset the mock counter + push_to_end_point.reset_mock() + + # Test AtMention Message + self.record_simple.with_user(self.user_email).message_post( + body=Markup('@user') % + self.user_inbox.partner_id.id, + message_type='comment', subtype_xmlid='mail.mt_comment' + ) + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + + @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') + def test_push_notifications_mail_replay(self, push_to_end_point): + test_record = self.env['mail.test.gateway'].with_context(self._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com', + }) + test_record.message_subscribe(partner_ids=[self.user_inbox.partner_id.id]) + + fake_email = self.env['mail.message'].create({ + 'model': 'mail.test.gateway', + 'res_id': test_record.id, + 'subject': 'Public Discussion', + 'message_type': 'email', + 'subtype_id': self.env.ref('mail.mt_comment').id, + 'author_id': self.user_email.partner_id.id, + 'message_id': '<123456-openerp-%s-mail.test.gateway@%s>' % (test_record.id, socket.gethostname()), + }) + + self.format_and_process( + MAIL_TEMPLATE, self.user_email.email_formatted, + self.user_inbox.email_formatted, + subject='Test Subject Reply By mail', + extra='In-Reply-To:\r\n\t%s\n' % fake_email.message_id, + ) + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) + self.assertIn(self.user_email.name, payload_value['title']) + self.assertIn( + 'Please call me as soon as possible this afternoon!\n\n--\nSylvie', + payload_value['options']['body'], + 'The body must contain the text send by mail' + ) + + @patch.object(odoo.addons.mail.models.web_push, 'push_to_end_point') + def test_push_notifications_cron(self, push_to_end_point): + # Add 4 more devices to force sending via cron queue + for index in range(10, 14): + self.env['mail.partner.device'].sudo().create([{ + 'endpoint': 'https://test.odoo.com/webpush/user%d' % index, + 'expiration_time': None, + 'keys': json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + 'partner_id': self.user_inbox.partner_id.id, + }]) + + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + record_name=self.record_simple._name, + ) + + self._assert_notification_count_for_cron(5) + # Force the execution of the cron + self._trigger_cron_job() + self.assertEqual(push_to_end_point.call_count, 5) + + @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', + return_value=SimpleNamespace(**{'status_code': 201, 'text': 'Ok'})) + def test_push_notifications_encryption_simple(self, post): + """ + Test to see if all parameters sent to the endpoint are present. + This test doesn't test if the cryptographic values are correct. + """ + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + record_name=self.record_simple._name, + ) + + self._assert_notification_count_for_cron(0) + post.assert_called_once() + self.assertEqual(post.call_args.args[0], 'https://test.odoo.com/webpush/user2') + self.assertIn('headers', post.call_args.kwargs) + self.assertIn('vapid', post.call_args.kwargs['headers']['Authorization']) + self.assertIn('t=', post.call_args.kwargs['headers']['Authorization']) + self.assertIn('k=', post.call_args.kwargs['headers']['Authorization']) + self.assertEqual('aes128gcm', post.call_args.kwargs['headers']['Content-Encoding']) + self.assertEqual('0', post.call_args.kwargs['headers']['TTL']) + self.assertIn('data', post.call_args.kwargs) + self.assertIn('timeout', post.call_args.kwargs) + + @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', + return_value=SimpleNamespace(**{'status_code': 404, 'text': 'Device Unreachable'})) + def test_push_notifications_device_unreachable(self, post): + with mute_logger('odoo.addons.mail.web_push'): + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + record_name=self.record_simple._name, + ) + + self._assert_notification_count_for_cron(0) + post.assert_called_once() + # Test that the unreachable device is deleted from the DB + notification_count = self.env['mail.partner.device'].search_count([('endpoint', '=', 'https://test.odoo.com/webpush/user2')]) + self.assertEqual(notification_count, 0) + + + def test_push_notification_regenerate_vpaid_keys(self): + ir_params_sudo = self.env['ir.config_parameter'].sudo() + ir_params_sudo.search([('key', 'in', [ + 'mail.web_push_vapid_private_key', + 'mail.web_push_vapid_public_key' + ])]).unlink() + new_vapid_public_key = self.env['mail.partner.device'].get_web_push_vapid_public_key() + self.assertNotEqual(self.vapid_public_key, new_vapid_public_key) + with self.assertRaises(InvalidVapidError): + self.env['mail.partner.device'].register_devices( + endpoint='https://test.odoo.com/webpush/user1', + expiration_time=None, + keys=json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + partner_id=self.user_email.partner_id.id, + vapid_public_key=self.vapid_public_key, + ) diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 6a825d0d021..28ad4477d56 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -18,6 +18,7 @@ This module provides the core of the Odoo Web Client. 'views/webclient_templates.xml', 'views/report_templates.xml', 'views/base_document_layout_views.xml', + 'views/partner_view.xml', 'views/speedscope_template.xml', 'views/neutralize_views.xml', 'data/ir_attachment.xml', diff --git a/addons/web/controllers/__init__.py b/addons/web/controllers/__init__.py index ceb32b0072c..73d97efdbf8 100644 --- a/addons/web/controllers/__init__.py +++ b/addons/web/controllers/__init__.py @@ -11,7 +11,9 @@ from . import pivot from . import profiling from . import report from . import session +from . import vcard from . import view from . import webclient +from . import webmanifest from . import main # deprecated diff --git a/addons/web/controllers/vcard.py b/addons/web/controllers/vcard.py new file mode 100644 index 00000000000..d0b2d4f6986 --- /dev/null +++ b/addons/web/controllers/vcard.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.http as http + +from odoo.http import request, content_disposition + + +class Partner(http.Controller): + + @http.route('/web/partner//vcard', type='http', auth="user") + def download_vcard(self, partner, **kwargs): + content = partner._get_vcard_file() + if not content: + return request.not_found() + return request.make_response(content, [ + ('Content-Type', 'text/vcard'), + ('Content-Length', len(content)), + ('Content-Disposition', content_disposition('%s.vcf' % partner.name)) + ]) diff --git a/addons/web/controllers/webmanifest.py b/addons/web/controllers/webmanifest.py new file mode 100644 index 00000000000..01bc4a7ce43 --- /dev/null +++ b/addons/web/controllers/webmanifest.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import json +import mimetypes + +from odoo import http +from odoo.exceptions import AccessError +from odoo.http import request +from odoo.tools import ustr, file_open + + +class WebManifest(http.Controller): + + def _get_shortcuts(self): + module_names = ['mail', 'crm', 'project', 'project_todo'] + try: + module_ids = request.env['ir.module.module'].search([('state', '=', 'installed'), ('name', 'in', module_names)]) \ + .sorted(key=lambda r: module_names.index(r["name"])) + except AccessError: + return [] + menu_roots = request.env['ir.ui.menu'].get_user_roots() + datas = request.env['ir.model.data'].sudo().search([('model', '=', 'ir.ui.menu'), + ('res_id', 'in', menu_roots.ids), + ('module', 'in', module_names)]) + shortcuts = [] + for module in module_ids: + data = datas.filtered(lambda res: res.module == module.name) + if data: + shortcuts.append({ + 'name': module.display_name, + 'url': '/web#menu_id=%s' % data.mapped('res_id')[0], + 'description': module.summary, + 'icons': [{ + 'sizes': '100x100', + 'src': module.icon, + 'type': mimetypes.guess_type(module.icon)[0] or 'image/png' + }] + }) + return shortcuts + + @http.route('/web/manifest.webmanifest', type='http', auth='public', methods=['GET']) + def webmanifest(self): + """ Returns a WebManifest describing the metadata associated with a web application. + Using this metadata, user agents can provide developers with means to create user + experiences that are more comparable to that of a native application. + """ + web_app_name = request.env['ir.config_parameter'].sudo().get_param('web.web_app_name', 'Odoo') + manifest = { + 'name': web_app_name, + 'scope': '/web', + 'start_url': '/web', + 'display': 'standalone', + 'background_color': '#714B67', + 'theme_color': '#714B67', + 'prefer_related_applications': False, + } + icon_sizes = ['192x192', '512x512'] + manifest['icons'] = [{ + 'src': '/web/static/img/odoo-icon-%s.png' % size, + 'sizes': size, + 'type': 'image/png', + } for size in icon_sizes] + manifest['shortcuts'] = self._get_shortcuts() + body = json.dumps(manifest, default=ustr) + response = request.make_response(body, [ + ('Content-Type', 'application/manifest+json'), + ]) + return response + + @http.route('/web/service-worker.js', type='http', auth='public', methods=['GET']) + def service_worker(self): + response = request.make_response( + self._get_service_worker_content(), + [ + ('Content-Type', 'text/javascript'), + ('Service-Worker-Allowed', '/web'), + ] + ) + return response + + def _get_service_worker_content(self): + """ Returns a ServiceWorker javascript file scoped for the backend (aka. '/web') + """ + with file_open('web/static/src/service_worker.js') as f: + body = f.read() + return body + + def _icon_path(self): + return 'web/static/img/odoo-icon-192x192.png' + + @http.route('/web/offline', type='http', auth='public', methods=['GET']) + def offline(self): + """ Returns the offline page delivered by the service worker """ + return request.render('web.webclient_offline', { + 'odoo_icon': base64.b64encode(file_open(self._icon_path(), 'rb').read()) + }) diff --git a/addons/web/models/__init__.py b/addons/web/models/__init__.py index c011109670b..8a1845070fa 100644 --- a/addons/web/models/__init__.py +++ b/addons/web/models/__init__.py @@ -7,4 +7,6 @@ from . import ir_model from . import ir_ui_menu from . import models from . import base_document_layout +from . import res_config_settings +from . import res_partner from . import res_users diff --git a/addons/web/models/res_config_settings.py b/addons/web/models/res_config_settings.py new file mode 100644 index 00000000000..e8e0ee36cbd --- /dev/null +++ b/addons/web/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + web_app_name = fields.Char('Web App Name', config_parameter='web.web_app_name') diff --git a/addons/web/models/res_partner.py b/addons/web/models/res_partner.py new file mode 100644 index 00000000000..f7d42f5ea42 --- /dev/null +++ b/addons/web/models/res_partner.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +from base64 import b64decode + +from odoo import models + +_logger = logging.getLogger(__name__) + +try: + import vobject +except ImportError: + _logger.warning("`vobject` Python module not found, vcard file generation disabled. Consider installing this module if you want to generate vcard files") + vobject = None + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + def _build_vcard(self): + """ Build the partner's vCard. + :returns a vobject.vCard object + """ + if not vobject: + return False + vcard = vobject.vCard() + # Name + n = vcard.add('n') + n.value = vobject.vcard.Name(family=self.name) + if self.title: + n.value.prefix = self.title.name + # Formatted Name + fn = vcard.add('fn') + fn.value = self.name + # Address + adr = vcard.add('adr') + adr.value = vobject.vcard.Address(street=self.street or '', city=self.city or '', code=self.zip or '') + if self.state_id: + adr.value.region = self.state_id.name + if self.country_id: + adr.value.country = self.country_id.name + # Email + if self.email: + email = vcard.add('email') + email.value = self.email + email.type_param = 'INTERNET' + # Telephone numbers + if self.phone: + tel = vcard.add('tel') + tel.type_param = 'work' + tel.value = self.phone + if self.mobile: + tel = vcard.add('tel') + tel.type_param = 'cell' + tel.value = self.mobile + # URL + if self.website: + url = vcard.add('url') + url.value = self.website + # Organisation + if self.commercial_company_name: + org = vcard.add('org') + org.value = [self.commercial_company_name] + if self.function: + function = vcard.add('title') + function.value = self.function + # Photo + photo = vcard.add('photo') + photo.value = b64decode(self.avatar_512) + photo.encoding_param = 'B' + photo.type_param = 'JPG' + return vcard + + def _get_vcard_file(self): + vcard = self._build_vcard() + if vcard: + return vcard.serialize().encode() + return False diff --git a/addons/web/static/img/odoo-icon-192x192.png b/addons/web/static/img/odoo-icon-192x192.png new file mode 100644 index 00000000000..c79f5477c7b Binary files /dev/null and b/addons/web/static/img/odoo-icon-192x192.png differ diff --git a/addons/web/static/img/odoo-icon-512x512.png b/addons/web/static/img/odoo-icon-512x512.png new file mode 100644 index 00000000000..e37ce12765f Binary files /dev/null and b/addons/web/static/img/odoo-icon-512x512.png differ diff --git a/addons/web/static/img/odoo-icon-ios.png b/addons/web/static/img/odoo-icon-ios.png new file mode 100644 index 00000000000..e16cb2c9ea2 Binary files /dev/null and b/addons/web/static/img/odoo-icon-ios.png differ diff --git a/addons/web/static/img/odoo-icon.svg b/addons/web/static/img/odoo-icon.svg new file mode 100644 index 00000000000..9f8df0a0594 --- /dev/null +++ b/addons/web/static/img/odoo-icon.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/addons/web/static/src/service_worker.js b/addons/web/static/src/service_worker.js new file mode 100644 index 00000000000..da03c832c93 --- /dev/null +++ b/addons/web/static/src/service_worker.js @@ -0,0 +1,37 @@ +/* eslint-disable no-restricted-globals */ +const cacheName = "odoo-sw-cache"; +const cachedRequests = ["/web/offline"]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(cacheName).then((cache) => cache.addAll(cachedRequests))); +}); + +const navigateOrDisplayOfflinePage = async (request) => { + try { + return await fetch(request); + } catch (requestError) { + if ( + request.method === "GET" && + ["Failed to fetch", "Load failed"].includes(requestError.message) + ) { + if (cachedRequests.includes("/web/offline")) { + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match("/web/offline"); + if (cachedResponse) { + return cachedResponse; + } + } + } + throw requestError; + } +}; + +self.addEventListener("fetch", (event) => { + if ( + (event.request.mode === "navigate" && event.request.destination === "document") || + // request.mode = navigate isn't supported in all browsers => check for http header accept:text/html + event.request.headers.get("accept").includes("text/html") + ) { + event.respondWith(navigateOrDisplayOfflinePage(event.request)); + } +}); diff --git a/addons/web/static/src/webclient/webclient.js b/addons/web/static/src/webclient/webclient.js index 6c3662f8a0d..630d6833053 100644 --- a/addons/web/static/src/webclient/webclient.js +++ b/addons/web/static/src/webclient/webclient.js @@ -9,7 +9,7 @@ import { useBus, useService } from "@web/core/utils/hooks"; import { ActionContainer } from "./actions/action_container"; import { NavBar } from "./navbar/navbar"; -import { Component, onMounted, useExternalListener, useState } from "@odoo/owl"; +import { Component, onMounted, onWillStart, useExternalListener, useState } from "@odoo/owl"; export class WebClient extends Component { setup() { @@ -47,6 +47,7 @@ export class WebClient extends Component { this.env.bus.trigger("WEB_CLIENT_READY"); }); useExternalListener(window, "click", this.onGlobalClick, { capture: true }); + onWillStart(this.registerServiceWorker); } async loadRouterState() { @@ -108,6 +109,19 @@ export class WebClient extends Component { return; } } + + registerServiceWorker() { + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/web/service-worker.js", { scope: "/web" }) + .then((registration) => { + console.info("Registration successful, scope is:", registration.scope); + }) + .catch((error) => { + console.error("Service worker registration failed, error:", error); + }); + } + } } WebClient.components = { ActionContainer, diff --git a/addons/web/static/tests/webclient/webclient_tests.js b/addons/web/static/tests/webclient/webclient_tests.js index 2e3b12d5823..da125f16af5 100644 --- a/addons/web/static/tests/webclient/webclient_tests.js +++ b/addons/web/static/tests/webclient/webclient_tests.js @@ -2,6 +2,7 @@ import { dialogService } from "@web/core/dialog/dialog_service"; import { notificationService } from "@web/core/notifications/notification_service"; +import { ormService } from "@web/core/orm_service"; import { popoverService } from "@web/core/popover/popover_service"; import { registry } from "@web/core/registry"; import { uiService } from "@web/core/ui/ui_service"; @@ -24,6 +25,7 @@ let target; QUnit.module("WebClient", { async beforeEach() { serviceRegistry + .add("orm", ormService) .add("action", actionService) .add("dialog", dialogService) .add("hotkey", hotkeyService) diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index 7420ed38091..51cb07d6920 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -9,6 +9,7 @@ from . import test_menu from . import test_click_everywhere from . import test_base_document_layout from . import test_load_menus +from . import test_partner from . import test_profiler from . import test_session_info from . import test_read_progress_bar @@ -19,3 +20,4 @@ from . import test_web_search_read from . import test_domain from . import test_web_redirect from . import test_res_users +from . import test_webmanifest diff --git a/addons/web/tests/test_partner.py b/addons/web/tests/test_partner.py new file mode 100644 index 00000000000..8263266dd6e --- /dev/null +++ b/addons/web/tests/test_partner.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import unittest + +from odoo.tests.common import HttpCase, tagged +from base64 import b64decode + +_logger = logging.getLogger(__name__) + +try: + import vobject +except ImportError: + _logger.warning("`vobject` Python module not found, vcard file generation disabled. Consider installing this module if you want to generate vcard files") + vobject = None + + +@tagged('-at_install', 'post_install') +class TestPartnerVCard(HttpCase): + + def setUp(self): + super().setUp() + + if not vobject: + raise unittest.SkipTest("Skip tests when `vobject` Python module is not found.") + + self.partner = self.env['res.partner'].create({ + 'name': 'John Doe', + 'email': 'john.doe@test.example.com', + 'mobile': '+1 202 555 0888', + 'phone': '+1 202 555 0122', + 'function': 'Painter', + 'street': 'Cookieville Minimum-Security Orphanarium', + 'city': 'New York', + 'country_id': self.env.ref('base.us').id, + 'zip': '97648', + 'website': 'https://test.exemple.com', + }) + self.authenticate("admin", "admin") + + def test_fetch_partner_vcard(self): + res = self.url_open('/web/partner/%d/vcard' % self.partner.id) + vcard = vobject.readOne(res.text) + self.assertEqual(vcard.contents["n"][0].value.family, self.partner.name, "Vcard should have the same name") + self.assertEqual(vcard.contents["adr"][0].value.street, self.partner.street, "Vcard should have the same street") + self.assertEqual(vcard.contents["adr"][0].value.city, self.partner.city, "Vcard should have the same city") + self.assertEqual(vcard.contents["adr"][0].value.code, self.partner.zip, "Vcard should have the same zip") + self.assertEqual(vcard.contents["adr"][0].value.country, self.env.ref('base.us').name, "Vcard should have the same country") + self.assertEqual(vcard.contents["email"][0].value, self.partner.email, "Vcard should have the same email") + self.assertEqual(vcard.contents["url"][0].value, self.partner.website, "Vcard should have the same website") + self.assertEqual(vcard.contents["tel"][0].params['TYPE'], ["work"], "Vcard should have the same phone") + self.assertEqual(vcard.contents["tel"][0].value, self.partner.phone, "Vcard should have the same phone") + self.assertEqual(vcard.contents["tel"][1].params['TYPE'], ["cell"], "Vcard should have the same mobile") + self.assertEqual(vcard.contents["tel"][1].value, self.partner.mobile, "Vcard should have the same mobile") + self.assertEqual(vcard.contents["title"][0].value, self.partner.function, "Vcard should have the same function") + self.assertEqual(len(vcard.contents['photo'][0].value), len(b64decode(self.partner.avatar_512)), "Vcard should have the same photo") + + @unittest.skip + def test_not_exist_partner_vcard(self): + partner_id = self.partner.id + self.partner.unlink() + res = self.url_open('/web/partner/%d/vcard' % partner_id) + self.assertEqual(res.status_code, 404) diff --git a/addons/web/tests/test_webmanifest.py b/addons/web/tests/test_webmanifest.py new file mode 100644 index 00000000000..fac7a53eeb1 --- /dev/null +++ b/addons/web/tests/test_webmanifest.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import HttpCase, tagged + +@tagged("-at_install", "post_install") +class WebManifestRoutesTest(HttpCase): + """ + This test suite is used to request the routes used by the PWA backend implementation + """ + + def test_webmanifest(self): + """ + This route returns a well formed backend's WebManifest + """ + self.authenticate("admin", "admin") + response = self.url_open("/web/manifest.webmanifest") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "application/manifest+json") + data = response.json() + self.assertEqual(data["name"], "Odoo") + self.assertEqual(data["scope"], "/web") + self.assertEqual(data["start_url"], "/web") + self.assertEqual(data["display"], "standalone") + self.assertEqual(data["background_color"], "#714B67") + self.assertEqual(data["theme_color"], "#714B67") + self.assertEqual(data["prefer_related_applications"], False) + self.assertCountEqual(data["icons"], [ + {'src': '/web/static/img/odoo-icon-192x192.png', 'sizes': '192x192', 'type': 'image/png'}, + {'src': '/web/static/img/odoo-icon-512x512.png', 'sizes': '512x512', 'type': 'image/png'} + ]) + self.assertGreaterEqual(len(data["shortcuts"]), 0) + for shortcut in data["shortcuts"]: + self.assertGreater(len(shortcut["name"]), 0) + self.assertGreater(len(shortcut["description"]), 0) + self.assertGreater(len(shortcut["icons"]), 0) + self.assertTrue(shortcut["url"].startswith("/web#menu_id=")) + + def test_webmanifest_unauthenticated(self): + """ + This route returns a well formed backend's WebManifest + """ + response = self.url_open("/web/manifest.webmanifest") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "application/manifest+json") + data = response.json() + self.assertEqual(data["name"], "Odoo") + self.assertEqual(data["scope"], "/web") + self.assertEqual(data["start_url"], "/web") + self.assertEqual(data["display"], "standalone") + self.assertEqual(data["background_color"], "#714B67") + self.assertEqual(data["theme_color"], "#714B67") + self.assertEqual(data["prefer_related_applications"], False) + self.assertCountEqual(data["icons"], [ + {'src': '/web/static/img/odoo-icon-192x192.png', 'sizes': '192x192', 'type': 'image/png'}, + {'src': '/web/static/img/odoo-icon-512x512.png', 'sizes': '512x512', 'type': 'image/png'} + ]) + self.assertEqual(len(data["shortcuts"]), 0) + + def test_serviceworker(self): + """ + This route returns a JavaScript's ServiceWorker + """ + response = self.url_open("/web/service-worker.js") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "text/javascript") + self.assertEqual(response.headers["Service-Worker-Allowed"], "/web") + + def test_offline_url(self): + """ + This route returns the offline page + """ + response = self.url_open("/web/offline") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "text/html; charset=utf-8") + + def test_apple_touch_icon(self): + """ + This request tests the presence of an apple-touch-icon image route for the PWA icon and + its presence from the head of the document. + """ + self.authenticate("demo", "demo") + response = self.url_open("/web/static/img/odoo-icon-ios.png") + self.assertEqual(response.status_code, 200) + + document = self.url_open("/web") + self.assertIn( + '', document.text, + "Icon for iOS is present in the head of the document.", + ) diff --git a/addons/web/views/partner_view.xml b/addons/web/views/partner_view.xml new file mode 100644 index 00000000000..314960f307e --- /dev/null +++ b/addons/web/views/partner_view.xml @@ -0,0 +1,17 @@ + + + + Download (vCard) + + + form + code + + action = { + 'type': 'ir.actions.act_url', + 'url': '/web/partner/%d/vcard' % record.id, + 'target': 'self', + } + + + diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 83d4e828126..fa76ed1a162 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -253,6 +253,8 @@ + + + + + +
+ Odoo logo +

You are offline

+

Check your network connection and come back here. Odoo will load as soon as you're back online.

+ +
+ +