From ab2adbdc7229c720271a0b4b534b56c286ec32ac Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Fri, 7 Oct 2022 12:09:14 +0000 Subject: [PATCH] [FIX] web_editor, *: allow internal user to upload unsplash image * web_unsplash, website_forum With [1], the access errors when a portal user were using the media dialog got fixed. It also came with the unsplash capability for portal user in the media dialog. Still, there was a remaining problematic point: an internal user can't upload an unsplash image in the backend through the media dialog. It doesn't really make sense for an internal user to have less right than the portal user. This commit corrects that part. Note that only unsplash images were problematic, not regular uploaded image as the difference was that unsplash images attachment are saved with an `url` property, which was triggering due to [2]. Finally, it was chosen to make that "bypass" more robust and opt in, so forum and unsplash are allowing their use cases to go through, it's not done in a generic way anymore. Also fixing a small issue about the res_id not being sent when editing a forum post, because it was assuming that the URL would end with the ID which is not the case when you edit your answer. [1]: https://github.com/odoo/odoo/commit/e10493711879c7f0cc8832db3f1936c622ea605c [2]: https://github.com/odoo/odoo/commit/bfffe39f1376a56226572295b945a2cc73ba50ce task-3007844 closes odoo/odoo#103138 Signed-off-by: Quentin Smetz (qsm) --- addons/web_editor/controllers/main.py | 20 ++++++++-------- addons/web_editor/models/ir_attachment.py | 9 ++++++++ addons/web_unsplash/models/__init__.py | 3 ++- addons/web_unsplash/models/ir_attachment.py | 18 +++++++++++++++ addons/website_forum/models/__init__.py | 1 + addons/website_forum/models/forum.py | 4 ++++ addons/website_forum/models/ir_attachment.py | 23 +++++++++++++++++++ .../static/src/js/website_forum.js | 6 ++++- addons/website_forum/tests/test_controller.py | 13 +++++------ 9 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 addons/web_unsplash/models/ir_attachment.py create mode 100644 addons/website_forum/models/ir_attachment.py diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 7f1769050a3..b4e03d97de5 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -292,6 +292,8 @@ class Web_Editor(http.Controller): def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view'): """Create and return a new attachment.""" + IrAttachment = request.env['ir.attachment'] + if name.lower().endswith('.bmp'): # Avoid mismatch between content type and mimetype, see commit msg name = name[:-4] @@ -323,15 +325,13 @@ class Web_Editor(http.Controller): else: raise UserError(_("You need to specify either data or url to create an attachment.")) - # If the user is a portal and has access rights on the res_model (and - # on the res_id if it exists), we can create the attachment. Otherwise, - # one of the check will raise an error and the attachment will not be - # created - if request.env.user.has_group('base.group_portal'): - request.env[res_model].check_access_rights('write') - if res_id: - request.env[res_model].browse(res_id).check_access_rule('write') - attachment = request.env['ir.attachment'].sudo().create(attachment_data) + # Despite the user having no right to create an attachment, he can still + # create an image attachment through some flows + if ( + not request.env.is_admin() + and IrAttachment._can_bypass_rights_on_media_dialog(**attachment_data) + ): + attachment = IrAttachment.sudo().create(attachment_data) # When portal users upload an attachment with the wysiwyg widget, # the access token is needed to use the image in the editor. If # the attachment is not public, the user won't be able to generate @@ -339,7 +339,7 @@ class Web_Editor(http.Controller): if not attachment_data['public']: attachment.sudo().generate_access_token() else: - attachment = request.env['ir.attachment'].create(attachment_data) + attachment = IrAttachment.create(attachment_data) return attachment diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py index 6eede12910c..abc192f0978 100644 --- a/addons/web_editor/models/ir_attachment.py +++ b/addons/web_editor/models/ir_attachment.py @@ -63,3 +63,12 @@ class IrAttachment(models.Model): """Return a dict with the values that we need on the media dialog.""" self.ensure_one() return self._read_format(['id', 'name', 'description', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'])[0] + + def _can_bypass_rights_on_media_dialog(self, **attachment_data): + """ This method is meant to be overridden, for instance to allow to + create image attachment despite the user not allowed to create + attachment, eg: + - Portal user uploading an image on the forum (bypass acl) + - Non admin user uploading an unsplash image (bypass binary/url check) + """ + return False diff --git a/addons/web_unsplash/models/__init__.py b/addons/web_unsplash/models/__init__.py index 06c3c1ac4cd..d69af5fe79a 100644 --- a/addons/web_unsplash/models/__init__.py +++ b/addons/web_unsplash/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import ir_attachment +from . import ir_qweb_fields from . import res_config_settings from . import res_users -from . import ir_qweb_fields diff --git a/addons/web_unsplash/models/ir_attachment.py b/addons/web_unsplash/models/ir_attachment.py new file mode 100644 index 00000000000..373d0d933a9 --- /dev/null +++ b/addons/web_unsplash/models/ir_attachment.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Attachment(models.Model): + + _inherit = "ir.attachment" + + def _can_bypass_rights_on_media_dialog(self, **attachment_data): + # We need to allow and sudo the case of an "url + file" attachment, + # which is by default forbidden for non admin. + # See `_check_serving_attachments` + forbidden = 'url' in attachment_data and attachment_data.get('type', 'binary') == 'binary' + if forbidden and attachment_data['url'].startswith('/unsplash/'): + return True + return super()._can_bypass_rights_on_media_dialog(**attachment_data) diff --git a/addons/website_forum/models/__init__.py b/addons/website_forum/models/__init__.py index 6400bd0bdb4..cb7e2438978 100644 --- a/addons/website_forum/models/__init__.py +++ b/addons/website_forum/models/__init__.py @@ -2,5 +2,6 @@ from . import forum from . import gamification +from . import ir_attachment from . import res_users from . import website diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py index e3fab92f903..b071a43f4d6 100644 --- a/addons/website_forum/models/forum.py +++ b/addons/website_forum/models/forum.py @@ -374,6 +374,9 @@ class Post(models.Model): can_post = fields.Boolean('Can Automatically be Validated', compute='_get_post_karma_rights', compute_sudo=False) can_flag = fields.Boolean('Can Flag', compute='_get_post_karma_rights', compute_sudo=False) can_moderate = fields.Boolean('Can Moderate', compute='_get_post_karma_rights', compute_sudo=False) + can_use_full_editor = fields.Boolean( + compute='_get_post_karma_rights', compute_sudo=False, + help="Editor Features: image and links") def _search_can_view(self, operator, value): if operator not in ('=', '!=', '<>'): @@ -495,6 +498,7 @@ class Post(models.Model): post.can_post = is_admin or user.karma >= post.forum_id.karma_post post.can_flag = is_admin or user.karma >= post.forum_id.karma_flag post.can_moderate = is_admin or user.karma >= post.forum_id.karma_moderate + post.can_use_full_editor = is_admin or user.karma >= post.forum_id.karma_editor def _update_content(self, content, forum_id): forum = self.env['forum.forum'].browse(forum_id) diff --git a/addons/website_forum/models/ir_attachment.py b/addons/website_forum/models/ir_attachment.py new file mode 100644 index 00000000000..e518d9ef740 --- /dev/null +++ b/addons/website_forum/models/ir_attachment.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Attachment(models.Model): + + _inherit = "ir.attachment" + + def _can_bypass_rights_on_media_dialog(self, **attachment_data): + # Bypass the attachment create ACL and let the user create the image + # attachment if he has write right on the model (the image attachment + # will be bound to this model's record). + res_model = attachment_data['res_model'] + res_id = attachment_data.get('res_id') + if ( + res_model == 'forum.post' and res_id + and self.env['forum.post'].browse(res_id).can_use_full_editor + ): + return True + + return super()._can_bypass_rights_on_media_dialog(**attachment_data) diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index 5e06d5a3aca..5dd5bcc3612 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -129,7 +129,11 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ recordInfo: { context: self._getContext(), res_model: 'forum.post', - res_id: +window.location.pathname.split('-').pop(), + // Id is retrieved from URL, which is either: + // - /forum/name-1/post/something-5 + // - /forum/name-1/post/something-5/edit + // TODO: Make this more robust. + res_id: +window.location.pathname.split('-').slice(-1)[0].split('/')[0], }, resizable: true, userGeneratedContent: true, diff --git a/addons/website_forum/tests/test_controller.py b/addons/website_forum/tests/test_controller.py index 6f2fc6c794c..9dfe82ed84d 100644 --- a/addons/website_forum/tests/test_controller.py +++ b/addons/website_forum/tests/test_controller.py @@ -10,10 +10,8 @@ class TestController(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() - portal_user = new_test_user(cls.env, login='portal_user', groups='base.group_portal') - cls.portal = portal_user.login - admin_user = new_test_user(cls.env, login='admin_user', groups='base.group_user,base.group_system') - cls.admin = admin_user.login + cls.portal_user = new_test_user(cls.env, login='portal_user', groups='base.group_portal') + cls.admin_user = new_test_user(cls.env, login='admin_user', groups='base.group_user,base.group_system') cls.headers = {"Content-Type": "application/json"} cls.pixel = 'R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=' @@ -29,15 +27,16 @@ class TestController(HttpCase): } def test_01_portal_attachment(self): - self.authenticate(self.portal, self.portal) - payload = self._build_payload({"name": "pixel", "data": self.pixel, "is_image": True, "res_model": "forum.post"}) + self.authenticate(self.portal_user.login, self.portal_user.login) + payload = self._build_payload({'name': 'pixel', 'data': self.pixel, 'is_image': True, 'res_model': 'forum.post', 'res_id': 1}) + self.portal_user.karma = 30 response = self.url_open('/web_editor/attachment/add_data', data=json.dumps(payload), headers=self.headers, timeout=60000) self.assertEqual(200, response.status_code) attachment = self.env['ir.attachment'].search([('name', '=', 'pixel')]) self.assertTrue(attachment) def test_02_admin_attachment(self): - self.authenticate(self.admin, self.admin) + self.authenticate(self.admin_user.login, self.admin_user.login) payload = self._build_payload({"name": "pixel", "data": self.pixel, "is_image": True, "res_model": "forum.post"}) response = self.url_open('/web_editor/attachment/add_data', data=json.dumps(payload), headers=self.headers) self.assertEqual(200, response.status_code)