Skip to content

Commit

Permalink
[FIX] web_editor, website: prevent deletion of used attachment
Browse files Browse the repository at this point in the history
The deletion of used attachments from the media dialog is prevented
since [1]. It is possible that it worked at the time, but that it
stopped working in [2] when the routes for attachments have been
updated.
Also, it only checked for the presence of attachments in views,
ignoring other HTML fields.

This commit fixes the existing mechanism and also limits the search on
views to QWeb views, and adds the check across other HTML fields.
The fields inside ir.action models and mail messages are explicitly
blacklisted, similarly to what exists in 15.0.
This commit also detects non-image attachments which are obtained
through the `/web/content/...` route.
It also adapts the link within the warning so that the website page of
the blocking object is reached if there is one, otherwise it falls back
onto the backend page of the object.

In 15.0, the list of fields should be obtained from `website`'s
`_get_html_fields` instead of duplicating its mechanism.

In 16.0, apply the same principle to the menu dependencies which were
restored by [3] and [4].

Steps to reproduce:
- Drop a Text - Image block on a website page/a product page.
- Upload an attachment in that block.
- Save.
- Edit another page.
- In the media dialog, delete the uploaded attachment.

=> Attachment is deleted and page contains a missing image/document.

[1]: e78a3b1
[2]: 6baf611
[3]: 11db2f6
[4]: 6ac17b9

task-3223788
  • Loading branch information
bso-odoo committed Dec 8, 2023
1 parent c7cc703 commit 8cb3471
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 13 deletions.
49 changes: 49 additions & 0 deletions addons/web_editor/controllers/main.py
Expand Up @@ -217,6 +217,7 @@ def remove(self, ids, **kwargs):
Returns a dict mapping attachments which would not be removed (if any)
mapped to the views preventing their removal
"""
# deprecated for remove_with_new_check
self._clean_context()
Attachment = attachments_to_remove = request.env['ir.attachment']
Views = request.env['ir.ui.view']
Expand All @@ -242,6 +243,54 @@ def remove(self, ids, **kwargs):
attachments_to_remove.unlink()
return removal_blocked_by

@http.route('/web_editor/attachment/remove_with_new_check', type='json', auth='user', website=True)
def remove_with_new_check(self, ids, **kwargs):
""" Removes a web-based image attachment if it is used by no view (template)
Returns a dict mapping attachments which would not be removed (if any)
mapped to a dict about what prevents their removal containing:
- matches: list of records that reference the attachment
- skippedModels: list of names of models that contain too many records
- accessModels: list of names of models that contain records that reference the attachment
but cannot be accessed by the user
"""
# TODO In master: replace remove route
self._clean_context()
Attachment = attachments_to_remove = request.env['ir.attachment']

# views blocking removal of the attachment
removal_blocked_by = {}

for attachment in Attachment.browse(ids):
referrers = None if kwargs.get('force') else attachment._is_used_in_html_field()
if referrers:
matches = []
unique_url = set()
for items in referrers['matches']:
if 'name' not in items._fields:
continue
fields = ['name', 'website_url'] if 'website_url' in items._fields else ['name']
records = items.read(fields)
if 'website_url' not in items._fields:
# Fallback to backend url
for record in records:
record['website_url'] = '/web#model=%s&id=%s' % (items._name, record['id'])
for record in records:
if record['website_url'] in unique_url:
continue
unique_url.add(record['website_url'])
matches.append(record)
removal_blocked_by[attachment.id] = {
'matches': matches,
'skippedModels': [{'name': request.env[model_name]._description} for model_name in referrers['skipped_models']],
'accessModels': [{'name': request.env[model_name]._description} for model_name in referrers['access_models']],
}
else:
attachments_to_remove += attachment
if attachments_to_remove:
attachments_to_remove.unlink()
return removal_blocked_by

@http.route('/web_editor/get_image_info', type='json', auth='user', website=True)
def get_image_info(self, src=''):
"""This route is used to determine the original of an attachment so that
Expand Down
1 change: 1 addition & 0 deletions addons/web_editor/models/__init__.py
Expand Up @@ -6,6 +6,7 @@
from . import ir_ui_view
from . import ir_http
from . import ir_translation
from . import models

