From 83e4551492d403f0c27a9cd8b9ee5db4fc104ce1 Mon Sep 17 00:00:00 2001 From: Renaud Thiry Date: Tue, 6 Dec 2022 19:51:26 +0000 Subject: [PATCH 1/3] [FIX] event: add signed static maps api Google now requires an API key for the static maps API. As API calls cost money, and we cannot restrict the domain of the caller with the API key in emails, as the domain may vary vastly, the signing of static API URLs is implemented in the existing 'google_map_img' of partners. This in turn can be used to get a url for an existing location. While preventing anyone from stealing the API key for their own purposes task - 3079113 Part-of: odoo/odoo#107200 --- addons/event/data/mail_template_data.xml | 12 +++-- addons/event/models/res_partner.py | 66 +++++++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/addons/event/data/mail_template_data.xml b/addons/event/data/mail_template_data.xml index 03d541a588d..392b637d14d 100644 --- a/addons/event/data/mail_template_data.xml +++ b/addons/event/data/mail_template_data.xml @@ -225,7 +225,10 @@
- See location on Google Maps + Google Maps + See location on Google Maps
@@ -442,11 +445,14 @@ -
+
diff --git a/addons/event/models/res_partner.py b/addons/event/models/res_partner.py index f82b58ae502..58a59efc063 100644 --- a/addons/event/models/res_partner.py +++ b/addons/event/models/res_partner.py @@ -1,7 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models +import base64 +import binascii +import hmac + +import requests +import werkzeug.urls + +from odoo import api, fields, models class ResPartner(models.Model): @@ -9,14 +16,71 @@ class ResPartner(models.Model): event_count = fields.Integer( '# Events', compute='_compute_event_count', groups='event.group_event_registration_desk') + static_map_url = fields.Char(compute="_compute_static_map_url") + static_map_url_is_valid = fields.Boolean(compute="_compute_static_map_url_is_valid") def _compute_event_count(self): self.event_count = 0 for partner in self: partner.event_count = self.env['event.event'].search_count([('registration_ids.partner_id', 'child_of', partner.ids)]) + @api.depends('zip', 'city', 'country_id', 'street') + def _compute_static_map_url(self): + for partner in self: + partner.static_map_url = partner._google_map_signed_img(zoom=13, width=598, height=200) + + def _compute_static_map_url_is_valid(self): + """Compute whether the link is valid. + + This should only remain valid for a relatively short time. + Here, for the duration it is in cache. + """ + session = requests.Session() + for partner in self: + url = self.static_map_url + if not url: + partner.static_map_url_is_valid = False + continue + + is_valid = False + # If the response isn't strictly successful, assume invalid url + try: + res = session.get(url, timeout=2) + if res.ok and not res.headers.get('X-Staticmap-API-Warning'): + is_valid = True + except requests.exceptions.RequestException: + pass + + partner.static_map_url_is_valid = is_valid + def action_event_view(self): action = self.env["ir.actions.actions"]._for_xml_id("event.action_event_view") action['context'] = {} action['domain'] = [('registration_ids.partner_id', 'child_of', self.ids)] return action + + def _google_map_signed_img(self, zoom=13, width=298, height=298): + """Create a signed static image URL for the location of this partner.""" + GOOGLE_MAPS_STATIC_API_KEY = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_key') + GOOGLE_MAPS_STATIC_API_SECRET = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_secret') + if not GOOGLE_MAPS_STATIC_API_KEY or not GOOGLE_MAPS_STATIC_API_SECRET: + return None + # generate signature as per https://developers.google.com/maps/documentation/maps-static/digital-signature#server-side-signing + location_string = f"{self.street}, {self.city} {self.zip}, {self.country_id and self.country_id.display_name or ''}" + params = { + 'center': location_string, + 'markers': f'size:mid|{location_string}', + 'size': f"{width}x{height}", + 'zoom': zoom, + 'sensor': "false", + 'key': GOOGLE_MAPS_STATIC_API_KEY, + } + unsigned_path = '/maps/api/staticmap?' + werkzeug.urls.url_encode(params) + try: + api_secret_bytes = base64.urlsafe_b64decode(GOOGLE_MAPS_STATIC_API_SECRET + "====") + except binascii.Error: + return None + url_signature_bytes = hmac.digest(api_secret_bytes, unsigned_path.encode(), 'sha1') + params['signature'] = base64.urlsafe_b64encode(url_signature_bytes) + + return 'https://maps.googleapis.com/maps/api/staticmap?' + werkzeug.urls.url_encode(params) From 134850966879f9e007c3126fe1360b0ba740fd91 Mon Sep 17 00:00:00 2001 From: Renaud Thiry Date: Wed, 14 Dec 2022 13:59:11 +0000 Subject: [PATCH 2/3] [IMP] event: add setting menu for g-map API key Add some setting buttons for users to configure their API keys in events task-3079113 Part-of: odoo/odoo#107200 --- addons/event/models/res_config_settings.py | 48 ++++++++++++++++++- .../event/views/res_config_settings_views.xml | 19 ++++++++ .../mail/views/res_config_settings_views.xml | 5 +- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/addons/event/models/res_config_settings.py b/addons/event/models/res_config_settings.py index 5105365bdc6..38907e18fb1 100644 --- a/addons/event/models/res_config_settings.py +++ b/addons/event/models/res_config_settings.py @@ -1,12 +1,23 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +import base64 +import binascii +from odoo import _, api, exceptions, fields, models class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' + def _default_use_google_maps_static_api(self): + api_key = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_key') + api_secret = self.env['ir.config_parameter'].sudo().get_param('google_maps.signed_static_api_secret') + return bool(api_key and api_secret) + + google_maps_static_api_key = fields.Char("Google Maps API key", compute="_compute_maps_static_api_key", + readonly=False, store=True, config_parameter='google_maps.signed_static_api_key') + google_maps_static_api_secret = fields.Char("Google Maps API secret", compute="_compute_maps_static_api_secret", + readonly=False, store=True, config_parameter='google_maps.signed_static_api_secret') module_event_sale = fields.Boolean("Tickets") module_website_event_meet = fields.Boolean("Discussion Rooms") module_website_event_track = fields.Boolean("Tracks and Agenda") @@ -17,6 +28,21 @@ class ResConfigSettings(models.TransientModel): module_event_barcode = fields.Boolean("Barcode") module_website_event_sale = fields.Boolean("Online Ticketing") module_event_booth = fields.Boolean("Booth Management") + use_google_maps_static_api = fields.Boolean("Google Maps static API", default=_default_use_google_maps_static_api) + + @api.depends('use_google_maps_static_api') + def _compute_maps_static_api_key(self): + """Clear API key on disabling google maps.""" + for config in self: + if not config.use_google_maps_static_api: + config.google_maps_static_api_key = '' + + @api.depends('use_google_maps_static_api') + def _compute_maps_static_api_secret(self): + """Clear API secret on disabling google maps.""" + for config in self: + if not config.use_google_maps_static_api: + config.google_maps_static_api_secret = '' @api.onchange('module_website_event_track') def _onchange_module_website_event_track(self): @@ -27,3 +53,23 @@ class ResConfigSettings(models.TransientModel): if not config.module_website_event_track: config.module_website_event_track_live = False config.module_website_event_track_quiz = False + + def _check_google_maps_static_api_secret(self): + for config in self: + if config.google_maps_static_api_secret: + try: + base64.urlsafe_b64decode(config.google_maps_static_api_secret) + except binascii.Error: + raise exceptions.UserError(_("Please enter a valid base64 secret")) + + @api.model_create_multi + def create(self, vals_list): + configs = super().create(vals_list) + configs._check_google_maps_static_api_secret() + return configs + + def write(self, vals): + configs = super().write(vals) + if vals.get('google_maps_static_api_secret'): + configs._check_google_maps_static_api_secret() + return configs diff --git a/addons/event/views/res_config_settings_views.xml b/addons/event/views/res_config_settings_views.xml index f26b6c7796c..5b734a72bca 100644 --- a/addons/event/views/res_config_settings_views.xml +++ b/addons/event/views/res_config_settings_views.xml @@ -52,6 +52,25 @@ + + + + +
+
+
+
+
+
+
+
diff --git a/addons/mail/views/res_config_settings_views.xml b/addons/mail/views/res_config_settings_views.xml index 291c3c95cb7..b805040ed73 100644 --- a/addons/mail/views/res_config_settings_views.xml +++ b/addons/mail/views/res_config_settings_views.xml @@ -42,7 +42,10 @@
Save this page and come back here to set up the feature.
- +
From a64a72188b190074c55fea02aa170a7b08f74ff2 Mon Sep 17 00:00:00 2001 From: Renaud Thiry Date: Fri, 20 Jan 2023 09:21:09 +0000 Subject: [PATCH 3/3] [FIX] mail: remove MAKO compatibility Before this change URLs would get their url-safe encoding replaced with regular characters when cleaning up html content. This caused signed URLs that use some of those characters to become invalid if the target did not accept equivalent characters. Concrete example: /markers=size%3Alittle%7CAddress is not the same as /markers=size%3Alittle|Address for google maps signed static urls Do not add special handling for MAKO patterns as they are not used in templates anymore task - 3079113 Part-of: odoo/odoo#107200 --- odoo/tools/mail.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index 5271c302adc..0f6a8c86b30 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -223,16 +223,6 @@ def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=Fals cleaner = _Cleaner(**kwargs) cleaned = cleaner.clean_html(src) assert isinstance(cleaned, str) - # MAKO compatibility: $, { and } inside quotes are escaped, preventing correct mako execution - cleaned = cleaned.replace(u'%24', u'$') - cleaned = cleaned.replace(u'%7B', u'{') - cleaned = cleaned.replace(u'%7D', u'}') - cleaned = cleaned.replace(u'%20', u' ') - cleaned = cleaned.replace(u'%5B', u'[') - cleaned = cleaned.replace(u'%5D', u']') - cleaned = cleaned.replace(u'%7C', u'|') - cleaned = cleaned.replace(u'<%', u'<%') - cleaned = cleaned.replace(u'%>', u'%>') # html considerations so real html content match database value cleaned = cleaned.replace(u'\xa0', u' ') except etree.ParserError as e: