diff --git a/addons/digest/controllers/portal.py b/addons/digest/controllers/portal.py index 451956a7bfa..31cf3197e99 100644 --- a/addons/digest/controllers/portal.py +++ b/addons/digest/controllers/portal.py @@ -1,21 +1,34 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from werkzeug.exceptions import Forbidden +from werkzeug.exceptions import Forbidden, NotFound from werkzeug.urls import url_encode from odoo import _ from odoo.http import Controller, request, route +from odoo.tools import consteq class DigestController(Controller): - @route('/digest//unsubscribe', type='http', website=True, auth='user') - def digest_unsubscribe(self, digest_id): - digest = request.env['digest.digest'].browse(digest_id).exists() - digest.action_unsubcribe() + @route('/digest//unsubscribe', type='http', website=True, auth='public') + def digest_unsubscribe(self, digest_id, token=None, user_id=None): + digest_sudo = request.env['digest.digest'].sudo().browse(digest_id).exists() + + # new route parameters + if digest_sudo and token and user_id: + correct_token = digest_sudo._get_unsubscribe_token(int(user_id)) + if not consteq(correct_token, token): + raise NotFound() + digest_sudo._action_unsubscribe_users(request.env['res.users'].sudo().browse(int(user_id))) + # old route was given without any token or user_id but only for auth users + elif digest_sudo and not token and not user_id and not request.env.user.share: + digest_sudo.action_unsubcribe() + else: + raise NotFound() + return request.render('digest.portal_digest_unsubscribed', { - 'digest': digest, + 'digest': digest_sudo, }) @route('/digest//set_periodicity', type='http', website=True, auth='user') diff --git a/addons/digest/data/digest_data.xml b/addons/digest/data/digest_data.xml index 6d8161a3c1c..1d7826141cb 100644 --- a/addons/digest/data/digest_data.xml +++ b/addons/digest/data/digest_data.xml @@ -402,7 +402,14 @@
Sent by Odoo - + + – + + Unsubscribe + + + – diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 0a9edbc2b6f..658e00e4eac 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -7,7 +7,6 @@ import pytz from datetime import datetime, date from dateutil.relativedelta import relativedelta from markupsafe import Markup -from werkzeug.urls import url_join from odoo import api, fields, models, tools, _ from odoo.addons.base.models.ir_mail_server import MailDeliveryException @@ -137,6 +136,7 @@ class Digest(models.Model): 'top_button_url': self.get_base_url(), 'company': user.company_id, 'user': user, + 'unsubscribe_token': self._get_unsubscribe_token(user.id), 'tips_count': tips_count, 'formatted_date': datetime.today().strftime('%B %d, %Y'), 'display_mobile_banner': True, @@ -176,6 +176,14 @@ class Digest(models.Model): except MailDeliveryException as e: _logger.warning('MailDeliveryException while sending digest %d. Digest is now scheduled for next cron update.', digest.id) + def _get_unsubscribe_token(self, user_id): + """Generate a secure hash for this digest and user. It allows to + unsubscribe from a digest while keeping some security in that process. + + :param int user_id: ID of the user to unsubscribe + """ + return tools.hmac(self.env(su=True), 'digest-unsubscribe', (self.id, user_id)) + # ------------------------------------------------------------ # KPIS # ------------------------------------------------------------ diff --git a/addons/digest/tests/test_digest.py b/addons/digest/tests/test_digest.py index 193d0b6c95c..1c403433da4 100644 --- a/addons/digest/tests/test_digest.py +++ b/addons/digest/tests/test_digest.py @@ -160,6 +160,7 @@ class TestUnsubscribe(HttpCaseWithUserDemo): }) self.test_digest._action_subscribe_users(self.user_demo) self.base_url = self.test_digest.get_base_url() + self.user_demo_unsubscribe_token = self.test_digest._get_unsubscribe_token(self.user_demo.id) @users('demo') def test_unsubscribe_classic(self): @@ -182,13 +183,21 @@ class TestUnsubscribe(HttpCaseWithUserDemo): self.assertEqual(response.status_code, 200) self.assertNotIn(self.user_demo, self.test_digest.user_ids) + def test_unsubscribe_token(self): + self.assertIn(self.user_demo, self.test_digest.user_ids) + self.authenticate(None, None) + response = self._url_unsubscribe(token=self.user_demo_unsubscribe_token, user_id=self.user_demo.id) + self.assertEqual(response.status_code, 200) + self.test_digest.invalidate_cache() + self.assertNotIn(self.user_demo, self.test_digest.user_ids) + def test_unsubscribe_public(self): """ Check public users are redirected when trying to catch unsubscribe route. """ self.authenticate(None, None) response = self._url_unsubscribe() - self.assertIn('web/login?redirect', response.url) + self.assertEqual(response.status_code, 404) def _url_unsubscribe(self, token=None, user_id=None): url_params = {}