from . import assets

Expand Down
26 changes: 26 additions & 0 deletions addons/web_editor/models/ir_attachment.py
Expand Up @@ -4,6 +4,7 @@
from werkzeug.urls import url_quote

from odoo import api, models, fields, tools
from odoo.addons.web_editor.tools import find_in_html_field

SUPPORTED_IMAGE_MIMETYPES = ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml']
SUPPORTED_IMAGE_EXTENSIONS = ['.gif', '.jpe', '.jpeg', '.jpg', '.png', '.svg']
Expand Down Expand Up @@ -63,3 +64,28 @@ def _get_media_info(self):
"""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 _is_used_in_html_field(self):
"""Returns a model where this attachment is used, or a dict with model names or False."""
self.ensure_one()
# Keep significant part of attachment url
url = self.local_url
is_default_local_url = url.startswith('/web/image/%s?' % self.id)
if is_default_local_url:
url = ('/web/image/%s' if self.image_src else '/web/content/%s') % self.id

# in-document URLs are html-escaped, a straight search will not
# find them
url = tools.html_escape(url)
likes = [
'"%s"' % url,
"'%s'" % url,
]
if is_default_local_url:
likes.extend([
'"%s-' % url,
"'%s-" % url,
'"%s?' % url,
"'%s?" % url,
])
return find_in_html_field(self.env, likes)
16 changes: 16 additions & 0 deletions addons/web_editor/models/models.py
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import api, models


class BaseModel(models.AbstractModel):
_inherit = 'base'

@api.model
def _get_examined_html_fields(self):
""" Returns an array of (model name, field name, domain, requires full
scan boolean) indicating which HTML field values should be
examined when using find_in_html_field.
"""
return [('ir.ui.view', 'arch_db', [('type', '=', 'qweb')], True)]
60 changes: 47 additions & 13 deletions addons/web_editor/static/src/js/wysiwyg/widgets/media.js
Expand Up @@ -554,6 +554,22 @@ var FileWidget = SearchableMediaWidget.extend({
this.$urlSuccess.toggleClass('d-none', !isURL);
this.$urlError.toggleClass('d-none', emptyValue || isURL);
},
/**
* Removes the displayed deleted attachment's.
*
* @private
* @param {object} attachment
* @param {HTMLElement} thumbnailEl
*/
_removeDeletedAttachment(attachment, thumbnailEl) {
this.attachments = _.without(this.attachments, attachment);
this.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id);
if (!this.attachments.length) {
this._renderThumbnails(); //render the message and image if empty
} else {
thumbnailEl.closest('.o_existing_attachment_cell').remove();
}
},

//--------------------------------------------------------------------------
// Handlers
Expand Down Expand Up @@ -651,26 +667,44 @@ var FileWidget = SearchableMediaWidget.extend({
var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell');
var id = parseInt($a.data('id'), 10);
var attachment = _.findWhere(self.attachments, {id: id});
return self._rpc({
route: '/web_editor/attachment/remove',
return self._rpc({
route: '/web_editor/attachment/remove_with_new_check',
params: {
ids: [id],
},
}).then(function (prevented) {
if (_.isEmpty(prevented)) {
self.attachments = _.without(self.attachments, attachment);
self.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id);
if (!self.attachments.length) {
self._renderThumbnails(); //render the message and image if empty
} else {
$a.closest('.o_existing_attachment_cell').remove();
}
self._removeDeletedAttachment(attachment, $a[0]);
return;
}
self.$errorText.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', {
views: prevented[id],
widget: self,
}));
const buttons = [{
text: _t("Delete Anyway"),
classes: 'btn-primary',
close: true,
click: () => {
return self._rpc({
route: '/web_editor/attachment/remove_with_new_check',
params: {
ids: [id],
force: true,
},
}).then(() => {
self._removeDeletedAttachment(attachment, $a[0]);
});
},
}, {
text: _t("Cancel"),
close: true,
}];
Dialog.alert(this, undefined, {
title: prevented[id].matches.length || prevented[id].sudoModels.length ?
_t("Attachment is still used") : _t("Delete Attachment"),
$content: QWeb.render('wysiwyg.widgets.image.existing.multierror', {
cause: prevented[id],
widget: self,
}),
buttons: buttons,
});
});
}
});
Expand Down
33 changes: 33 additions & 0 deletions addons/web_editor/static/src/xml/wysiwyg.xml
Expand Up @@ -257,6 +257,7 @@
</t>

<t t-name="wysiwyg.widgets.image.existing.error">
<!-- Deprecated for wysiwyg.widgets.image.existing.multierror -->
<div class="form-text">
<p>The image could not be deleted because it is used in the
following pages or views:</p>
Expand Down Expand Up @@ -301,6 +302,38 @@
</div>
</t>

<t t-name="wysiwyg.widgets.image.existing.multierror">
<div class="form-text">
<p>The image could not be deleted.</p>
<t t-if="cause.matches.length">
<p>It is used in the following pages:</p>
<ul t-as="page" t-foreach="cause.matches">
<li>
<a t-att-href="page.website_url">
<t t-esc="page.name"/>
</a>
</li>
</ul>
</t>
<t t-if="cause.accessModels.length">
<p>It is used in records to which you do not have access in the following models:</p>
<ul t-as="model" t-foreach="cause.accessModels">
<li>
<t t-esc="model.name"/>
</li>
</ul>
</t>
<t t-if="cause.skippedModels.length">
<p>It might still be referenced in one of the following models that has many records:</p>
<ul t-as="model" t-foreach="cause.skippedModels">
<li>
<t t-esc="model.name"/>
</li>
</ul>
</t>
</div>
</t>

<!-- Icon choosing part of the Media Dialog -->
<t t-name="wysiwyg.widgets.font-icons">
<form action="#">
Expand Down
44 changes: 44 additions & 0 deletions addons/web_editor/tools.py
@@ -0,0 +1,44 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

SCAN_MAX_COUNT = 1000

def find_in_html_field(env, html_escaped_likes):
""" Returns models where the likes appear inside HTML fields.
:param env: env
:param html_escaped_likes: array of string to include as values of
domain 'like'. Values must be HTML escaped.
:returns PNG image converted from given font
"""
all_matches = []
big_models = []
sudo_models = []
for model_name, field_name, domain, requires_full_scan in env['base']._get_examined_html_fields():
if not requires_full_scan:
if model_name in big_models:
continue
if env[model_name].sudo().with_context(active_test=False).search_count([]) > SCAN_MAX_COUNT:
big_models.append(model_name)
continue
likes = [(field_name, 'like', like) for like in html_escaped_likes]
domain.extend([
*(['|'] * (len(likes) - 1)),
*likes,
])
matches = env[model_name]
if matches.check_access_rights('read', raise_exception=False):
matches = matches.with_context(active_test=False).search(domain)
all_matches.append(matches)
continue
if model_name in sudo_models:
continue
sudo_matches = env[model_name].sudo().with_context(active_test=False).search(domain, limit=1)
if sudo_matches:
sudo_models.append(model_name)
return {
'matches': all_matches,
'skipped_models': big_models,
'access_models': sudo_models,
} if matches or big_models or sudo_models else None
7 changes: 7 additions & 0 deletions addons/website/models/ir_model.py
Expand Up @@ -38,3 +38,10 @@ def get_website_meta(self):
# dummy version of 'get_website_meta' above; this is a graceful fallback
# for models that don't inherit from 'website.seo.metadata'
return {}

def _get_examined_html_fields(self):
html_fields = super()._get_examined_html_fields()
table_to_model = {self.env[model_name]._table: model_name for model_name in self.env if self.env[model_name]._table}
for table, name in self.env['website']._get_html_fields():
html_fields.append((table_to_model.get(table), name, [], False))
return html_fields

0 comments on commit 8cb3471

Please sign in to comment.