diff --git a/addons/account/tests/test_portal_attachment.py b/addons/account/tests/test_portal_attachment.py
index e6f559052ad..c6bc00733a8 100644
--- a/addons/account/tests/test_portal_attachment.py
+++ b/addons/account/tests/test_portal_attachment.py
@@ -89,11 +89,11 @@ class TestPortalAttachment(AccountTestInvoicingHttpCommon):
self.assertEqual(create_res['mimetype'], 'text/plain')
res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
- self.assertEqual(res_binary.headers['Content-Type'], 'text/plain')
+ self.assertEqual(res_binary.headers['Content-Type'], 'text/plain; charset=utf-8')
self.assertEqual(res_binary.content, b' ')
res_image = self.url_open('/web/image/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
- self.assertEqual(res_image.headers['Content-Type'], 'text/plain')
+ self.assertEqual(res_image.headers['Content-Type'], 'text/plain; charset=utf-8')
self.assertEqual(res_image.content, b' ')
# Test attachment can't be removed without valid token
diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py
index d8053e808ee..6812e0f7f80 100644
--- a/addons/im_livechat/controllers/main.py
+++ b/addons/im_livechat/controllers/main.py
@@ -20,10 +20,9 @@ class LivechatController(http.Controller):
mock_attachment = getattr(asset, ext)()
if isinstance(mock_attachment, list): # suppose that CSS asset will not required to be split in pages
mock_attachment = mock_attachment[0]
- # can't use /web/content directly because we don't have attachment ids (attachments must be created)
- _, headers, content = request.env['ir.http'].binary_content(id=mock_attachment.id, unique=asset.checksum)
- headers.append(('Content-Length', len(content)))
- return request.make_response(content, headers)
+
+ stream = request.env['ir.binary']._get_stream_from(mock_attachment)
+ return stream.get_response()
@http.route('/im_livechat/load_templates', type='json', auth='none', cors="*")
def load_templates(self, **kwargs):
@@ -100,21 +99,11 @@ class LivechatController(http.Controller):
('operator_partner_id', 'in', operator.ids)
]))
- status = 200
- headers = []
- # custom placeholer (a smiley face instead of the infamous camera)
- image_placeholder = 'mail/static/src/img/smiley/avatar.jpg'
-
- if is_livechat_member:
- status, headers, image_base64 = request.env['ir.http'].sudo().binary_content(
- model='res.partner', id=operator_id, field='avatar_128', default_mimetype='image/png')
-
- if status in [301, 304]:
- image_base64 = request.env['ir.http']._placeholder(image_placeholder)
- else:
- image_base64 = request.env['ir.http']._placeholder(image_placeholder)
-
- return request.env['ir.http']._content_image_get_response(status, headers, image_base64)
+ return request.env['ir.binary']._get_image_stream_from(
+ operator if is_livechat_member else None,
+ field_name='avatar_128',
+ placeholder='mail/static/src/img/smiley/avatar.jpg',
+ ).get_response()
@http.route('/im_livechat/get_session', type="json", auth='public', cors="*")
def get_session(self, channel_id, anonymous_name, previous_operator_id=None, chatbot_script_id=None, **kwargs):
diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py
index 6f77c30668f..7e63fa7da9e 100644
--- a/addons/mail/controllers/discuss.py
+++ b/addons/mail/controllers/discuss.py
@@ -136,29 +136,36 @@ class DiscussController(http.Controller):
@http.route('/mail/channel//partner//avatar_128', methods=['GET'], type='http', auth='public')
def mail_channel_partner_avatar_128(self, channel_id, partner_id, **kwargs):
channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request(request=request, channel_id=channel_id)
- if not channel_partner_sudo or not channel_partner_sudo.env['mail.channel.partner'].search([('channel_id', '=', channel_id), ('partner_id', '=', partner_id)], limit=1):
- if request.env.user.share:
- placeholder = channel_partner_sudo.env['res.partner'].browse(partner_id).exists()._avatar_get_placeholder()
- return channel_partner_sudo.env['ir.http']._placeholder_image_get_response(placeholder)
- return channel_partner_sudo.sudo(False).env['ir.http']._content_image(model='res.partner', res_id=partner_id, field='avatar_128')
- return channel_partner_sudo.env['ir.http']._content_image(model='res.partner', res_id=partner_id, field='avatar_128')
+ partner_sudo = channel_partner_sudo.env['res.partner'].browse(partner_id).exists()
+ placeholder = partner_sudo._avatar_get_placeholder_path()
+ if channel_partner_sudo and channel_partner_sudo.env['mail.channel.partner'].search([('channel_id', '=', channel_id), ('partner_id', '=', partner_id)], limit=1):
+ return request.env['ir.binary']._get_image_stream_from(partner_sudo, field_name='avatar_128', placeholder=placeholder).get_response()
+ if request.env.user.share:
+ return request.env['ir.binary']._get_placeholder_stream(placeholder)
+ return request.env['ir.binary']._get_image_stream_from(partner_sudo.sudo(False), field_name='avatar_128', placeholder=placeholder).get_response()
@http.route('/mail/channel//guest//avatar_128', methods=['GET'], type='http', auth='public')
def mail_channel_guest_avatar_128(self, channel_id, guest_id, **kwargs):
channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request(request=request, channel_id=channel_id)
- if not channel_partner_sudo or not channel_partner_sudo.env['mail.channel.partner'].search([('channel_id', '=', channel_id), ('guest_id', '=', guest_id)], limit=1):
- if request.env.user.share:
- placeholder = channel_partner_sudo.env['mail.guest'].browse(guest_id).exists()._avatar_get_placeholder()
- return channel_partner_sudo.env['ir.http']._placeholder_image_get_response(placeholder)
- return channel_partner_sudo.sudo(False).env['ir.http']._content_image(model='mail.guest', res_id=guest_id, field='avatar_128')
- return channel_partner_sudo.env['ir.http']._content_image(model='mail.guest', res_id=guest_id, field='avatar_128')
+ guest_sudo = channel_partner_sudo.env['mail.guest'].browse(guest_id).exists()
+ placeholder = guest_sudo._avatar_get_placeholder_path()
+ if channel_partner_sudo and channel_partner_sudo.env['mail.channel.partner'].search([('channel_id', '=', channel_id), ('guest_id', '=', guest_id)], limit=1):
+ return request.env['ir.binary']._get_image_stream_from(guest_sudo, field_name='avatar_128', placeholder=placeholder).get_response()
+ if request.env.user.share:
+ return request.env['ir.binary']._get_placeholder_stream(placeholder)
+ return request.env['ir.binary']._get_image_stream_from(guest_sudo.sudo(False), field_name='avatar_128', placeholder=placeholder).get_response()
@http.route('/mail/channel//attachment/', methods=['GET'], type='http', auth='public')
def mail_channel_attachment(self, channel_id, attachment_id, download=None, **kwargs):
channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
- if not channel_partner_sudo.env['ir.attachment'].search([('id', '=', int(attachment_id)), ('res_id', '=', int(channel_id)), ('res_model', '=', 'mail.channel')], limit=1):
+ attachment_sudo = channel_partner_sudo.env['ir.attachment'].search([
+ ('id', '=', int(attachment_id)),
+ ('res_id', '=', int(channel_id)),
+ ('res_model', '=', 'mail.channel')
+ ], limit=1)
+ if not attachment_sudo:
raise NotFound()
- return channel_partner_sudo.env['ir.http']._get_content_common(res_id=int(attachment_id), download=download)
+ return request.env['ir.binary']._get_stream_from(attachment_sudo).get_response(as_attachment=download)
@http.route([
'/mail/channel//image/',
@@ -166,9 +173,18 @@ class DiscussController(http.Controller):
], methods=['GET'], type='http', auth='public')
def fetch_image(self, channel_id, attachment_id, width=0, height=0, **kwargs):
channel_partner_sudo = request.env['mail.channel.partner']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
- if not channel_partner_sudo.env['ir.attachment'].search([('id', '=', int(attachment_id)), ('res_id', '=', int(channel_id)), ('res_model', '=', 'mail.channel')], limit=1):
+ attachment_sudo = channel_partner_sudo.env['ir.attachment'].search([
+ ('id', '=', int(attachment_id)),
+ ('res_id', '=', int(channel_id)),
+ ('res_model', '=', 'mail.channel'),
+ ], limit=1)
+
+ if not attachment_sudo:
raise NotFound()
- return channel_partner_sudo.env['ir.http']._content_image(res_id=int(attachment_id), height=int(height), width=int(width))
+
+ return request.env['ir.binary']._get_image_stream_from(
+ attachment_sudo, width=width, height=height
+ ).get_response(as_attachment=kwargs.get('download'))
# --------------------------------------------------------------------------
# Client Initialization
diff --git a/addons/point_of_sale/static/src/js/Chrome.js b/addons/point_of_sale/static/src/js/Chrome.js
index b8c946488fb..2b39a3dcf1d 100644
--- a/addons/point_of_sale/static/src/js/Chrome.js
+++ b/addons/point_of_sale/static/src/js/Chrome.js
@@ -417,12 +417,12 @@ odoo.define('point_of_sale.Chrome', function(require) {
_preloadImages() {
for (let product of this.env.pos.db.get_product_by_category(0)) {
const image = new Image();
- image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
+ image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
}
for (let category of Object.values(this.env.pos.db.category_by_id)) {
if (category.id == 0) continue;
const image = new Image();
- image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`;
+ image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&unique=${category.write_date}`;
}
const staticImages = ['backspace.png', 'bc-arrow-big.png'];
for (let imageName of staticImages) {
diff --git a/addons/point_of_sale/static/src/js/Screens/PartnerListScreen/PartnerDetailsEdit.js b/addons/point_of_sale/static/src/js/Screens/PartnerListScreen/PartnerDetailsEdit.js
index 87478dabc6c..c712f07b48b 100644
--- a/addons/point_of_sale/static/src/js/Screens/PartnerListScreen/PartnerDetailsEdit.js
+++ b/addons/point_of_sale/static/src/js/Screens/PartnerListScreen/PartnerDetailsEdit.js
@@ -33,7 +33,7 @@ odoo.define('point_of_sale.PartnerDetailsEdit', function(require) {
if (this.changes.image_1920) {
return this.changes.image_1920;
} else if (partner.id) {
- return `/web/image?model=res.partner&id=${partner.id}&field=avatar_128&write_date=${partner.write_date}&unique=1`;
+ return `/web/image?model=res.partner&id=${partner.id}&field=avatar_128&unique=${partner.write_date}`;
} else {
return false;
}
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js
index 05914becdb0..9aa6914e087 100644
--- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js
@@ -7,7 +7,7 @@ odoo.define('point_of_sale.CategoryButton', function(require) {
class CategoryButton extends PosComponent {
get imageUrl() {
const category = this.props.category
- return `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`;
+ return `/web/image?model=pos.category&field=image_128&id=${category.id}&unique=${category.write_date}`;
}
}
CategoryButton.template = 'CategoryButton';
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
index d9ce3b9e4c2..978926b825e 100644
--- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
@@ -18,7 +18,7 @@ odoo.define('point_of_sale.ProductItem', function(require) {
}
get imageUrl() {
const product = this.props.product;
- return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
+ return `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
}
get pricelist() {
const current_order = this.env.pos.get_order();
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 7f56650e412..1fe7ad90e17 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -581,7 +581,7 @@ class PosGlobalState extends PosModel {
if (order) {
order.get_orderlines().forEach(function (orderline) {
var product = orderline.product;
- var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
+ var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
// only download and convert image if we haven't done it before
if (!(product.id in PRODUCT_ID_TO_IMAGE_CACHE)) {
diff --git a/addons/purchase/controllers/portal.py b/addons/purchase/controllers/portal.py
index 578532ddaab..9726f729f31 100644
--- a/addons/purchase/controllers/portal.py
+++ b/addons/purchase/controllers/portal.py
@@ -93,7 +93,7 @@ class CustomerPortal(portal.CustomerPortal):
#
def resize_to_48(source):
if not source:
- source = request.env['ir.http']._placeholder()
+ source = request.env['ir.binary']._placeholder()
else:
source = base64.b64decode(source)
return base64.b64encode(image_process(source, size=(48, 48)))
diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py
index fdb6cdc625f..908b6d29a3e 100644
--- a/addons/survey/controllers/main.py
+++ b/addons/survey/controllers/main.py
@@ -401,7 +401,9 @@ class Survey(http.Controller):
type='http', auth="public", website=True, sitemap=False)
def survey_get_background(self, survey_token):
survey_sudo, dummy = self._fetch_from_access_token(survey_token, False)
- return self._get_background_image(survey_sudo._name, survey_sudo.id)
+ return request.env['ir.binary']._get_image_stream_from(
+ survey_sudo, 'background_image'
+ ).get_response()
@http.route('/survey///get_background_image',
type='http', auth="public", website=True, sitemap=False)
@@ -413,13 +415,9 @@ class Survey(http.Controller):
# trying to access a question that is not in this survey
raise werkzeug.exceptions.Forbidden()
- return self._get_background_image(section._name, section.id)
-
- def _get_background_image(self, model_name, res_id):
- status, headers, image_base64 = request.env['ir.http'].sudo().binary_content(
- model=model_name, id=res_id, field='background_image',
- default_mimetype='image/png')
- return request.env['ir.http']._content_image_get_response(status, headers, image_base64)
+ return request.env['ir.binary']._get_image_stream_from(
+ section, 'background_image'
+ ).get_response()
@http.route('/survey/get_question_image////', type='http', auth="public", website=True, sitemap=False)
def survey_get_question_image(self, survey_token, answer_token, question_id, suggested_answer_id):
@@ -429,15 +427,20 @@ class Survey(http.Controller):
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
- if not survey_sudo.question_ids.filtered(lambda q: q.id == question_id)\
- .suggested_answer_ids.filtered(lambda a: a.id == suggested_answer_id):
+ suggested_answer = False
+ if int(question_id) in survey_sudo.question_ids.ids:
+ suggested_answer = request.env['survey.question.answer'].sudo().search([
+ ('id', '=', int(suggested_answer_id)),
+ ('question_id', '=', int(question_id)),
+ ('question_id.survey_id', '=', survey_sudo.id),
+ ])
+
+ if not suggested_answer:
return werkzeug.exceptions.NotFound()
- status, headers, image_base64 = request.env['ir.http'].sudo().binary_content(
- model='survey.question.answer', id=suggested_answer_id, field='value_image',
- default_mimetype='image/png')
-
- return request.env['ir.http']._content_image_get_response(status, headers, image_base64)
+ return request.env['ir.binary']._get_image_stream_from(
+ suggested_answer, 'value_image'
+ ).get_response()
# ----------------------------------------------------------------
# JSON ROUTES to begin / continue survey (ajax navigation) + Tools
diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py
index 7441e83839a..1454fbc8a9a 100644
--- a/addons/web/__manifest__.py
+++ b/addons/web/__manifest__.py
@@ -20,6 +20,7 @@ This module provides the core of the Odoo Web Client.
'views/base_document_layout_views.xml',
'views/speedscope_template.xml',
'views/lazy_assets.xml',
+ 'data/ir_attachment.xml',
'data/report_layout.xml',
],
'assets': {
diff --git a/addons/web/controllers/binary.py b/addons/web/controllers/binary.py
index 3931c296388..b547d55110f 100644
--- a/addons/web/controllers/binary.py
+++ b/addons/web/controllers/binary.py
@@ -8,20 +8,41 @@ import logging
import os
import unicodedata
+try:
+ from werkzeug.utils import send_file
+except ImportError:
+ from odoo.tools._vendor.send_file import send_file
+
import odoo
import odoo.modules.registry
-from odoo import http
-from odoo.exceptions import AccessError
+from odoo import http, _
+from odoo.exceptions import AccessError, UserError
from odoo.http import request
from odoo.modules import get_resource_path
-from odoo.tools import pycompat
+from odoo.tools import file_open, file_path, replace_exceptions
from odoo.tools.mimetypes import guess_mimetype
-from odoo.tools.misc import file_open, file_path
-from odoo.tools.translate import _
+from odoo.tools.image import image_guess_size_from_field_name
_logger = logging.getLogger(__name__)
+BAD_X_SENDFILE_ERROR = """\
+Odoo is running with --x-sendfile but is receiving /web/filestore requests.
+
+With --x-sendfile enabled, NGINX should be serving the
+/web/filestore route, however Odoo is receiving the
+request.
+
+This usually indicates that NGINX is badly configured,
+please make sure the /web/filestore location block exists
+in your configuration file and that it is similar to:
+
+ location /web/filestore {{
+ internal;
+ alias {data_dir}/filestore;
+ }}
+"""
+
def clean(name):
return name.replace('\x3c', '')
@@ -29,6 +50,15 @@ def clean(name):
class Binary(http.Controller):
+ @http.route('/web/filestore/', type='http', auth='none')
+ def content_filestore(self, _path):
+ if odoo.tools.config['x_sendfile']:
+ # pylint: disable=logging-format-interpolation
+ _logger.error(BAD_X_SENDFILE_ERROR.format(
+ data_dir=odoo.tools.config['data_dir']
+ ))
+ raise http.request.not_found()
+
@http.route(['/web/content',
'/web/content/',
'/web/content//',
@@ -36,25 +66,45 @@ class Binary(http.Controller):
'/web/content//',
'/web/content///',
'/web/content////'], type='http', auth="public")
- def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas',
- filename=None, filename_field='name', unique=None, mimetype=None,
- download=None, data=None, token=None, access_token=None, **kw):
+ # pylint: disable=redefined-builtin,invalid-name
+ def content_common(self, xmlid=None, model='ir.attachment', id=None, field='raw',
+ filename=None, filename_field='name', mimetype=None, unique=False,
+ download=False, access_token=None, nocache=False):
+ with replace_exceptions(UserError, by=request.not_found()):
+ record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token)
+ stream = request.env['ir.binary']._get_stream_from(record, field, filename, filename_field, mimetype)
+ send_file_kwargs = {'as_attachment': download}
+ if unique:
+ send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
+ if nocache:
+ send_file_kwargs['max_age'] = None
- return request.env['ir.http']._get_content_common(xmlid=xmlid, model=model, res_id=id, field=field, unique=unique, filename=filename,
- filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token, token=token)
+ return stream.get_response(**send_file_kwargs)
@http.route(['/web/assets/debug/',
'/web/assets/debug//',
'/web/assets//',
'/web/assets/-/',
'/web/assets/-//'], type='http', auth="public")
- def content_assets(self, id=None, filename=None, unique=None, extra=None, **kw):
- id = id or request.env['ir.attachment'].sudo().search_read(
- [('url', '=like', f'/web/assets/%/{extra}/{filename}' if extra else f'/web/assets/%/{filename}')],
- fields=['id'], limit=1)[0]['id']
+ # pylint: disable=redefined-builtin,invalid-name
+ def content_assets(self, id=None, filename=None, unique=False, extra=None, nocache=False):
+ if not id:
+ domain = [('url', '=like', '/web/assets/%/' + (f'{extra}/{filename}' if extra else filename))]
+ attachments = request.env['ir.attachment'].sudo().search_read(domain, fields=['id'], limit=1)
+ if not attachments:
+ raise request.not_found()
+ id = attachments[0]['id']
+ with replace_exceptions(UserError, by=request.not_found()):
+ record = request.env['ir.binary']._find_record(res_id=int(id))
+ stream = request.env['ir.binary']._get_stream_from(record, 'raw', filename)
- return request.env['ir.http']._get_content_common(xmlid=None, model='ir.attachment', res_id=id, field='datas', unique=unique, filename=filename,
- filename_field='name', download=None, mimetype=None, access_token=None, token=None)
+ send_file_kwargs = {'as_attachment': False}
+ if unique:
+ send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
+ if nocache:
+ send_file_kwargs['max_age'] = None
+
+ return stream.get_response(as_attachment=False)
@http.route(['/web/image',
'/web/image/',
@@ -73,39 +123,35 @@ class Binary(http.Controller):
'/web/image/-/',
'/web/image/-/x',
'/web/image/-/x/'], type='http', auth="public")
+ # pylint: disable=redefined-builtin,invalid-name
def content_image(self, xmlid=None, model='ir.attachment', id=None, field='raw',
- filename_field='name', unique=None, filename=None, mimetype=None,
- download=None, width=0, height=0, crop=False, access_token=None,
- **kwargs):
- # other kwargs are ignored on purpose
- return request.env['ir.http']._content_image(xmlid=xmlid, model=model, res_id=id, field=field,
- filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype,
- download=download, width=width, height=height, crop=crop,
- quality=int(kwargs.get('quality', 0)), access_token=access_token)
-
- # backward compatibility
- @http.route(['/web/binary/image'], type='http', auth="public")
- def content_image_backward_compatibility(self, model, id, field, resize=None, **kw):
- width = None
- height = None
- if resize:
- width, height = resize.split(",")
- return request.env['ir.http']._content_image(model=model, res_id=id, field=field, width=width, height=height)
-
- @http.route('/web/binary/upload', type='http', auth="user")
- def upload(self, ufile, callback=None):
- # TODO: might be useful to have a configuration flag for max-length file uploads
- out = """"""
+ filename_field='name', filename=None, mimetype=None, unique=False,
+ download=False, width=0, height=0, crop=False, access_token=None,
+ nocache=False):
try:
- data = ufile.read()
- args = [len(data), ufile.filename,
- ufile.content_type, pycompat.to_text(base64.b64encode(data))]
- except Exception as e:
- args = [False, str(e)]
- return out % (json.dumps(clean(callback)), json.dumps(args)) if callback else json.dumps(args)
+ record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token)
+ stream = request.env['ir.binary']._get_image_stream_from(
+ record, field, filename=filename, filename_field=filename_field,
+ mimetype=mimetype, width=int(width), height=int(height), crop=crop,
+ )
+ except UserError as exc:
+ if download:
+ raise request.not_found() from exc
+ # Use the ratio of the requested field_name instead of "raw"
+ if (int(width), int(height)) == (0, 0):
+ width, height = image_guess_size_from_field_name(field)
+ record = request.env.ref('web.image_placeholder').sudo()
+ stream = request.env['ir.binary']._get_image_stream_from(
+ record, 'raw', width=width, height=height, crop=crop,
+ )
+
+ send_file_kwargs = {'as_attachment': download}
+ if unique:
+ send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
+ if nocache:
+ send_file_kwargs['max_age'] = None
+
+ return stream.get_response(**send_file_kwargs)
@http.route('/web/binary/upload_attachment', type='http', auth="user")
def upload_attachment(self, model, id, ufile, callback=None):
@@ -161,7 +207,7 @@ class Binary(http.Controller):
uid = (request.session.uid if dbname else None) or odoo.SUPERUSER_ID
if not dbname:
- response = http.send_file(placeholder(imgname + imgext))
+ response = http.Stream.from_path(placeholder(imgname + imgext)).get_response()
else:
try:
# create an empty registry
@@ -188,11 +234,11 @@ class Binary(http.Controller):
imgext = '.' + mimetype.split('/')[1]
if imgext == '.svg+xml':
imgext = '.svg'
- response = http.send_file(image_data, filename=imgname + imgext, mimetype=mimetype, mtime=row[1])
+ response = send_file(image_data, filename=imgname + imgext, mimetype=mimetype, mtime=row[1])
else:
- response = http.send_file(placeholder('nologo.png'))
+ response = http.Stream.from_path(placeholder('nologo.png')).get_response()
except Exception:
- response = http.send_file(placeholder(imgname + imgext))
+ response = http.Stream.from_path(placeholder(imgname + imgext)).get_response()
return response
diff --git a/addons/web/data/ir_attachment.xml b/addons/web/data/ir_attachment.xml
new file mode 100644
index 00000000000..33993487161
--- /dev/null
+++ b/addons/web/data/ir_attachment.xml
@@ -0,0 +1,10 @@
+
+
+
+ placeholder.png
+ url
+ /web/static/img/placeholder.png
+ True
+
+
+
diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py
index 8ead63ae2f3..d440997f6b3 100644
--- a/addons/web/models/ir_http.py
+++ b/addons/web/models/ir_http.py
@@ -170,70 +170,3 @@ class Http(models.AbstractModel):
Currency = request.env['res.currency']
currencies = Currency.search([]).read(['symbol', 'position', 'decimal_places'])
return {c['id']: {'symbol': c['symbol'], 'position': c['position'], 'digits': [69,c['decimal_places']]} for c in currencies}
-
- @api.model
- def _get_content_common(self, xmlid=None, model='ir.attachment', res_id=None, field='datas',
- unique=None, filename=None, filename_field='name', download=None, mimetype=None,
- access_token=None, token=None):
- status, headers, content = self.binary_content(
- xmlid=xmlid, model=model, id=res_id, field=field, unique=unique, filename=filename,
- filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token
- )
- if status != 200:
- return self._response_by_status(status, headers, content)
- else:
- headers.append(('Content-Length', len(content)))
- response = request.make_response(content, headers)
- return response
-
- @api.model
- def _content_image(self, xmlid=None, model='ir.attachment', res_id=None, field='raw',
- filename_field='name', unique=None, filename=None, mimetype=None, download=None,
- width=0, height=0, crop=False, quality=0, access_token=None, **kwargs):
- status, headers, image = self.binary_content(
- xmlid=xmlid, model=model, id=res_id, field=field, unique=unique, filename=filename,
- filename_field=filename_field, download=download, mimetype=mimetype,
- default_mimetype='image/png', access_token=access_token
- )
- return self._content_image_get_response(
- status, headers, image, model=model, field=field, download=download,
- width=width, height=height, crop=crop, quality=quality)
-
- @api.model
- def _content_image_get_response(self, status, headers, image, model='ir.attachment',
- field='raw', download=None, width=0, height=0, crop=False, quality=0):
- if status in [301, 304] or (status != 200 and download):
- return self._response_by_status(status, headers, image)
- if not image:
- placeholder_filename = False
- if model in self.env:
- placeholder_filename = self.env[model]._get_placeholder_filename(field)
- image = self._placeholder(image=placeholder_filename)
- # Since we set a placeholder for any missing image, the status must be 200. In case one
- # wants to configure a specific 404 page (e.g. though nginx), a 404 status will cause
- # troubles.
- status = 200
- if not (width or height):
- width, height = odoo.tools.image_guess_size_from_field_name(field)
- try:
- content = image_process(image, size=(int(width), int(height)), crop=crop, quality=int(quality))
- except Exception:
- return request.not_found()
- headers = http.set_safe_image_headers(headers, content)
- response = request.make_response(content, headers)
- response.status_code = status
- return response
-
- @api.model
- def _placeholder_image_get_response(self, content):
- headers = http.set_safe_image_headers([], content)
- response = request.make_response(content, headers)
- response.status_code = 200
- return response
-
- @api.model
- def _placeholder(self, image=False):
- if not image:
- image = 'web/static/img/placeholder.png'
- with file_open(image, 'rb', filter_ext=('.png', '.jpg')) as fd:
- return fd.read()
diff --git a/addons/web/tests/test_image.py b/addons/web/tests/test_image.py
index 73099bcef55..1b0618ef01c 100644
--- a/addons/web/tests/test_image.py
+++ b/addons/web/tests/test_image.py
@@ -18,26 +18,31 @@ class TestImage(HttpCase):
# CASE: resize placeholder, given size but original ratio is always kept
response = self.url_open('/web/image/0/200x150')
+ response.raise_for_status()
image = Image.open(io.BytesIO(response.content))
self.assertEqual(image.size, (150, 150))
# CASE: resize placeholder to 128
response = self.url_open('/web/image/fake/0/image_128')
+ response.raise_for_status()
image = Image.open(io.BytesIO(response.content))
self.assertEqual(image.size, (128, 128))
# CASE: resize placeholder to 256
response = self.url_open('/web/image/fake/0/image_256')
+ response.raise_for_status()
image = Image.open(io.BytesIO(response.content))
self.assertEqual(image.size, (256, 256))
# CASE: resize placeholder to 1024 (but placeholder image is too small)
response = self.url_open('/web/image/fake/0/image_1024')
+ response.raise_for_status()
image = Image.open(io.BytesIO(response.content))
self.assertEqual(image.size, (256, 256))
# CASE: no size found, use placeholder original size
response = self.url_open('/web/image/fake/0/image_no_size')
+ response.raise_for_status()
image = Image.open(io.BytesIO(response.content))
self.assertEqual(image.size, (256, 256))
@@ -51,12 +56,14 @@ class TestImage(HttpCase):
'mimetype': 'image/gif',
})
response = self.url_open('/web/image/%s' % attachment.id, timeout=None)
+ response.raise_for_status()
self.assertEqual(response.status_code, 200)
self.assertEqual(base64.b64encode(response.content), attachment.datas)
etag = response.headers.get('ETag')
response2 = self.url_open('/web/image/%s' % attachment.id, headers={"If-None-Match": etag})
+ response2.raise_for_status()
self.assertEqual(response2.status_code, 304)
self.assertEqual(len(response2.content), 0)
@@ -72,12 +79,15 @@ class TestImage(HttpCase):
# CASE: no filename given
res = self.url_open('/web/image/%s/0x0/?download=true' % att.id)
- self.assertEqual(res.headers['Content-Disposition'], content_disposition('testFilename.gif'))
+ res.raise_for_status()
+ self.assertEqual(res.headers['Content-Disposition'], 'attachment; filename=testFilename.gif')
# CASE: given filename without extension
res = self.url_open('/web/image/%s/0x0/custom?download=true' % att.id)
- self.assertEqual(res.headers['Content-Disposition'], content_disposition('custom.gif'))
+ res.raise_for_status()
+ self.assertEqual(res.headers['Content-Disposition'], 'attachment; filename=custom.gif')
# CASE: given filename and extention
res = self.url_open('/web/image/%s/0x0/custom.png?download=true' % att.id)
- self.assertEqual(res.headers['Content-Disposition'], content_disposition('custom.png'))
+ res.raise_for_status()
+ self.assertEqual(res.headers['Content-Disposition'], 'attachment; filename=custom.png')
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index fce4ab4a393..8f999f23c5e 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -1,11 +1,12 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import contextlib
import io
import json
import logging
import re
import time
import requests
+import werkzeug.exceptions
import werkzeug.urls
import werkzeug.wrappers
from PIL import Image, ImageFont, ImageDraw
@@ -16,7 +17,7 @@ from odoo.http import request
from odoo import http, tools, _, SUPERUSER_ID
from odoo.addons.http_routing.models.ir_http import slug, unslug
from odoo.addons.web_editor.tools import get_video_url_data
-from odoo.exceptions import UserError
+from odoo.exceptions import UserError, MissingError
from odoo.modules.module import get_resource_path
from odoo.tools import file_open
from odoo.tools.mimetypes import guess_mimetype
@@ -262,15 +263,17 @@ class Web_Editor(http.Controller):
it can be used as a base to modify it again (crop/optimization/filters).
"""
attachment = None
- id_match = re.search('^/web/image/([^/?]+)', src)
- if id_match:
- url_segment = id_match.group(1)
- number_match = re.match('^(\d+)', url_segment)
- if '.' in url_segment: # xml-id
- attachment = request.env['ir.http']._xmlid_to_obj(request.env, url_segment)
- elif number_match: # numeric id
- attachment = request.env['ir.attachment'].browse(int(number_match.group(1)))
- else:
+ if src.startswith('/web/image'):
+ with contextlib.suppress(werkzeug.exceptions.NotFound, MissingError):
+ _, args = request.env['ir.http']._match(src)
+ record = request.env['ir.binary']._find_record(
+ xmlid=args.get('xmlid'),
+ res_model=args.get('model'),
+ res_id=args.get('id'),
+ )
+ if record._name == 'ir.attachment':
+ attachment = record
+ if not attachment:
# Find attachment by url. There can be multiple matches because of default
# snippet images referencing the same image in /static/, so we limit to 1
attachment = request.env['ir.attachment'].search([
@@ -617,10 +620,18 @@ class Web_Editor(http.Controller):
@http.route(['/web_editor/image_shape///'], type='http', auth="public", website=True)
def image_shape(self, module, filename, img_key, **kwargs):
svg = self._get_shape_svg(module, 'image_shapes', filename)
- _, _, image = request.env['ir.http'].binary_content(
- xmlid=img_key, model='ir.attachment', field='datas', default_mimetype='image/png')
- if not image:
- image = request.env['ir.http']._placeholder()
+
+ record = request.env['ir.binary']._find_record(img_key)
+ stream = request.env['ir.binary']._get_image_stream_from(record)
+ if stream.type == 'url':
+ return stream.get_response()
+
+ if stream.type == 'path':
+ with file_open(stream.path, 'rb') as file:
+ image = file.read()
+ else:
+ image = stream.data
+
img = binary_to_image(image)
width, height = tuple(str(size) for size in img.size)
root = etree.fromstring(svg)
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index c28639011e5..fe35a4f3910 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -764,33 +764,7 @@ class Website(Home):
return request.redirect('/')
-# ------------------------------------------------------
-# Retrocompatibility routes
-# ------------------------------------------------------
class WebsiteBinary(http.Controller):
-
- @http.route([
- '/website/image',
- '/website/image/',
- '/website/image//x',
- '/website/image//',
- '/website/image///x',
- '/website/image///',
- '/website/image////x'
- ], type='http', auth="public", website=False, multilang=False)
- def content_image(self, id=None, max_width=0, max_height=0, **kw):
- if max_width:
- kw['width'] = max_width
- if max_height:
- kw['height'] = max_height
- if id:
- id, _, unique = id.partition('_')
- kw['id'] = int(id)
- if unique:
- kw['unique'] = unique
- kw['res_id'] = kw.pop('id', None)
- return request.env['ir.http']._content_image(**kw)
-
# if not icon provided in DOM, browser tries to access /favicon.ico, eg when opening an order pdf
@http.route(['/favicon.ico'], type='http', auth='public', website=True, multilang=False, sitemap=False)
def favicon(self, **kw):
diff --git a/addons/website/models/__init__.py b/addons/website/models/__init__.py
index f6bac4c2848..9423d047805 100644
--- a/addons/website/models/__init__.py
+++ b/addons/website/models/__init__.py
@@ -5,6 +5,7 @@ from . import assets
from . import ir_actions
from . import ir_asset
from . import ir_attachment
+from . import ir_binary
from . import ir_http
from . import ir_model
from . import ir_model_data
diff --git a/addons/website/models/ir_attachment.py b/addons/website/models/ir_attachment.py
index eec9e9dcf2d..2efda37e0b7 100644
--- a/addons/website/models/ir_attachment.py
+++ b/addons/website/models/ir_attachment.py
@@ -27,9 +27,8 @@ class Attachment(models.Model):
def get_serving_groups(self):
return super(Attachment, self).get_serving_groups() + ['website.group_website_designer']
- @api.model
- def get_serve_attachment(self, url, extra_domain=None, extra_fields=None, order=None):
+ def _get_serve_attachment(self, url, extra_domain=None, order=None):
website = self.env['website'].get_current_website()
extra_domain = (extra_domain or []) + website.website_domain()
order = ('website_id, %s' % order) if order else 'website_id'
- return super(Attachment, self).get_serve_attachment(url, extra_domain, extra_fields, order)
+ return super()._get_serve_attachment(url, extra_domain, order)
diff --git a/addons/website/models/ir_binary.py b/addons/website/models/ir_binary.py
new file mode 100644
index 00000000000..bdce480927c
--- /dev/null
+++ b/addons/website/models/ir_binary.py
@@ -0,0 +1,28 @@
+from odoo import models
+
+
+class IrBinary(models.AbstractModel):
+ _inherit = 'ir.binary'
+
+ def _find_record(
+ self, xmlid=None, res_model='ir.attachment', res_id=None,
+ access_token=None,
+ ):
+ record = None
+ if xmlid:
+ website = self.env['website'].get_current_website()
+ if website.theme_id:
+ domain = [('key', '=', xmlid), ('website_id', '=', website.id)]
+ Attachment = self.env['ir.attachment']
+ if self.env.user.share:
+ domain.append(('public', '=', True))
+ Attachment = Attachment.sudo()
+ record = Attachment.search(domain, limit=1)
+
+ if not record:
+ record = super()._find_record(xmlid, res_model, res_id, access_token)
+
+ if 'website_published' in record and record.sudo().website_published:
+ record = record.sudo()
+
+ return record
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 157108214f6..c1395c5fce3 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -398,41 +398,6 @@ class Http(models.AbstractModel):
return code.split('_')[1], env['ir.ui.view']._render_template('website.%s' % code, values)
return super()._get_error_html(env, code, values)
- def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas',
- unique=False, filename=None, filename_field='name', download=False,
- mimetype=None, default_mimetype='application/octet-stream',
- access_token=None):
- obj = None
- if xmlid:
- obj = self._xmlid_to_obj(self.env, xmlid)
- elif id and model in self.env:
- obj = self.env[model].browse(int(id))
- if obj and 'website_published' in obj._fields:
- try:
- if obj.sudo().website_published:
- self = self.sudo()
- except MissingError:
- pass
- return super(Http, self).binary_content(
- xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
- filename_field=filename_field, download=download, mimetype=mimetype,
- default_mimetype=default_mimetype, access_token=access_token)
-
- @classmethod
- def _xmlid_to_obj(cls, env, xmlid):
- website_id = env['website'].get_current_website()
- if website_id and website_id.theme_id:
- domain = [('key', '=', xmlid), ('website_id', '=', website_id.id)]
- Attachment = env['ir.attachment']
- if request.env.user.share:
- domain.append(('public', '=', True))
- Attachment = Attachment.sudo()
- obj = Attachment.search(domain)
- if obj:
- return obj[0]
-
- return super()._xmlid_to_obj(env, xmlid)
-
@api.model
def get_frontend_session_info(self):
session_info = super(Http, self).get_frontend_session_info()
diff --git a/addons/website/tests/test_performance.py b/addons/website/tests/test_performance.py
index 723800db3a9..d7a1e39b274 100644
--- a/addons/website/tests/test_performance.py
+++ b/addons/website/tests/test_performance.py
@@ -43,16 +43,16 @@ class TestStandardPerformance(UtilPerf):
self.authenticate('demo', 'demo')
self.env['res.users'].sudo().browse(2).website_published = True
url = '/web/image/res.users/2/image_256'
- self.assertEqual(self._get_url_hot_query(url), 5)
- self.assertEqual(self._get_url_hot_query(url, cache=False), 5)
+ self.assertEqual(self._get_url_hot_query(url), 6)
+ self.assertEqual(self._get_url_hot_query(url, cache=False), 6)
def test_20_perf_sql_img_controller_bis(self):
url = '/web/image/website/1/favicon'
- self.assertEqual(self._get_url_hot_query(url), 4)
- self.assertEqual(self._get_url_hot_query(url, cache=False), 4)
+ self.assertEqual(self._get_url_hot_query(url), 5)
+ self.assertEqual(self._get_url_hot_query(url, cache=False), 5)
self.authenticate('portal', 'portal')
- self.assertEqual(self._get_url_hot_query(url), 4)
- self.assertEqual(self._get_url_hot_query(url, cache=False), 4)
+ self.assertEqual(self._get_url_hot_query(url), 5)
+ self.assertEqual(self._get_url_hot_query(url, cache=False), 5)
class TestWebsitePerformance(UtilPerf):
@@ -138,5 +138,5 @@ class TestWebsitePerformance(UtilPerf):
# assets route /web/assets/..
self.url_open('/') # create assets attachments
assets_url = self.env['ir.attachment'].search([('url', '=like', '/web/assets/%/web.assets_common%.js')], limit=1).url
- self.assertEqual(self._get_url_hot_query(assets_url), 2)
- self.assertEqual(self._get_url_hot_query(assets_url, cache=False), 2)
+ self.assertEqual(self._get_url_hot_query(assets_url), 4)
+ self.assertEqual(self._get_url_hot_query(assets_url, cache=False), 4)
diff --git a/addons/website_profile/controllers/main.py b/addons/website_profile/controllers/main.py
index dbcd8ad5c38..19da1d356bc 100644
--- a/addons/website_profile/controllers/main.py
+++ b/addons/website_profile/controllers/main.py
@@ -35,10 +35,6 @@ class WebsiteProfile(http.Controller):
return user.website_published and user.karma > 0
return False
- def _get_default_avatar(self):
- with tools.file_open("web/static/img/placeholder.png", 'rb') as f:
- return f.read()
-
def _check_user_profile_access(self, user_id):
user_sudo = request.env['res.users'].sudo().browse(user_id)
# User can access - no matter what - his own profile
@@ -79,30 +75,14 @@ class WebsiteProfile(http.Controller):
if field not in ('image_128', 'image_256', 'avatar_128', 'avatar_256'):
return werkzeug.exceptions.Forbidden()
- can_sudo = self._check_avatar_access(user_id, **post)
- if can_sudo:
- status, headers, image = request.env['ir.http'].sudo().binary_content(
- model='res.users', id=user_id, field=field,
- default_mimetype='image/png')
- else:
- status, headers, image = request.env['ir.http'].binary_content(
- model='res.users', id=user_id, field=field,
- default_mimetype='image/png')
- if status == 301:
- return request.env['ir.http']._response_by_status(status, headers, image)
- if status == 304:
- return werkzeug.wrappers.Response(status=304)
+ if (int(width), int(height)) == (0, 0):
+ width, height = tools.image_guess_size_from_field_name(field)
- if not image:
- image = self._get_default_avatar()
- if not (width or height):
- width, height = tools.image_guess_size_from_field_name(field)
-
- content = tools.image_process(image, size=(int(width), int(height)), crop=crop)
- headers = http.set_safe_image_headers(headers, content)
- response = request.make_response(content, headers)
- response.status_code = status
- return response
+ can_sudo = self._check_avatar_access(int(user_id), **post)
+ return request.env['ir.binary']._get_image_stream_from(
+ request.env['res.users'].sudo(can_sudo).browse(int(user_id)),
+ field_name=field, width=int(width), height=int(height), crop=crop
+ ).get_response()
@http.route(['/profile/user/'], type='http', auth="public", website=True)
def view_user_profile(self, user_id, **post):
diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py
index 73ece417d34..7cac5ce0bb7 100644
--- a/addons/website_slides/controllers/main.py
+++ b/addons/website_slides/controllers/main.py
@@ -819,25 +819,9 @@ class WebsiteSlides(WebsiteProfile):
if not slide:
raise werkzeug.exceptions.NotFound()
- status, headers, image = request.env['ir.http'].sudo().binary_content(
- model='slide.slide', id=slide.id, field=field,
- default_mimetype='image/png')
- if status == 301:
- return request.env['ir.http']._response_by_status(status, headers, image)
- if status == 304:
- return werkzeug.wrappers.Response(status=304)
-
- if not image:
- image = self._get_default_avatar()
- if not (width or height):
- width, height = tools.image_guess_size_from_field_name(field)
-
- content = tools.image_process(image, size=(int(width), int(height)), crop=crop)
-
- headers = http.set_safe_image_headers(headers, content)
- response = request.make_response(content, headers)
- response.status_code = status
- return response
+ return request.env['ir.binary']._get_image_stream_from(
+ slide, field, width=width, height=int(height), crop=int(crop)
+ ).get_response()
# SLIDE.SLIDE UTILS
# --------------------------------------------------
diff --git a/addons/website_slides/models/__init__.py b/addons/website_slides/models/__init__.py
index 335bfd8fb08..a0697b21214 100644
--- a/addons/website_slides/models/__init__.py
+++ b/addons/website_slides/models/__init__.py
@@ -1,7 +1,5 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from . import ir_http
from . import gamification_challenge
from . import slide_slide
from . import slide_question
diff --git a/addons/website_slides/models/ir_http.py b/addons/website_slides/models/ir_http.py
deleted file mode 100644
index 55fa9e931bc..00000000000
--- a/addons/website_slides/models/ir_http.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo import models
-
-
-class Http(models.AbstractModel):
- _inherit = 'ir.http'
-
- def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas',
- unique=False, filename=None, filename_field='name', download=False,
- mimetype=None, default_mimetype='application/octet-stream',
- access_token=None):
- obj = None
- if xmlid:
- obj = self._xmlid_to_obj(self.env, xmlid)
- if obj and obj._name != 'slide.slide':
- obj = None
- elif id and model == 'slide.slide':
- obj = self.env[model].browse(int(id))
- if obj:
- obj.check_access_rights('read')
- obj.check_access_rule('read')
- return super(Http, self).binary_content(
- xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
- filename_field=filename_field, download=download, mimetype=mimetype,
- default_mimetype=default_mimetype, access_token=access_token)
diff --git a/odoo/addons/base/models/__init__.py b/odoo/addons/base/models/__init__.py
index 3dc60a48f6b..dedeb5ca987 100644
--- a/odoo/addons/base/models/__init__.py
+++ b/odoo/addons/base/models/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import assetsbundle
@@ -11,6 +10,7 @@ from . import ir_asset
from . import ir_actions
from . import ir_actions_report
from . import ir_attachment
+from . import ir_binary
from . import ir_cron
from . import ir_filters
from . import ir_default
diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py
index aa6a83f3976..0a2f0e36665 100644
--- a/odoo/addons/base/models/assetsbundle.py
+++ b/odoo/addons/base/models/assetsbundle.py
@@ -757,9 +757,9 @@ class WebAsset(object):
return
try:
# Test url against ir.attachments
- attach = self.bundle.env['ir.attachment'].sudo().get_serve_attachment(self.url)
- self._ir_attach = attach[0]
- except Exception:
+ self._ir_attach = self.bundle.env['ir.attachment'].sudo()._get_serve_attachment(self.url)
+ self._ir_attach.ensure_one()
+ except ValueError:
raise AssetNotFound("Could not find %s" % self.name)
def to_node(self):
@@ -791,7 +791,7 @@ class WebAsset(object):
with closing(file_open(self._filename, 'rb', filter_ext=EXTENSIONS)) as fp:
return fp.read().decode('utf-8')
else:
- return base64.b64decode(self._ir_attach['datas']).decode('utf-8')
+ return self._ir_attach.raw.decode()
except UnicodeDecodeError:
raise AssetError('%s is not utf-8 encoded.' % self.name)
except IOError:
diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py
index e8421b143bf..ff277b95cba 100644
--- a/odoo/addons/base/models/ir_attachment.py
+++ b/odoo/addons/base/models/ir_attachment.py
@@ -1,5 +1,5 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
import base64
import hashlib
import io
@@ -15,7 +15,7 @@ from PIL import Image
from odoo import api, fields, models, tools, _
from odoo.exceptions import AccessError, ValidationError, UserError
-from odoo.tools import config, human_size, ImageProcess, str2bool
+from odoo.tools import config, human_size, ImageProcess, str2bool, consteq
from odoo.tools.mimetypes import guess_mimetype
from odoo.osv import expression
@@ -673,12 +673,33 @@ class IrAttachment(models.Model):
def _generate_access_token(self):
return str(uuid.uuid4())
+ def validate_access(self, access_token):
+ self.ensure_one()
+ record_sudo = self.sudo()
+
+ if access_token:
+ tok = record_sudo.with_context(prefetch_fields=False).access_token
+ valid_token = consteq(tok or '', access_token)
+ if not valid_token:
+ raise AccessError("Invalid access token")
+ return record_sudo
+
+ if record_sudo.with_context(prefetch_fields=False).public:
+ return record_sudo
+
+ if self.env.user.has_group('base.group_portal'):
+ # Check the read access on the record linked to the attachment
+ # eg: Allow to download an attachment on a task from /my/tasks/task_id
+ self.check('read')
+ return record_sudo
+
+ return self
+
@api.model
def action_get(self):
return self.env['ir.actions.act_window']._for_xml_id('base.action_attachment')
@api.model
- def get_serve_attachment(self, url, extra_domain=None, extra_fields=None, order=None):
+ def _get_serve_attachment(self, url, extra_domain=None, order=None):
domain = [('type', '=', 'binary'), ('url', '=', url)] + (extra_domain or [])
- fieldNames = ['__last_update', 'datas', 'mimetype'] + (extra_fields or [])
- return self.search_read(domain, fieldNames, order=order, limit=1)
+ return self.search(domain, order=order, limit=1)
diff --git a/odoo/addons/base/models/ir_binary.py b/odoo/addons/base/models/ir_binary.py
new file mode 100644
index 00000000000..43f5b304e8c
--- /dev/null
+++ b/odoo/addons/base/models/ir_binary.py
@@ -0,0 +1,247 @@
+import logging
+import werkzeug.http
+from datetime import datetime
+from mimetypes import guess_extension
+
+from odoo import models
+from odoo.exceptions import MissingError, UserError
+from odoo.http import Stream, request
+from odoo.tools import file_open, replace_exceptions
+from odoo.tools.image import image_process, image_guess_size_from_field_name
+from odoo.tools.mimetypes import guess_mimetype, get_extension
+
+
+DEFAULT_PLACEHOLDER_PATH = 'web/static/img/placeholder.png'
+_logger = logging.getLogger(__name__)
+
+
+class IrBinary(models.AbstractModel):
+ _name = 'ir.binary'
+ _description = "File streaming helper model for controllers"
+
+ def _find_record(
+ self, xmlid=None, res_model='ir.attachment', res_id=None,
+ access_token=None,
+ ):
+ """
+ Find and return a record either using an xmlid either a model+id
+ pair. This method is an helper for the ``/web/content`` and
+ ``/web/image`` controllers and should not be used in other
+ contextes.
+
+ :param Optional[str] xmlid: xmlid of the record
+ :param Optional[str] res_model: model of the record,
+ ir.attachment by default.
+ :param Optional[id] res_id: id of the record
+ :param Optional[str] access_token: access token to use instead
+ of the access rights and access rules.
+ :returns: single record
+ :raises MissingError: when no record was found.
+ """
+ record = None
+ if xmlid:
+ record = self.env.ref(xmlid, False)
+ elif res_id is not None and res_model in self.env:
+ record = self.env[res_model].browse(res_id).exists()
+ if not record:
+ raise MissingError(f"No record found for xmlid={xmlid}, res_model={res_model}, id={res_id}")
+
+ if record._name == 'ir.attachment':
+ record = record.validate_access(access_token)
+
+ return record
+
+ def _record_to_stream(self, record, field_name):
+ """
+ Low level method responsible for the actual conversion from a
+ model record to a stream. This method is an extensible hook for
+ other modules. It is not meant to be directly called from
+ outside or the ir.binary model.
+
+ :param record: the record where to load the data from.
+ :param str field_name: the binary field where to load the data
+ from.
+ :rtype: odoo.http.Stream
+ """
+ if record._name == 'ir.attachment' and field_name in ('raw', 'datas', 'db_datas'):
+ return Stream.from_attachment(record)
+
+ field_def = record._fields[field_name]
+
+ # fields.Binary(attachment=False) or compute/related
+ if not field_def.attachment or field_def.compute or field_def.related:
+ return Stream.from_binary_field(record, field_name)
+
+ # fields.Binary(attachment=True)
+ field_attachment = self.env['ir.attachment'].sudo().search(
+ domain=[('res_model', '=', record._name),
+ ('res_id', '=', record.id),
+ ('res_field', '=', field_name)],
+ limit=1)
+ if not field_attachment:
+ raise MissingError("The related attachment does not exist.")
+ return Stream.from_attachment(field_attachment)
+
+ def _get_stream_from(
+ self, record, field_name='raw', filename=None, filename_field='name',
+ mimetype=None, default_mimetype='application/octet-stream',
+ ):
+ """
+ Create a :class:odoo.http.Stream: from a record's binary field.
+
+ :param record: the record where to load the data from.
+ :param str field_name: the binary field where to load the data
+ from.
+ :param Optional[str] filename: when the stream is downloaded by
+ a browser, what filename it should have on disk. By default
+ it is ``{model}-{id}-{field}.{extension}``, the extension is
+ determined thanks to mimetype.
+ :param Optional[str] filename_field: like ``filename`` but use
+ one of the record's char field as filename.
+ :param Optional[str] mimetype: the data mimetype to use instead
+ of the stored one (attachment) or the one determined by
+ magic.
+ :param str default_mimetype: the mimetype to use when the
+ mimetype couldn't be determined. By default it is
+ ``application/octet-stream``.
+ :rtype: odoo.http.Stream
+ """
+ with replace_exceptions(ValueError, by=UserError(f'Expected singleton: {record}')):
+ record.ensure_one()
+
+ try:
+ field_def = record._fields[field_name]
+ except KeyError:
+ raise UserError(f"Record has no field {field_name!r}.")
+ if field_def.type != 'binary':
+ raise UserError(
+ f"Field {field_def!r} is type {field_def.type!r} but "
+ f"it is only possible to stream Binary or Image fields."
+ )
+
+ stream = self._record_to_stream(record, field_name)
+
+ if stream.type in ('data', 'path'):
+ if mimetype:
+ stream.mimetype = mimetype
+ elif not stream.mimetype:
+ if stream.type == 'data':
+ head = stream.data[:1024]
+ else:
+ with open(stream.path, 'rb') as file:
+ head = file.read(1024)
+ stream.mimetype = guess_mimetype(head, default=default_mimetype)
+
+ if filename:
+ stream.download_name = filename
+ elif filename_field in record:
+ stream.download_name = record[filename_field]
+ if not stream.download_name:
+ stream.download_name = f'{record._table}-{record.id}-{field_name}'
+
+ if (not get_extension(stream.download_name)
+ and stream.mimetype != 'application/octet-stream'):
+ stream.download_name += guess_extension(stream.mimetype) or ''
+
+ return stream
+
+ def _get_image_stream_from(
+ self, record, field_name='raw', filename=None, filename_field='name',
+ mimetype=None, default_mimetype='image/png', placeholder=None,
+ width=0, height=0, crop=False, quality=0,
+ ):
+ """
+ Create a :class:odoo.http.Stream: from a record's binary field,
+ equivalent of :meth:`~get_stream_from` but for images.
+
+ In case the record does not exist or is not accessible, the
+ alternative ``placeholder`` path is used instead. If not set,
+ a path is determined via
+ :meth:`~odoo.models.BaseModel._get_placeholder_filename` which
+ ultimately fallbacks on ``web/static/img/placeholder.png``.
+
+ In case the arguments ``width``, ``height``, ``crop`` or
+ ``quality`` are given, the image will be post-processed and the
+ ETags (the unique cache http header) will be updated
+ accordingly. See also :func:`odoo.tools.image.image_process`.
+
+ :param record: the record where to load the data from.
+ :param str field_name: the binary field where to load the data
+ from.
+ :param Optional[str] filename: when the stream is downloaded by
+ a browser, what filename it should have on disk. By default
+ it is ``{table}-{id}-{field}.{extension}``, the extension is
+ determined thanks to mimetype.
+ :param Optional[str] filename_field: like ``filename`` but use
+ one of the record's char field as filename.
+ :param Optional[str] mimetype: the data mimetype to use instead
+ of the stored one (attachment) or the one determined by
+ magic.
+ :param str default_mimetype: the mimetype to use when the
+ mimetype couldn't be determined. By default it is
+ ``image/png``.
+ :param Optional[pathlike] placeholder: in case the image is not
+ found or unaccessible, the path of an image to use instead.
+ By default the record ``_get_placeholder_filename`` on the
+ requested field or ``web/static/img/placeholder.png``.
+ :param int width: if not zero, the width of the resized image.
+ :param int height: if not zero, the height of the resized image.
+ :param bool crop: if true, crop the image instead of rezising
+ it.
+ :param int quality: if not zero, the quality of the resized
+ image.
+
+ """
+ try:
+ stream = self._get_stream_from(
+ record, field_name, filename, filename_field, mimetype,
+ default_mimetype
+ )
+ except UserError:
+ if request.params.get('download'):
+ raise
+ if not placeholder:
+ placeholder = record._get_placeholder_filename(field_name)
+ stream = self._get_placeholder_stream(placeholder)
+
+ if stream.type == 'url':
+ return stream # Rezising an external URL is not supported
+
+ if (width, height) == (0, 0):
+ width, height = image_guess_size_from_field_name(field_name)
+ stream.etag += f'-{width}x{height}-crop={crop}-quality={quality}'
+
+ if isinstance(stream.last_modified, (int, float)):
+ stream.last_modified = datetime.utcfromtimestamp(stream.last_modified)
+ modified = werkzeug.http.is_resource_modified(
+ request.httprequest.environ,
+ etag=stream.etag,
+ last_modified=stream.last_modified
+ )
+
+ if modified and (width or height or crop):
+ if stream.type == 'path':
+ with open(stream.path, 'rb') as file:
+ stream.type = 'data'
+ stream.path = None
+ stream.data = file.read()
+ stream.data = image_process(
+ stream.data,
+ size=(width, height),
+ crop=crop,
+ quality=quality,
+ )
+ stream.size = len(stream.data)
+
+ return stream
+
+ def _get_placeholder_stream(self, path=None):
+ if not path:
+ path = DEFAULT_PLACEHOLDER_PATH
+ return Stream.from_path(path, filter_ext=('.png', '.jpg'))
+
+ def _placeholder(self, path=False):
+ if not path:
+ path = DEFAULT_PLACEHOLDER_PATH
+ with file_open(path, 'rb', filter_ext=('.png', '.jpg')) as file:
+ return file.read()
diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py
index 4f91d14178c..da8c869823b 100644
--- a/odoo/addons/base/models/ir_http.py
+++ b/odoo/addons/base/models/ir_http.py
@@ -19,10 +19,9 @@ import werkzeug.utils
import odoo
from odoo import api, http, models, tools, SUPERUSER_ID
from odoo.exceptions import AccessDenied, AccessError, MissingError
-from odoo.http import request, content_disposition, Response, ROUTING_KEYS
+from odoo.http import request, Response, ROUTING_KEYS, Stream
from odoo.service import security
from odoo.tools import consteq, submap
-from odoo.tools.mimetypes import get_extension, guess_mimetype
from odoo.modules.module import get_resource_path, get_module_path
_logger = logging.getLogger(__name__)
@@ -147,39 +146,12 @@ class IrHttp(models.AbstractModel):
def _handle_error(cls, exception):
return request.dispatcher.handle_error(exception)
- @classmethod
- def _serve_attachment(cls):
- env = request.env(user=SUPERUSER_ID)
- attach = env['ir.attachment'].get_serve_attachment(request.httprequest.path, extra_fields=['name', 'checksum'])
- if attach:
- wdate = attach[0]['__last_update']
- datas = attach[0]['datas'] or b''
- name = attach[0]['name']
- checksum = attach[0]['checksum'] or hashlib.sha512(datas).hexdigest()[:64] # sha512/256
-
- if (not datas and name != request.httprequest.path and
- name.startswith(('http://', 'https://', '/'))):
- return request.redirect(name, 301, local=False)
-
- response = werkzeug.wrappers.Response()
- response.last_modified = wdate
-
- response.set_etag(checksum)
- response.make_conditional(request.httprequest)
-
- if response.status_code == 304:
- return response
-
- response.mimetype = attach[0]['mimetype'] or 'application/octet-stream'
- response.data = base64.b64decode(datas)
- return response
-
@classmethod
def _serve_fallback(cls):
- # serve attachment
- attach = cls._serve_attachment()
+ model = request.env['ir.attachment']
+ attach = model.sudo()._get_serve_attachment(request.httprequest.path)
if attach:
- return attach
+ return Stream.from_attachment(attach).get_response()
@classmethod
def _redirect(cls, location, code=303):
@@ -226,210 +198,3 @@ class IrHttp(models.AbstractModel):
@api.autovacuum
def _gc_sessions(self):
http.root.session_store.vacuum()
-
- #------------------------------------------------------
- # Binary server
- #------------------------------------------------------
-
- @classmethod
- def _xmlid_to_obj(cls, env, xmlid):
- return env.ref(xmlid, False)
-
- def _get_record_and_check(self, xmlid=None, model=None, id=None, field='datas', access_token=None):
- # get object and content
- record = None
- if xmlid:
- record = self._xmlid_to_obj(self.env, xmlid)
- elif id and model in self.env:
- record = self.env[model].browse(int(id))
-
- # obj exists
- if not record or field not in record:
- return None, 404
-
- try:
- if model == 'ir.attachment':
- record_sudo = record.sudo()
- if access_token and not consteq(record_sudo.access_token or '', access_token):
- return None, 403
- elif (access_token and consteq(record_sudo.access_token or '', access_token)):
- record = record_sudo
- elif record_sudo.public:
- record = record_sudo
- elif self.env.user.has_group('base.group_portal'):
- # Check the read access on the record linked to the attachment
- # eg: Allow to download an attachment on a task from /my/tasks/task_id
- record.check('read')
- record = record_sudo
-
- # check read access
- try:
- # We have prefetched some fields of record, among which the field
- # 'write_date' used by '__last_update' below. In order to check
- # access on record, we have to invalidate its cache first.
- if not record.env.su:
- record._cache.clear()
- record['__last_update']
- except AccessError:
- return None, 403
-
- return record, 200
- except MissingError:
- return None, 404
-
- @classmethod
- def _binary_ir_attachment_redirect_content(cls, record, default_mimetype='application/octet-stream'):
- # mainly used for theme images attachemnts
- status = content = filename = filehash = None
- mimetype = getattr(record, 'mimetype', False)
- if record.type == 'url' and record.url:
- # if url in in the form /somehint server locally
- url_match = re.match("^/(\w+)/(.+)$", record.url)
- if url_match:
- module = url_match.group(1)
- module_path = get_module_path(module)
- module_resource_path = get_resource_path(module, url_match.group(2))
-
- if module_path and module_resource_path:
- module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/'
- module_resource_path = os.path.normpath(module_resource_path)
- if module_resource_path.startswith(module_path):
- with open(module_resource_path, 'rb') as f:
- content = f.read()
- status = 200
- filename = os.path.basename(module_resource_path)
- mimetype = record.mimetype
- filehash = record.checksum
-
- if not content:
- status = 301
- content = record.url
-
- return status, content, filename, mimetype, filehash
-
- def _binary_record_content(
- self, record, field='raw', filename=None,
- filename_field='name', default_mimetype='application/octet-stream'):
-
- model = record._name
- mimetype = 'mimetype' in record and record.mimetype or False
- content = None
- filehash = 'checksum' in record and record['checksum'] or False
-
- field_def = record._fields[field]
- if field_def.type == 'binary' and field_def.attachment and not field_def.related:
- if model != 'ir.attachment':
- field_attachment = self.env['ir.attachment'].sudo().search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['raw', 'mimetype', 'checksum'], limit=1)
- if field_attachment:
- mimetype = field_attachment[0]['mimetype']
- content = field_attachment[0]['raw']
- filehash = field_attachment[0]['checksum']
- else:
- mimetype = record['mimetype']
- content = record['raw']
- filehash = record['checksum']
-
- if not content:
- if model == 'ir.attachment' and field in {'datas', 'raw'}:
- content = record.raw
- elif (
- field_def.related_field and
- field_def.related_field.name == 'raw' and
- field_def.related_field.model_name == 'ir.attachment'
- ):
- content = record[field] or b''
- else:
- data = record[field] or b''
- content = base64.b64decode(data)
- filehash = '"%s"' % hashlib.md5(str(content).encode('utf-8')).hexdigest()
-
- # filename
- if not filename:
- if filename_field in record:
- filename = record[filename_field]
- if not filename:
- filename = "%s-%s-%s" % (record._name, record.id, field)
-
- if not mimetype:
- mimetype = guess_mimetype(content, default=default_mimetype)
-
- # extension
- has_extension = get_extension(filename) or mimetypes.guess_type(filename)[0]
- if not has_extension:
- extension = mimetypes.guess_extension(mimetype)
- if extension:
- filename = "%s%s" % (filename, extension)
-
- if not filehash:
- filehash = '"%s"' % hashlib.md5(str(base64.b64encode(content)).encode('utf-8')).hexdigest()
-
- status = 200 if content else 404
- return status, content, filename, mimetype, filehash
-
- def _binary_set_headers(self, status, filename, mimetype, unique, filehash=None, download=False):
- headers = [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff'), ('Content-Security-Policy', "default-src 'none'")]
- # cache
- etag = bool(request) and request.httprequest.headers.get('If-None-Match')
- status = status or 200
- if filehash:
- headers.append(('ETag', filehash))
- if etag == filehash and status == 200:
- status = 304
- headers.append(('Cache-Control', 'max-age=%s' % (http.STATIC_CACHE_LONG if unique else 0)))
- # content-disposition default name
- if download:
- headers.append(('Content-Disposition', content_disposition(filename)))
-
- return (status, headers)
-
- def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='raw',
- unique=False, filename=None, filename_field='name', download=False,
- mimetype=None, default_mimetype='application/octet-stream',
- access_token=None):
- """ Get file, attachment or downloadable content
-
- If the ``xmlid`` and ``id`` parameter is omitted, fetches the default value for the
- binary field (via ``default_get``), otherwise fetches the field for
- that precise record.
-
- :param str xmlid: xmlid of the record
- :param str model: name of the model to fetch the binary from
- :param int id: id of the record from which to fetch the binary
- :param str field: binary field
- :param bool unique: add a max-age for the cache control
- :param str filename: choose a filename
- :param str filename_field: if not create an filename with model-id-field
- :param bool download: apply headers to download the file
- :param str mimetype: mintype of the field (for headers)
- :param str default_mimetype: default mintype if no mintype found
- :param str access_token: optional token for unauthenticated access
- only available for ir.attachment
- :returns: (status, headers, content)
- """
- record, status = self._get_record_and_check(xmlid=xmlid, model=model, id=id, field=field, access_token=access_token)
-
- if not record:
- return (status or 404, [], None)
-
- content, headers, status = None, [], None
-
- if record._name == 'ir.attachment':
- status, content, default_filename, mimetype, filehash = self._binary_ir_attachment_redirect_content(record, default_mimetype=default_mimetype)
- filename = filename or default_filename
- if not content:
- status, content, filename, mimetype, filehash = self._binary_record_content(
- record, field=field, filename=filename, filename_field=filename_field,
- default_mimetype='application/octet-stream')
-
- status, headers = self._binary_set_headers(
- status, filename, mimetype, unique, filehash=filehash, download=download)
-
- return status, headers, content
-
- def _response_by_status(self, status, headers, content):
- if status == 304:
- return werkzeug.wrappers.Response(status=status, headers=headers)
- elif status == 301:
- return request.redirect(content, code=301, local=False)
- elif status != 200:
- raise request.not_found()
diff --git a/odoo/addons/base/tests/__init__.py b/odoo/addons/base/tests/__init__.py
index cc7d9f2099d..9ab47384d61 100644
--- a/odoo/addons/base/tests/__init__.py
+++ b/odoo/addons/base/tests/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
@@ -18,7 +17,6 @@ from . import test_avatar_mixin
from . import test_ir_actions
from . import test_ir_attachment
from . import test_ir_cron
-from . import test_ir_http
from . import test_ir_filters
from . import test_ir_mail_server
from . import test_ir_model
diff --git a/odoo/addons/base/tests/test_ir_http.py b/odoo/addons/base/tests/test_ir_http.py
deleted file mode 100644
index 011734882f0..00000000000
--- a/odoo/addons/base/tests/test_ir_http.py
+++ /dev/null
@@ -1,152 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from odoo.tests import common
-import base64
-import odoo
-
-GIF = b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
-
-
-class test_ir_http_mimetype(common.TransactionCase):
-
- def test_ir_http_mimetype_attachment(self):
- """ Test mimetype for attachment """
- attachment = self.env['ir.attachment'].create({
- 'datas': GIF,
- 'name': 'file.gif'})
-
- status, headers, content = self.env['ir.http'].binary_content(
- id=attachment.id,
- mimetype=None,
- default_mimetype='application/octet-stream',
- )
- mimetype = dict(headers).get('Content-Type')
- self.assertEqual(mimetype, 'image/gif')
-
- def test_ir_http_mimetype_attachment_name(self):
- """ Test mimetype for attachment with bad name"""
- attachment = self.env['ir.attachment'].create({
- 'datas': GIF,
- 'name': 'file.png'})
-
- status, headers, content = self.env['ir.http'].binary_content(
- id=attachment.id,
- mimetype=None,
- default_mimetype='application/octet-stream',
- )
- mimetype = dict(headers).get('Content-Type')
- # TODO: fix and change it in master, should be image/gif
- self.assertEqual(mimetype, 'image/png')
-
- def test_ir_http_mimetype_basic_field(self):
- """ Test mimetype for classic field """
- partner = self.env['res.partner'].create({
- 'image_1920': GIF,
- 'name': 'Test mimetype basic field',
- })
-
- status, headers, content = self.env['ir.http'].binary_content(
- model='res.partner',
- id=partner.id,
- field='image_1920',
- default_mimetype='application/octet-stream',
- )
- mimetype = dict(headers).get('Content-Type')
- self.assertEqual(mimetype, 'image/gif')
-
- def test_ir_http_mimetype_computed_field(self):
- """ Test mimetype for computed field wich resize picture"""
- prop = self.env['ir.property'].create({
- 'fields_id': self.env['ir.model.fields'].search([], limit=1).id,
- 'name': "Property binary",
- 'value_binary': GIF,
- 'type': 'binary',
- })
-
- resized = odoo.tools.image_process(base64.b64decode(prop.value_binary), size=(64, 64))
- # Simul computed field which resize and that is not attachement=True (E.G. on product)
- prop.write({'value_binary': base64.b64encode(resized)})
- status, headers, content = self.env['ir.http'].binary_content(
- model='ir.property',
- id=prop.id,
- field='value_binary',
- default_mimetype='application/octet-stream',
- )
- mimetype = dict(headers).get('Content-Type')
- self.assertEqual(mimetype, 'image/gif')
-
- def test_ir_http_attachment_access(self):
- """ Test attachment access with and without access token """
- public_user = self.env.ref('base.public_user')
- attachment = self.env['ir.attachment'].create({
- 'datas': GIF,
- 'name': 'image.gif'
- })
-
- defaults = {
- 'id': attachment.id,
- 'default_mimetype': 'image/gif',
- }
-
- def test_access(**kwargs):
- # DLE P69: `test_ir_http_attachment_access`
- # `binary_content` relies on the `__last_update` to determine if a user has the read access to an attachment.
- # as the attachment has just been created above as sudo, the data is in cache and if we don't remove it the below
- # `test_access` wont have to fetch it and therefore wont raise the accesserror as its already in the cache
- # `__last_update` must be removed from the cache when `test_access` is called, which happens and recompute the todos
- attachment.env.flush_all()
- attachment.env.invalidate_all()
- status, _, _ = self.env['ir.http'].with_user(public_user).binary_content(
- **dict(defaults, **kwargs)
- )
- return status
-
- status = test_access()
- self.assertEqual(status, 403, "no access")
-
- status = test_access(access_token=u'Secret')
- self.assertEqual(status, 403,
- "no access if access token for attachment without access token")
-
- attachment.access_token = u'Secret'
- status = test_access(access_token=u'Secret')
- self.assertEqual(status, 200, "access for correct access token")
-
- status = test_access(access_token=u'Wrong')
- self.assertEqual(status, 403, "no access for wrong access token")
-
- attachment.public = True
- status = test_access()
- self.assertEqual(status, 200, "access for attachment with access")
-
- status = test_access(access_token=u'Wrong')
- self.assertEqual(status, 403,
- "no access for wrong access token for attachment with access")
-
- attachment.unlink()
- status = test_access()
- self.assertEqual(status, 404, "no access for deleted attachment")
-
- status = test_access(access_token=u'Secret')
- self.assertEqual(status, 404,
- "no access with access token for deleted attachment")
-
- def test_ir_http_default_filename_extension(self):
- """ Test attachment extension when the record has a dot in its name """
- self.env.user.name = "Mr. John"
- self.env.user.image_128 = GIF
- _, _, filename, _, _ = self.env['ir.http']._binary_record_content(
- self.env.user, 'image_128',
- )
- self.assertEqual(filename, "Mr. John.gif")
-
- # For attachment, the name is considered to have the extension in the name
- # and thus the extension should not be added again.
- attachment = self.env['ir.attachment'].create({
- 'datas': GIF,
- 'name': 'image.gif'
- })
- _, _, filename, _, _ = self.env['ir.http']._binary_record_content(
- attachment,
- )
- self.assertEqual(filename, 'image.gif')
diff --git a/odoo/addons/base/tests/test_mimetypes.py b/odoo/addons/base/tests/test_mimetypes.py
index 774c5ac1b89..43d52b30f10 100644
--- a/odoo/addons/base/tests/test_mimetypes.py
+++ b/odoo/addons/base/tests/test_mimetypes.py
@@ -79,7 +79,7 @@ class test_guess_mimetype(BaseCase):
self.assertEqual(get_extension('filename.Abc'), '.abc')
self.assertEqual(get_extension('filename.scss'), '.scss')
self.assertEqual(get_extension('filename.torrent'), '.torrent')
- self.assertEqual(get_extension('.htaccess'), '.htaccess')
+ self.assertEqual(get_extension('.htaccess'), '')
# enough to suppose that extension is present and don't suffix the filename
self.assertEqual(get_extension('filename.tar.gz'), '.gz')
self.assertEqual(get_extension('filename'), '')
diff --git a/odoo/addons/test_http/controllers.py b/odoo/addons/test_http/controllers.py
index 1a2784b8c3f..855a7526dac 100644
--- a/odoo/addons/test_http/controllers.py
+++ b/odoo/addons/test_http/controllers.py
@@ -5,7 +5,7 @@ import werkzeug
from odoo import http
from odoo.exceptions import AccessError, UserError
from odoo.http import request
-from odoo.tools import reraise_x_as
+from odoo.tools import replace_exceptions
from odoo.addons.web.controllers.utils import ensure_db
@@ -151,16 +151,16 @@ class TestHttp(http.Controller):
raise ValueError('Unknown destination')
@http.route('/test_http/hide_errors/decorator', type='http', auth='none')
- @reraise_x_as(AccessError, as_=werkzeug.exceptions.NotFound())
- def hide_errors_deco(self, error):
+ @replace_exceptions(AccessError, by=werkzeug.exceptions.NotFound())
+ def hide_errors_decorator(self, error):
if error == 'AccessError':
raise AccessError("Wrong iris code")
if error == 'UserError':
raise UserError("Walter is AFK")
@http.route('/test_http/hide_errors/context-manager', type='http', auth='none')
- def hide_errors_cm(self, error):
- with reraise_x_as(AccessError, as_=werkzeug.exceptions.NotFound()):
+ def hide_errors_context_manager(self, error):
+ with replace_exceptions(AccessError, by=werkzeug.exceptions.NotFound()):
if error == 'AccessError':
raise AccessError("Wrong iris code")
if error == 'UserError':
diff --git a/odoo/addons/test_http/data.xml b/odoo/addons/test_http/data.xml
index 0fe47642439..5eaf9c0c6f2 100644
--- a/odoo/addons/test_http/data.xml
+++ b/odoo/addons/test_http/data.xml
@@ -1,5 +1,28 @@
+
+
+ gizeh.png
+ binary
+
+ /test_http/gizeh.png
+ True
+
+
+
+ gizeh.png
+ url
+ /test_http/static/src/img/gizeh.png
+ True
+
+
+
+ rickroll
+ url
+ https://www.youtube.com/watch?v=dQw4w9WgXcQ
+ True
+
+
Milky Way
@@ -8,11 +31,12 @@
Pegasus
-
Earth
sq5Abt
+
+
diff --git a/odoo/addons/test_http/models.py b/odoo/addons/test_http/models.py
index a67b8bee94e..90bb1ee9704 100644
--- a/odoo/addons/test_http/models.py
+++ b/odoo/addons/test_http/models.py
@@ -16,6 +16,8 @@ class Stargate(models.Model):
sgc_designation = fields.Char(store=True, compute='_compute_sgc_designation', help="The SGC designation name of this stargate.")
galaxy_id = fields.Many2one('test_http.galaxy', required=True, help="The galaxy where this stargate is.")
has_galaxy_crystal = fields.Boolean(store=True, compute='_compute_has_galaxy_crystal', readonly=False, help="Whether this stargate can dial other galaxies.")
+ glyph_attach = fields.Image(attachment=True)
+ glyph_inline = fields.Image(attachment=False)
_sql_constraints = [
('address_length', 'CHECK(LENGTH(address) = 6)', "Local addresses have 6 glyphs"),
diff --git a/odoo/addons/test_http/static/src/img/gizeh.svg b/odoo/addons/test_http/static/src/img/gizeh.svg
deleted file mode 100644
index 4a20f3757e2..00000000000
--- a/odoo/addons/test_http/static/src/img/gizeh.svg
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/odoo/addons/test_http/tests/__init__.py b/odoo/addons/test_http/tests/__init__.py
index e0be1dc5bd2..f8bfbef7e2f 100644
--- a/odoo/addons/test_http/tests/__init__.py
+++ b/odoo/addons/test_http/tests/__init__.py
@@ -1,2 +1,8 @@
+from . import test_common
+from . import test_echo_reply
from . import test_error
-from . import test_http
+from . import test_greeting
+from . import test_misc
+from . import test_models
+from . import test_static
+from . import test_web_server
diff --git a/odoo/addons/test_http/tests/test_common.py b/odoo/addons/test_http/tests/test_common.py
new file mode 100644
index 00000000000..41c9ed2ecaa
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_common.py
@@ -0,0 +1,45 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from unittest.mock import patch
+
+import odoo
+from odoo.http import Session
+from odoo.tests.common import HttpCase
+from odoo.tools.func import lazy_property
+from odoo.addons.test_http.utils import MemoryGeoipResolver, MemorySessionStore
+
+
+class TestHttpBase(HttpCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.addClassCleanup(lazy_property.reset_all, odoo.http.root)
+ cls.classPatch(odoo.conf, 'server_wide_modules', ['base', 'web', 'test_http'])
+ lazy_property.reset_all(odoo.http.root)
+ cls.classPatch(odoo.http.root, 'session_store', MemorySessionStore(session_class=Session))
+ cls.classPatch(odoo.http.root, 'geoip_resolver', MemoryGeoipResolver())
+
+ def setUp(self):
+ super().setUp()
+ odoo.http.root.session_store.store.clear()
+
+ def db_url_open(self, url, *args, allow_redirects=False, **kwargs):
+ return self.url_open(url, *args, allow_redirects=allow_redirects, **kwargs)
+
+ def nodb_url_open(self, url, *args, allow_redirects=False, **kwargs):
+ with patch('odoo.http.db_list') as db_list, \
+ patch('odoo.http.db_filter') as db_filter:
+ db_list.return_value = []
+ db_filter.return_value = []
+ return self.url_open(url, *args, allow_redirects=allow_redirects, **kwargs)
+
+ def multidb_url_open(self, url, *args, allow_redirects=False, dblist=(), **kwargs):
+ dblist = dblist or self.db_list
+ assert len(dblist) >= 2, "There should be at least 2 databases"
+ with patch('odoo.http.db_list') as db_list, \
+ patch('odoo.http.db_filter') as db_filter, \
+ patch('odoo.http.Registry') as Registry:
+ db_list.return_value = dblist
+ db_filter.side_effect = lambda dbs, host=None: [db for db in dbs if db in dblist]
+ Registry.return_value = self.registry
+ return self.url_open(url, *args, allow_redirects=allow_redirects, **kwargs)
diff --git a/odoo/addons/test_http/tests/test_echo_reply.py b/odoo/addons/test_http/tests/test_echo_reply.py
new file mode 100644
index 00000000000..fe9c4458280
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_echo_reply.py
@@ -0,0 +1,173 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+from urllib.parse import urlparse
+
+from odoo.http import Request
+from odoo.tests import tagged
+from odoo.tests.common import new_test_user
+from odoo.tools import mute_logger
+from odoo.addons.test_http.controllers import CT_JSON
+
+from .test_common import TestHttpBase
+
+
+@tagged('post_install', '-at_install')
+class TestHttpEchoReplyHttpNoDB(TestHttpBase):
+ def test_echohttp0_get_qs_nodb(self):
+ res = self.nodb_url_open('/test_http/echo-http-get?race=Asgard')
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, "{'race': 'Asgard'}")
+
+ def test_echohttp1_get_form_nodb(self):
+ res = self.nodb_url_open('/test_http/echo-http-get', data={'commander': 'Thor'})
+ self.assertEqual(res.status_code, 405)
+
+ def test_echohttp2_post_qs_nodb(self):
+ res = self.nodb_url_open('/test_http/echo-http-post?race=Asgard')
+ self.assertEqual(res.status_code, 405)
+
+ def test_echohttp3_post_qs_form_nodb(self):
+ res = self.nodb_url_open('/test_http/echo-http-post?race=Asgard', data={'commander': 'Thor'})
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, "{'race': 'Asgard', 'commander': 'Thor'}")
+
+ @mute_logger('odoo.http')
+ def test_echohttp4_post_json_nodb(self):
+ payload = json.dumps({'commander': 'Thor'})
+ res = self.nodb_url_open('/test_http/echo-http-post', data=payload, headers=CT_JSON)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, '{}')
+
+
+ def test_echohttp5_post_csrf(self):
+ res = self.nodb_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor'})
+ self.assertEqual(res.status_code, 303)
+ self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/web/database/selector')
+
+ def test_echohttp6_json_over_http(self):
+ payload = json.dumps({'commander': 'Thor'})
+ res = self.nodb_url_open('/test_http/echo-json-over-http', data=payload, headers=CT_JSON)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, payload)
+ mimetype = res.headers['Content-Type'].partition(';')[0]
+ self.assertEqual(mimetype, 'application/json')
+
+
+@tagged('post_install', '-at_install')
+class TestHttpEchoReplyJsonNoDB(TestHttpBase):
+ def test_echojson0_qs_json_nodb(self):
+ payload = json.dumps({
+ 'jsonrpc': '2.0',
+ 'id': 1234,
+ 'params': {
+ 'commander': 'Thor',
+ },
+ })
+ res = self.nodb_url_open("/test_http/echo-json?race=Asgard", data=payload, headers=CT_JSON)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, '{"jsonrpc": "2.0", "id": 1234, "result": {"commander": "Thor"}}')
+
+ def test_echojson1_http_get_nodb(self):
+ res = self.nodb_url_open('/test_http/echo-json') # GET
+ self.assertEqual(res.status_code, 405)
+
+ @mute_logger('odoo.http')
+ def test_echojson2_http_post_nodb(self):
+ res = self.nodb_url_open('/test_http/echo-json', data={'race': 'Asgard'}) # POST
+ self.assertIn("Bad Request", res.text)
+
+
+@tagged('post_install', '-at_install')
+class TestHttpEchoReplyHttpWithDB(TestHttpBase):
+ def setUp(self):
+ super().setUp()
+ self.jackoneill = new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
+ self.authenticate('jackoneill', 'jackoneill')
+
+ def test_echohttp0_get_qs_db(self):
+ res = self.db_url_open('/test_http/echo-http-get?race=Asgard')
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, "{'race': 'Asgard'}")
+
+ def test_echohttp1_get_form_db(self):
+ res = self.db_url_open('/test_http/echo-http-get', data={'commander': 'Thor'})
+ self.assertEqual(res.status_code, 405)
+
+ def test_echohttp2_post_qs_db(self):
+ res = self.db_url_open('/test_http/echo-http-post?race=Asgard')
+ self.assertEqual(res.status_code, 405)
+
+ def test_echohttp3_post_qs_form_db(self):
+ res = self.db_url_open('/test_http/echo-http-post?race=Asgard', data={'commander': 'Thor'})
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, "{'race': 'Asgard', 'commander': 'Thor'}")
+
+ @mute_logger('odoo.http')
+ def test_echohttp4_post_json_db(self):
+ payload = json.dumps({'commander': 'Thor'})
+ res = self.db_url_open('/test_http/echo-http-post', data=payload, headers=CT_JSON)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, '{}')
+
+ @mute_logger('odoo.http')
+ def test_echohttp5_post_no_csrf(self):
+ res = self.db_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor'})
+ self.assertEqual(res.status_code, 400)
+ self.assertIn("Session expired (invalid CSRF token)", res.text)
+
+ @mute_logger('odoo.http')
+ def test_echohttp6_post_bad_csrf(self):
+ res = self.db_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor', 'csrf_token': 'bad token'})
+ self.assertEqual(res.status_code, 400)
+ self.assertIn("Session expired (invalid CSRF token)", res.text)
+
+ @mute_logger('odoo.http')
+ def test_echohttp7_post_good_csrf(self):
+ res = self.db_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor', 'csrf_token': Request.csrf_token(self)})
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, "{'race': 'Asgard', 'commander': 'Thor'}")
+
+
+@tagged('post_install', '-at_install')
+class TestHttpEchoReplyJsonWithDB(TestHttpBase):
+ def setUp(self):
+ super().setUp()
+ self.jackoneill = new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
+ self.authenticate('jackoneill', 'jackoneill')
+
+ def test_echojson0_qs_json_db(self):
+ payload = json.dumps({
+ 'jsonrpc': '2.0',
+ 'id': 1234,
+ 'params': {
+ 'commander': 'Thor',
+ },
+ })
+ res = self.db_url_open('/test_http/echo-json?race=Asgard', data=payload, headers=CT_JSON)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, '{"jsonrpc": "2.0", "id": 1234, "result": {"commander": "Thor"}}')
+
+ def test_echojson1_http_get_db(self):
+ res = self.db_url_open('/test_http/echo-json') # GET
+ self.assertEqual(res.status_code, 405)
+
+ @mute_logger('odoo.http')
+ def test_echojson2_http_post_db(self):
+ res = self.db_url_open('/test_http/echo-json', data={'race': 'Asgard'}) # POST
+ self.assertIn("Bad Request", res.text)
+
+ def test_echojson3_context_db(self):
+ payload = json.dumps({
+ "jsonrpc": "2.0",
+ "id": 0,
+ "params": {
+ "context": {
+ "name": "Thor"
+ },
+ "race": "Asgard",
+ },
+ })
+ res = self.db_url_open("/test_http/echo-json-context", data=payload, headers=CT_JSON)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, '{"jsonrpc": "2.0", "id": 0, "result": {"name": "Thor"}}')
diff --git a/odoo/addons/test_http/tests/test_error.py b/odoo/addons/test_http/tests/test_error.py
index b43971d48be..6bef1138b87 100644
--- a/odoo/addons/test_http/tests/test_error.py
+++ b/odoo/addons/test_http/tests/test_error.py
@@ -1,5 +1,8 @@
-from odoo.tools import mute_logger
-from .test_http import TestHttpBase
+import json
+from unittest.mock import patch
+from odoo.tools import config, mute_logger
+from odoo.addons.test_http.controllers import CT_JSON
+from .test_common import TestHttpBase
class TestHttpErrorHttp(TestHttpBase):
@@ -24,3 +27,62 @@ class TestHttpErrorHttp(TestHttpBase):
res = self.nodb_url_open('/test_http/hide_errors/context-manager?error=UserError')
self.assertEqual(res.status_code, 400, "UserError are not configured to be hidden, they should be kept as-is.")
self.assertIn("Walter is AFK", res.text, "The real UserError message should be kept")
+
+
+class TestHttpJsonError(TestHttpBase):
+
+ jsonrpc_error_structure = {
+ 'error': {
+ 'code': ...,
+ 'data': {
+ 'arguments': ...,
+ 'context': ...,
+ 'debug': ...,
+ 'message': ...,
+ 'name': ...,
+ },
+ 'message': ...,
+ },
+ 'id': ...,
+ 'jsonrpc': ...,
+ }
+
+ def assertIsErrorPayload(self, payload):
+ self.assertEqual(
+ set(payload),
+ set(self.jsonrpc_error_structure),
+ )
+ self.assertEqual(
+ set(payload['error']),
+ set(self.jsonrpc_error_structure['error']),
+ )
+ self.assertEqual(
+ set(payload['error']['data']),
+ set(self.jsonrpc_error_structure['error']['data']),
+ )
+
+
+ @mute_logger('odoo.http')
+ def test_errorjson0_value_error(self):
+ res = self.db_url_open('/test_http/json_value_error',
+ data=json.dumps({'jsonrpc': '2.0', 'id': 1234, 'params': {}}),
+ headers=CT_JSON
+ )
+ res.raise_for_status()
+
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.headers.get('Content-Type', ''), 'application/json; charset=utf-8')
+
+ payload = res.json()
+ self.assertIsErrorPayload(payload)
+
+ error_data = payload['error']['data']
+ self.assertEqual(error_data['name'], 'builtins.ValueError')
+ self.assertEqual(error_data['message'], 'Unknown destination')
+ self.assertEqual(error_data['arguments'], ['Unknown destination'])
+ self.assertEqual(error_data['context'], {})
+
+ @mute_logger('odoo.http')
+ def test_errorjson1_dev_mode_werkzeug(self):
+ with patch.object(config, 'options', {**config.options, 'dev_mode': 'werkzeug'}):
+ self.test_errorjson0_value_error()
diff --git a/odoo/addons/test_http/tests/test_greeting.py b/odoo/addons/test_http/tests/test_greeting.py
new file mode 100644
index 00000000000..125aae4ffca
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_greeting.py
@@ -0,0 +1,63 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from odoo.tests import tagged
+from odoo.tests.common import new_test_user
+
+from .test_common import TestHttpBase
+
+
+@tagged('post_install', '-at_install')
+class TestHttpGreeting(TestHttpBase):
+ def test_greeting0_matrix(self):
+ new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
+ test_matrix = [
+ # path, database, login, expected_code, expected_re_pattern
+ ('/test_http/greeting', False, None, 200, r"Tek'ma'te"),
+ ('/test_http/greeting', True, None, 200, r"Tek'ma'te"),
+ ('/test_http/greeting', True, 'public', 200, r"Tek'ma'te"),
+ ('/test_http/greeting', True, 'jackoneill', 200, r"Tek'ma'te"),
+ ('/test_http/greeting-none', False, None, 200, r"Tek'ma'te"),
+ ('/test_http/greeting-none', True, None, 200, r"Tek'ma'te"),
+ ('/test_http/greeting-none', True, 'public', 200, r"Tek'ma'te"),
+ ('/test_http/greeting-none', True, 'jackoneill', 200, r"Tek'ma'te"),
+ ('/test_http/greeting-public', False, None, 404, r"Not Found"),
+ ('/test_http/greeting-public', True, None, 200, r"Tek'ma'te"),
+ ('/test_http/greeting-public', True, 'public', 200, r"Tek'ma'te"),
+ ('/test_http/greeting-public', True, 'jackoneill', 200, r"Tek'ma'te"),
+ ('/test_http/greeting-user', False, None, 404, r"Not Found"),
+ ('/test_http/greeting-user', True, None, 303, r".*/web/login.*"),
+ ('/test_http/greeting-user', True, 'public', 303, r".*/web/login.*"),
+ ('/test_http/greeting-user', True, 'jackoneill', 200, r"Tek'ma'te"),
+ ]
+
+ for path, withdb, login, expected_code, expected_pattern in test_matrix:
+ with self.subTest(path=path, withdb=withdb, login=login):
+ if withdb:
+ if login == 'public':
+ self.authenticate(None, None)
+ elif login:
+ self.authenticate(login, login)
+ res = self.db_url_open(path, allow_redirects=False)
+ else:
+ res = self.nodb_url_open(path, allow_redirects=False)
+
+ self.assertEqual(res.status_code, expected_code)
+ self.assertRegex(res.text, expected_pattern)
+
+ if withdb and login:
+ self.logout(keep_db=False)
+
+ def test_greeting1_headers_nodb(self):
+ res = self.nodb_url_open('/test_http/greeting')
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
+ self.assertEqual(res.text, "Tek'ma'te")
+
+ def test_greeting2_headers_db(self):
+ new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
+ self.authenticate('jackoneill', 'jackoneill')
+ res = self.db_url_open('/test_http/greeting')
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
+ self.assertEqual(res.text, "Tek'ma'te")
diff --git a/odoo/addons/test_http/tests/test_http.py b/odoo/addons/test_http/tests/test_http.py
deleted file mode 100644
index 533c05d1cfc..00000000000
--- a/odoo/addons/test_http/tests/test_http.py
+++ /dev/null
@@ -1,634 +0,0 @@
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-import html
-import json
-from unittest.mock import patch
-from urllib.parse import urlparse
-from socket import gethostbyname
-
-import odoo
-from odoo.http import Request, Session
-from odoo.tests import tagged
-from odoo.tests.common import HOST, HttpCase, new_test_user
-from odoo.tools import config, file_open, mute_logger
-from odoo.tools.func import lazy_property
-from odoo.addons.test_http.controllers import CT_JSON
-from odoo.addons.test_http.utils import (
- MemoryGeoipResolver, MemorySessionStore, HtmlTokenizer
-)
-
-GEOIP_ODOO_FARM_2 = {
- 'city': 'Ramillies',
- 'country_code': 'BE',
- 'country_name': 'Belgium',
- 'latitude': 50.6314,
- 'longitude': 4.8573,
- 'region': 'WAL',
- 'time_zone': 'Europe/Brussels'
-}
-
-
-class TestHttpBase(HttpCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.addClassCleanup(lazy_property.reset_all, odoo.http.root)
- cls.classPatch(odoo.conf, 'server_wide_modules', ['base', 'web', 'test_http'])
- lazy_property.reset_all(odoo.http.root)
- cls.classPatch(odoo.http.root, 'session_store', MemorySessionStore(session_class=Session))
- cls.classPatch(odoo.http.root, 'geoip_resolver', MemoryGeoipResolver())
-
- def setUp(self):
- super().setUp()
- odoo.http.root.session_store.store.clear()
-
- def db_url_open(self, url, *args, allow_redirects=False, **kwargs):
- return self.url_open(url, *args, allow_redirects=allow_redirects, **kwargs)
-
- def nodb_url_open(self, url, *args, allow_redirects=False, **kwargs):
- with patch('odoo.http.db_list') as db_list, \
- patch('odoo.http.db_filter') as db_filter:
- db_list.return_value = []
- db_filter.return_value = []
- return self.url_open(url, *args, allow_redirects=allow_redirects, **kwargs)
-
- def multidb_url_open(self, url, *args, allow_redirects=False, dblist=(), **kwargs):
- dblist = dblist or self.db_list
- assert len(dblist) >= 2, "There should be at least 2 databases"
- with patch('odoo.http.db_list') as db_list, \
- patch('odoo.http.db_filter') as db_filter, \
- patch('odoo.http.Registry') as Registry:
- db_list.return_value = dblist
- db_filter.side_effect = lambda dbs, host=None: [db for db in dbs if db in dblist]
- Registry.return_value = self.registry
- return self.url_open(url, *args, allow_redirects=allow_redirects, **kwargs)
-
-
-@tagged('post_install', '-at_install')
-class TestHttpGreeting(TestHttpBase):
- def test_greeting0_matrix(self):
- new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
- test_matrix = [
- # path, database, login, expected_code, expected_re_pattern
- ('/test_http/greeting', False, None, 200, r"Tek'ma'te"),
- ('/test_http/greeting', True, None, 200, r"Tek'ma'te"),
- ('/test_http/greeting', True, 'public', 200, r"Tek'ma'te"),
- ('/test_http/greeting', True, 'jackoneill', 200, r"Tek'ma'te"),
- ('/test_http/greeting-none', False, None, 200, r"Tek'ma'te"),
- ('/test_http/greeting-none', True, None, 200, r"Tek'ma'te"),
- ('/test_http/greeting-none', True, 'public', 200, r"Tek'ma'te"),
- ('/test_http/greeting-none', True, 'jackoneill', 200, r"Tek'ma'te"),
- ('/test_http/greeting-public', False, None, 404, r"Not Found"),
- ('/test_http/greeting-public', True, None, 200, r"Tek'ma'te"),
- ('/test_http/greeting-public', True, 'public', 200, r"Tek'ma'te"),
- ('/test_http/greeting-public', True, 'jackoneill', 200, r"Tek'ma'te"),
- ('/test_http/greeting-user', False, None, 404, r"Not Found"),
- ('/test_http/greeting-user', True, None, 303, r".*/web/login.*"),
- ('/test_http/greeting-user', True, 'public', 303, r".*/web/login.*"),
- ('/test_http/greeting-user', True, 'jackoneill', 200, r"Tek'ma'te"),
- ]
-
- for path, withdb, login, expected_code, expected_pattern in test_matrix:
- with self.subTest(path=path, withdb=withdb, login=login):
- if withdb:
- if login == 'public':
- self.authenticate(None, None)
- elif login:
- self.authenticate(login, login)
- res = self.db_url_open(path, allow_redirects=False)
- else:
- res = self.nodb_url_open(path, allow_redirects=False)
-
- self.assertEqual(res.status_code, expected_code)
- self.assertRegex(res.text, expected_pattern)
-
- if withdb and login:
- self.logout(keep_db=False)
-
- def test_greeting1_headers_nodb(self):
- res = self.nodb_url_open('/test_http/greeting')
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
- self.assertEqual(res.text, "Tek'ma'te")
-
- def test_greeting2_headers_db(self):
- new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
- self.authenticate('jackoneill', 'jackoneill')
- res = self.db_url_open('/test_http/greeting')
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
- self.assertEqual(res.text, "Tek'ma'te")
-
-
-@tagged('post_install', '-at_install')
-class TestHttpStatic(TestHttpBase):
- def test_static0_png_image(self):
- res = self.nodb_url_open("/test_http/static/src/img/gizeh.png")
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.headers.get('Content-Length'), '814')
- self.assertEqual(res.headers.get('Content-Type'), 'image/png')
- cache_control = set(res.headers.get('Cache-Control', '').split(', '))
- self.assertEqual(cache_control, {'public', 'max-age=604800'}) # one week
- with file_open('test_http/static/src/img/gizeh.png', 'rb') as file:
- self.assertEqual(res.content, file.read())
-
- def test_static1_svg_image(self):
- res = self.nodb_url_open("/test_http/static/src/img/gizeh.svg")
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.headers.get('Content-Length'), '1529')
- self.assertEqual(res.headers.get('Content-Type'), 'image/svg+xml; charset=utf-8')
- cache_control = set(res.headers.get('Cache-Control', '').split(', '))
- self.assertEqual(cache_control, {'public', 'max-age=604800'}) # one week
- with file_open('test_http/static/src/img/gizeh.svg', 'rb') as file:
- self.assertEqual(res.content, file.read())
-
- def test_static2_not_found(self):
- res = self.nodb_url_open("/test_http/static/i-dont-exist")
- self.assertEqual(res.status_code, 404)
-
- def test_static3_attachment(self):
- with file_open('test_http/static/src/img/gizeh.svg', 'rb') as file:
- content = file.read()
-
- attachment = self.env['ir.attachment'].create({
- 'name': 'point_of_origin.svg',
- 'type': 'binary',
- 'raw': content,
- 'res_model': 'test_http.stargate',
- 'res_id': self.ref('test_http.earth'),
- })
- attachment['url'] = f'/test_http/{attachment["checksum"]}'
-
- res = self.db_url_open(attachment['url'])
- self.assertEqual(res.headers.get('Content-Length'), '1529')
- self.assertEqual(res.headers.get('Content-Type'), 'image/svg+xml; charset=utf-8')
- self.assertEqual(res.headers.get('Content-Security-Policy'), "default-src 'none'")
- self.assertEqual(res.content, content)
-
-
-@tagged('post_install', '-at_install')
-class TestHttpEchoReplyHttpNoDB(TestHttpBase):
- def test_echohttp0_get_qs_nodb(self):
- res = self.nodb_url_open('/test_http/echo-http-get?race=Asgard')
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, "{'race': 'Asgard'}")
-
- def test_echohttp1_get_form_nodb(self):
- res = self.nodb_url_open('/test_http/echo-http-get', data={'commander': 'Thor'})
- self.assertEqual(res.status_code, 405)
-
- def test_echohttp2_post_qs_nodb(self):
- res = self.nodb_url_open('/test_http/echo-http-post?race=Asgard')
- self.assertEqual(res.status_code, 405)
-
- def test_echohttp3_post_qs_form_nodb(self):
- res = self.nodb_url_open('/test_http/echo-http-post?race=Asgard', data={'commander': 'Thor'})
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, "{'race': 'Asgard', 'commander': 'Thor'}")
-
- @mute_logger('odoo.http')
- def test_echohttp4_post_json_nodb(self):
- payload = json.dumps({'commander': 'Thor'})
- res = self.nodb_url_open('/test_http/echo-http-post', data=payload, headers=CT_JSON)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, '{}')
-
- def test_echohttp5_post_csrf(self):
- res = self.nodb_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor'})
- self.assertEqual(res.status_code, 303)
- self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/web/database/selector')
-
- def test_echohttp6_json_over_http(self):
- payload = json.dumps({'commander': 'Thor'})
- res = self.nodb_url_open('/test_http/echo-json-over-http', data=payload, headers=CT_JSON)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, payload)
- mimetype = res.headers['Content-Type'].partition(';')[0]
- self.assertEqual(mimetype, 'application/json')
-
-
-@tagged('post_install', '-at_install')
-class TestHttpEchoReplyJsonNoDB(TestHttpBase):
- def test_echojson0_qs_json_nodb(self):
- payload = json.dumps({
- 'jsonrpc': '2.0',
- 'id': 1234,
- 'params': {
- 'commander': 'Thor',
- },
- })
- res = self.nodb_url_open("/test_http/echo-json?race=Asgard", data=payload, headers=CT_JSON)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, '{"jsonrpc": "2.0", "id": 1234, "result": {"commander": "Thor"}}')
-
- def test_echojson1_http_get_nodb(self):
- res = self.nodb_url_open('/test_http/echo-json') # GET
- self.assertEqual(res.status_code, 405)
-
- @mute_logger('odoo.http')
- def test_echojson2_http_post_nodb(self):
- res = self.nodb_url_open('/test_http/echo-json', data={'race': 'Asgard'}) # POST
- self.assertIn("Bad Request", res.text)
-
-
-@tagged('post_install', '-at_install')
-class TestHttpEchoReplyHttpWithDB(TestHttpBase):
- def setUp(self):
- super().setUp()
- self.jackoneill = new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
- self.authenticate('jackoneill', 'jackoneill')
-
- def test_echohttp0_get_qs_db(self):
- res = self.db_url_open('/test_http/echo-http-get?race=Asgard')
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, "{'race': 'Asgard'}")
-
- def test_echohttp1_get_form_db(self):
- res = self.db_url_open('/test_http/echo-http-get', data={'commander': 'Thor'})
- self.assertEqual(res.status_code, 405)
-
- def test_echohttp2_post_qs_db(self):
- res = self.db_url_open('/test_http/echo-http-post?race=Asgard')
- self.assertEqual(res.status_code, 405)
-
- def test_echohttp3_post_qs_form_db(self):
- res = self.db_url_open('/test_http/echo-http-post?race=Asgard', data={'commander': 'Thor'})
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, "{'race': 'Asgard', 'commander': 'Thor'}")
-
- @mute_logger('odoo.http')
- def test_echohttp4_post_json_db(self):
- payload = json.dumps({'commander': 'Thor'})
- res = self.db_url_open('/test_http/echo-http-post', data=payload, headers=CT_JSON)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, '{}')
-
- @mute_logger('odoo.http')
- def test_echohttp5_post_no_csrf(self):
- res = self.db_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor'})
- self.assertEqual(res.status_code, 400)
- self.assertIn("Session expired (invalid CSRF token)", res.text)
-
- @mute_logger('odoo.http')
- def test_echohttp6_post_bad_csrf(self):
- res = self.db_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor', 'csrf_token': 'bad token'})
- self.assertEqual(res.status_code, 400)
- self.assertIn("Session expired (invalid CSRF token)", res.text)
-
- @mute_logger('odoo.http')
- def test_echohttp7_post_good_csrf(self):
- res = self.db_url_open('/test_http/echo-http-csrf?race=Asgard', data={'commander': 'Thor', 'csrf_token': Request.csrf_token(self)})
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, "{'race': 'Asgard', 'commander': 'Thor'}")
-
-
-
-@tagged('post_install', '-at_install')
-class TestHttpEchoReplyJsonWithDB(TestHttpBase):
- def setUp(self):
- super().setUp()
- self.jackoneill = new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
- self.authenticate('jackoneill', 'jackoneill')
-
- def test_echojson0_qs_json_db(self):
- payload = json.dumps({
- 'jsonrpc': '2.0',
- 'id': 1234,
- 'params': {
- 'commander': 'Thor',
- },
- })
- res = self.db_url_open('/test_http/echo-json?race=Asgard', data=payload, headers=CT_JSON)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, '{"jsonrpc": "2.0", "id": 1234, "result": {"commander": "Thor"}}')
-
- def test_echojson1_http_get_db(self):
- res = self.db_url_open('/test_http/echo-json') # GET
- self.assertEqual(res.status_code, 405)
-
- @mute_logger('odoo.http')
- def test_echojson2_http_post_db(self):
- res = self.db_url_open('/test_http/echo-json', data={'race': 'Asgard'}) # POST
- self.assertIn("Bad Request", res.text)
-
- def test_echojson3_context_db(self):
- payload = json.dumps({
- "jsonrpc": "2.0",
- "id": 0,
- "params": {
- "context": {
- "name": "Thor"
- },
- "race": "Asgard",
- },
- })
- res = self.db_url_open("/test_http/echo-json-context", data=payload, headers=CT_JSON)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, '{"jsonrpc": "2.0", "id": 0, "result": {"name": "Thor"}}')
-
-
-@tagged('post_install', '-at_install')
-class TestHttpModels(TestHttpBase):
- def setUp(self):
- super().setUp()
- self.jackoneill = new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
- self.authenticate('jackoneill', 'jackoneill')
-
- def test_models0_galaxy_ok(self):
- milky_way = self.env.ref('test_http.milky_way')
-
- res = self.url_open(f"/test_http/{milky_way.id}")
-
- self.assertEqual(res.status_code, 200)
- self.assertEqual(
- HtmlTokenizer.tokenize(res.text),
- HtmlTokenizer.tokenize('''\
- Milky Way
-
- ''')
- )
-
- @mute_logger('odoo.http')
- def test_models1_galaxy_ko(self):
- res = self.url_open("/test_http/404") # unknown galaxy
- self.assertEqual(res.status_code, 400)
- self.assertIn('The Ancients did not settle there.', res.text)
-
- def test_models2_stargate_ok(self):
- milky_way = self.env.ref('test_http.milky_way')
- earth = self.env.ref('test_http.earth')
-
- res = self.url_open(f'/test_http/{milky_way.id}/{earth.id}')
-
- self.assertEqual(res.status_code, 200)
- self.assertEqual(
- HtmlTokenizer.tokenize(res.text),
- HtmlTokenizer.tokenize('''\
-
- name Earth
- address sq5Abt
- sgc_designation P4X-126
-
- ''')
- )
-
- @mute_logger('odoo.http')
- def test_models3_stargate_ko(self):
- milky_way = self.env.ref('test_http.milky_way')
- res = self.url_open(f'/test_http/{milky_way.id}/9999') # unknown gate
- self.assertEqual(res.status_code, 400)
- self.assertIn("The goa'uld destroyed the gate", html.unescape(res.text))
-
-
-@tagged('post_install', '-at_install')
-class TestHttpMisc(TestHttpBase):
- def test_misc0_redirect(self):
- res = self.nodb_url_open('/test_http//greeting')
- self.assertEqual(res.status_code, 301)
- self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/test_http/greeting')
-
- def test_misc1_reverse_proxy(self):
- # client <-> reverse-proxy <-> odoo
- client_ip = '127.0.0.16'
- reverseproxy_ip = gethostbyname(HOST)
- host = 'mycompany.odoo.com'
-
- headers = {
- 'Host': '',
- 'X-Forwarded-For': client_ip,
- 'X-Forwarded-Host': host,
- 'X-Forwarded-Proto': 'https'
- }
-
- # Don't trust client-sent forwarded headers
- with patch.object(config, 'options', {**config.options, 'proxy_mode': False}):
- res = self.nodb_url_open('/test_http/wsgi_environ', headers=headers)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.json()['REMOTE_ADDR'], reverseproxy_ip)
- self.assertEqual(res.json()['HTTP_HOST'], '')
-
- # Trust proxy-sent forwarded headers
- with patch.object(config, 'options', {**config.options, 'proxy_mode': True}):
- res = self.nodb_url_open('/test_http/wsgi_environ', headers=headers)
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.json()['REMOTE_ADDR'], client_ip)
- self.assertEqual(res.json()['HTTP_HOST'], host)
-
-
-@tagged('post_install', '-at_install')
-class TestHttpCors(TestHttpBase):
- def test_cors0_http_default(self):
- res_opt = self.opener.options(f'{self.base_url()}/test_http/cors_http_default', timeout=10, allow_redirects=False)
- self.assertIn(res_opt.status_code, (200, 204))
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Origin'), '*')
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Methods'), 'GET, POST')
- self.assertEqual(res_opt.headers.get('Access-Control-Max-Age'), '86400') # one day
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Headers'), 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
-
- res_get = self.url_open('/test_http/cors_http_default')
- self.assertEqual(res_get.status_code, 200)
- self.assertEqual(res_get.headers.get('Access-Control-Allow-Origin'), '*')
- self.assertEqual(res_get.headers.get('Access-Control-Allow-Methods'), 'GET, POST')
-
- def test_cors1_http_methods(self):
- res_opt = self.opener.options(f'{self.base_url()}/test_http/cors_http_methods', timeout=10, allow_redirects=False)
- self.assertIn(res_opt.status_code, (200, 204))
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Origin'), '*')
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Methods'), 'GET, PUT')
- self.assertEqual(res_opt.headers.get('Access-Control-Max-Age'), '86400') # one day
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Headers'), 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
-
- res_post = self.url_open('/test_http/cors_http_methods')
- self.assertEqual(res_post.status_code, 200)
- self.assertEqual(res_post.headers.get('Access-Control-Allow-Origin'), '*')
- self.assertEqual(res_post.headers.get('Access-Control-Allow-Methods'), 'GET, PUT')
-
- def test_cors2_json(self):
- res_opt = self.opener.options(f'{self.base_url()}/test_http/cors_json', timeout=10, allow_redirects=False)
- self.assertIn(res_opt.status_code, (200, 204), res_opt.text)
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Origin'), '*')
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Methods'), 'POST')
- self.assertEqual(res_opt.headers.get('Access-Control-Max-Age'), '86400') # one day
- self.assertEqual(res_opt.headers.get('Access-Control-Allow-Headers'), 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
-
- res_post = self.url_open('/test_http/cors_json', data=json.dumps({'params': {}}), headers=CT_JSON)
- self.assertEqual(res_post.status_code, 200)
- self.assertEqual(res_post.headers.get('Access-Control-Allow-Origin'), '*')
- self.assertEqual(res_post.headers.get('Access-Control-Allow-Methods'), 'POST')
-
-@tagged('post_install', '-at_install')
-class TestHttpEnsureDb(TestHttpBase):
- def setUp(self):
- super().setUp()
- self.db_list = ['db0', 'db1']
-
- def test_ensure_db0_db_selector(self):
- res = self.multidb_url_open('/test_http/ensure_db')
- res.raise_for_status()
- self.assertEqual(res.status_code, 303)
- self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/web/database/selector')
-
- def test_ensure_db1_grant_db(self):
- res = self.multidb_url_open('/test_http/ensure_db?db=db0', timeout=10000)
- res.raise_for_status()
- self.assertEqual(res.status_code, 302)
- self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/test_http/ensure_db')
- self.assertEqual(odoo.http.root.session_store.get(res.cookies['session_id']).db, 'db0')
-
- # follow the redirection
- res = self.multidb_url_open('/test_http/ensure_db')
- res.raise_for_status()
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, 'db0')
-
- def test_ensure_db2_use_session_db(self):
- session = self.authenticate(None, None)
- session.db = 'db0'
- odoo.http.root.session_store.save(session)
-
- res = self.multidb_url_open('/test_http/ensure_db')
- res.raise_for_status()
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, 'db0')
-
- def test_ensure_db3_change_db(self):
- session = self.authenticate(None, None)
- session.db = 'db0'
- odoo.http.root.session_store.save(session)
-
- res = self.multidb_url_open('/test_http/ensure_db?db=db1')
- res.raise_for_status()
- self.assertEqual(res.status_code, 302)
- self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/test_http/ensure_db')
-
- new_session = odoo.http.root.session_store.get(res.cookies['session_id'])
- self.assertNotEqual(session.sid, new_session.sid)
- self.assertEqual(new_session.db, 'db1')
- self.assertEqual(new_session.uid, None)
-
- # follow redirection
- self.opener.cookies['session_id'] = new_session.sid
- res = self.multidb_url_open('/test_http/ensure_db')
- res.raise_for_status()
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.text, 'db1')
-
-
-class TestHttpSession(TestHttpBase):
-
- @mute_logger('odoo.http') # greeting_none called ignoring args {'debug'}
- def test_session0_debug_mode(self):
- session = self.authenticate(None, None)
- self.assertEqual(session.debug, '')
- self.db_url_open('/test_http/greeting').raise_for_status()
- self.assertEqual(session.debug, '')
- self.db_url_open('/test_http/greeting?debug=1').raise_for_status()
- self.assertEqual(session.debug, '1')
- self.db_url_open('/test_http/greeting').raise_for_status()
- self.assertEqual(session.debug, '1')
- self.db_url_open('/test_http/greeting?debug=').raise_for_status()
- self.assertEqual(session.debug, '')
-
- def test_session1_default_session(self):
- # The default session should not be saved on the filestore.
- with patch.object(odoo.http.root.session_store, 'save') as mock_save:
- res = self.db_url_open('/test_http/greeting')
- res.raise_for_status()
- try:
- mock_save.assert_not_called()
- except AssertionError as exc:
- msg = f'save() was called with args: {mock_save.call_args}'
- raise AssertionError(msg) from exc
-
- def test_session2_geoip(self):
- real_save = odoo.http.root.session_store.save
- with patch.object(odoo.http.root.geoip_resolver, 'resolve') as mock_resolve,\
- patch.object(odoo.http.root.session_store, 'save') as mock_save:
- mock_resolve.return_value = GEOIP_ODOO_FARM_2
- mock_save.side_effect = real_save
-
- # Geoip is lazy: it should be computed only when necessary.
- self.nodb_url_open('/test_http/greeting').raise_for_status()
- mock_resolve.assert_not_called()
-
- # Geoip is like the defaut session: the session should not
- # be stored only due to geoip.
- mock_resolve.reset_mock()
- mock_save.reset_mock()
- res = self.nodb_url_open('/test_http/geoip')
- res.raise_for_status()
- self.assertEqual(res.text, str(GEOIP_ODOO_FARM_2))
- mock_save.assert_not_called()
-
- # Geoip is cached on the session: we shouldn't geolocate the
- # same ip multiple times.
- mock_resolve.reset_mock()
- mock_save.reset_mock()
- self.nodb_url_open('/test_http/save_session').raise_for_status()
- self.nodb_url_open('/test_http/geoip').raise_for_status()
- res = self.nodb_url_open('/test_http/geoip')
- res.raise_for_status()
- self.assertEqual(res.text, str(GEOIP_ODOO_FARM_2))
- mock_resolve.assert_called_once()
-
-class TestHttpJsonError(TestHttpBase):
-
- jsonrpc_error_structure = {
- 'error': {
- 'code': ...,
- 'data': {
- 'arguments': ...,
- 'context': ...,
- 'debug': ...,
- 'message': ...,
- 'name': ...,
- },
- 'message': ...,
- },
- 'id': ...,
- 'jsonrpc': ...,
- }
-
- def assertIsErrorPayload(self, payload):
- self.assertEqual(
- set(payload),
- set(self.jsonrpc_error_structure),
- )
- self.assertEqual(
- set(payload['error']),
- set(self.jsonrpc_error_structure['error']),
- )
- self.assertEqual(
- set(payload['error']['data']),
- set(self.jsonrpc_error_structure['error']['data']),
- )
-
-
- @mute_logger('odoo.http')
- def test_errorjson0_value_error(self):
- res = self.db_url_open('/test_http/json_value_error',
- data=json.dumps({'jsonrpc': '2.0', 'id': 1234, 'params': {}}),
- headers=CT_JSON
- )
- res.raise_for_status()
-
- self.assertEqual(res.status_code, 200)
- self.assertEqual(res.headers.get('Content-Type', ''), 'application/json; charset=utf-8')
-
- payload = res.json()
- self.assertIsErrorPayload(payload)
-
- error_data = payload['error']['data']
- self.assertEqual(error_data['name'], 'builtins.ValueError')
- self.assertEqual(error_data['message'], 'Unknown destination')
- self.assertEqual(error_data['arguments'], ['Unknown destination'])
- self.assertEqual(error_data['context'], {})
-
- @mute_logger('odoo.http')
- def test_errorjson1_dev_mode_werkzeug(self):
- with patch.object(config, 'options', {**config.options, 'dev_mode': 'werkzeug'}):
- self.test_errorjson0_value_error()
diff --git a/odoo/addons/test_http/tests/test_misc.py b/odoo/addons/test_http/tests/test_misc.py
new file mode 100644
index 00000000000..572a0b13cc5
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_misc.py
@@ -0,0 +1,164 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+from socket import gethostbyname
+from unittest.mock import patch
+from urllib.parse import urlparse
+
+import odoo
+from odoo.http import root
+from odoo.tests import tagged
+from odoo.tests.common import HOST
+from odoo.tools import config, file_path
+from odoo.addons.test_http.controllers import CT_JSON
+
+from .test_common import TestHttpBase
+
+
+@tagged('post_install', '-at_install')
+class TestHttpMisc(TestHttpBase):
+ def test_misc0_redirect(self):
+ res = self.nodb_url_open('/test_http//greeting')
+ self.assertEqual(res.status_code, 301)
+ self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/test_http/greeting')
+
+ def test_misc1_reverse_proxy(self):
+ # client <-> reverse-proxy <-> odoo
+ client_ip = '127.0.0.16'
+ reverseproxy_ip = gethostbyname(HOST)
+ host = 'mycompany.odoo.com'
+
+ headers = {
+ 'Host': '',
+ 'X-Forwarded-For': client_ip,
+ 'X-Forwarded-Host': host,
+ 'X-Forwarded-Proto': 'https'
+ }
+
+ # Don't trust client-sent forwarded headers
+ with patch.object(config, 'options', {**config.options, 'proxy_mode': False}):
+ res = self.nodb_url_open('/test_http/wsgi_environ', headers=headers)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.json()['REMOTE_ADDR'], reverseproxy_ip)
+ self.assertEqual(res.json()['HTTP_HOST'], '')
+
+ # Trust proxy-sent forwarded headers
+ with patch.object(config, 'options', {**config.options, 'proxy_mode': True}):
+ res = self.nodb_url_open('/test_http/wsgi_environ', headers=headers)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.json()['REMOTE_ADDR'], client_ip)
+ self.assertEqual(res.json()['HTTP_HOST'], host)
+
+ def test_misc3_is_static_file(self):
+ uri = 'test_http/static/src/img/gizeh.png'
+ path = file_path(uri)
+
+ # Valid URLs
+ self.assertEqual(root.get_static_file(f'/{uri}'), path, "Valid file")
+ self.assertEqual(root.get_static_file(f'odoo.com/{uri}', host='odoo.com'), path, "Valid file with valid host")
+ self.assertEqual(root.get_static_file(f'http://odoo.com/{uri}', host='odoo.com'), path, "Valid file with valid host")
+
+ # Invalid URLs
+ self.assertIsNone(root.get_static_file('/test_http/i-dont-exist'), "File doesn't exist")
+ self.assertIsNone(root.get_static_file('/test_http/__manifest__.py'), "File is not static")
+ self.assertIsNone(root.get_static_file(f'odoo.com/{uri}'), "No host allowed")
+ self.assertIsNone(root.get_static_file(f'http://odoo.com/{uri}'), "No host allowed")
+
+
+@tagged('post_install', '-at_install')
+class TestHttpCors(TestHttpBase):
+ def test_cors0_http_default(self):
+ res_opt = self.opener.options(f'{self.base_url()}/test_http/cors_http_default', timeout=10, allow_redirects=False)
+ self.assertIn(res_opt.status_code, (200, 204))
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Origin'), '*')
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Methods'), 'GET, POST')
+ self.assertEqual(res_opt.headers.get('Access-Control-Max-Age'), '86400') # one day
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Headers'), 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
+
+ res_get = self.url_open('/test_http/cors_http_default')
+ self.assertEqual(res_get.status_code, 200)
+ self.assertEqual(res_get.headers.get('Access-Control-Allow-Origin'), '*')
+ self.assertEqual(res_get.headers.get('Access-Control-Allow-Methods'), 'GET, POST')
+
+ def test_cors1_http_methods(self):
+ res_opt = self.opener.options(f'{self.base_url()}/test_http/cors_http_methods', timeout=10, allow_redirects=False)
+ self.assertIn(res_opt.status_code, (200, 204))
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Origin'), '*')
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Methods'), 'GET, PUT')
+ self.assertEqual(res_opt.headers.get('Access-Control-Max-Age'), '86400') # one day
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Headers'), 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
+
+ res_post = self.url_open('/test_http/cors_http_methods')
+ self.assertEqual(res_post.status_code, 200)
+ self.assertEqual(res_post.headers.get('Access-Control-Allow-Origin'), '*')
+ self.assertEqual(res_post.headers.get('Access-Control-Allow-Methods'), 'GET, PUT')
+
+ def test_cors2_json(self):
+ res_opt = self.opener.options(f'{self.base_url()}/test_http/cors_json', timeout=10, allow_redirects=False)
+ self.assertIn(res_opt.status_code, (200, 204), res_opt.text)
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Origin'), '*')
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Methods'), 'POST')
+ self.assertEqual(res_opt.headers.get('Access-Control-Max-Age'), '86400') # one day
+ self.assertEqual(res_opt.headers.get('Access-Control-Allow-Headers'), 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
+
+ res_post = self.url_open('/test_http/cors_json', data=json.dumps({'params': {}}), headers=CT_JSON)
+ self.assertEqual(res_post.status_code, 200)
+ self.assertEqual(res_post.headers.get('Access-Control-Allow-Origin'), '*')
+ self.assertEqual(res_post.headers.get('Access-Control-Allow-Methods'), 'POST')
+
+
+@tagged('post_install', '-at_install')
+class TestHttpEnsureDb(TestHttpBase):
+ def setUp(self):
+ super().setUp()
+ self.db_list = ['db0', 'db1']
+
+ def test_ensure_db0_db_selector(self):
+ res = self.multidb_url_open('/test_http/ensure_db')
+ res.raise_for_status()
+ self.assertEqual(res.status_code, 303)
+ self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/web/database/selector')
+
+ def test_ensure_db1_grant_db(self):
+ res = self.multidb_url_open('/test_http/ensure_db?db=db0', timeout=10000)
+ res.raise_for_status()
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/test_http/ensure_db')
+ self.assertEqual(odoo.http.root.session_store.get(res.cookies['session_id']).db, 'db0')
+
+ # follow the redirection
+ res = self.multidb_url_open('/test_http/ensure_db')
+ res.raise_for_status()
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, 'db0')
+
+ def test_ensure_db2_use_session_db(self):
+ session = self.authenticate(None, None)
+ session.db = 'db0'
+ odoo.http.root.session_store.save(session)
+
+ res = self.multidb_url_open('/test_http/ensure_db')
+ res.raise_for_status()
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, 'db0')
+
+ def test_ensure_db3_change_db(self):
+ session = self.authenticate(None, None)
+ session.db = 'db0'
+ odoo.http.root.session_store.save(session)
+
+ res = self.multidb_url_open('/test_http/ensure_db?db=db1')
+ res.raise_for_status()
+ self.assertEqual(res.status_code, 302)
+ self.assertEqual(urlparse(res.headers.get('Location', '')).path, '/test_http/ensure_db')
+
+ new_session = odoo.http.root.session_store.get(res.cookies['session_id'])
+ self.assertNotEqual(session.sid, new_session.sid)
+ self.assertEqual(new_session.db, 'db1')
+ self.assertEqual(new_session.uid, None)
+
+ # follow redirection
+ res = self.multidb_url_open('/test_http/ensure_db')
+ res.raise_for_status()
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.text, 'db1')
diff --git a/odoo/addons/test_http/tests/test_models.py b/odoo/addons/test_http/tests/test_models.py
new file mode 100644
index 00000000000..40521d88c51
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_models.py
@@ -0,0 +1,66 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from odoo.tests import tagged
+from odoo.tests.common import new_test_user
+from odoo.tools import mute_logger
+from odoo.addons.test_http.utils import HtmlTokenizer
+
+from .test_common import TestHttpBase
+
+
+@tagged('post_install', '-at_install')
+class TestHttpModels(TestHttpBase):
+ def setUp(self):
+ super().setUp()
+ self.jackoneill = new_test_user(self.env, 'jackoneill', context={'lang': 'en_US'})
+ self.authenticate('jackoneill', 'jackoneill')
+
+ def test_models0_galaxy_ok(self):
+ milky_way = self.env.ref('test_http.milky_way')
+
+ res = self.url_open(f"/test_http/{milky_way.id}")
+
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(
+ HtmlTokenizer.tokenize(res.text),
+ HtmlTokenizer.tokenize('''\
+ Milky Way
+
+ ''')
+ )
+
+ @mute_logger('odoo.http')
+ def test_models1_galaxy_ko(self):
+ res = self.url_open("/test_http/404") # unknown galaxy
+ self.assertEqual(res.status_code, 400)
+ self.assertIn('The Ancients did not settle there.', res.text)
+
+ def test_models2_stargate_ok(self):
+ milky_way = self.env.ref('test_http.milky_way')
+ earth = self.env.ref('test_http.earth')
+
+ res = self.url_open(f'/test_http/{milky_way.id}/{earth.id}')
+
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(
+ HtmlTokenizer.tokenize(res.text),
+ HtmlTokenizer.tokenize('''\
+
+ name Earth
+ address sq5Abt
+ sgc_designation P4X-126
+
+ ''')
+ )
+
+ @mute_logger('odoo.http')
+ def test_models3_stargate_ko(self):
+ milky_way = self.env.ref('test_http.milky_way')
+ res = self.url_open(f'/test_http/{milky_way.id}/9999') # unknown gate
+ self.assertEqual(res.status_code, 400)
+ self.assertIn("The goa'uld destroyed the gate", res.text)
diff --git a/odoo/addons/test_http/tests/test_session.py b/odoo/addons/test_http/tests/test_session.py
new file mode 100644
index 00000000000..f67967afe59
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_session.py
@@ -0,0 +1,76 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from unittest.mock import patch
+
+import odoo
+from odoo.tools import mute_logger
+from .test_common import TestHttpBase
+
+
+GEOIP_ODOO_FARM_2 = {
+ 'city': 'Ramillies',
+ 'country_code': 'BE',
+ 'country_name': 'Belgium',
+ 'latitude': 50.6314,
+ 'longitude': 4.8573,
+ 'region': 'WAL',
+ 'time_zone': 'Europe/Brussels'
+}
+
+
+class TestHttpSession(TestHttpBase):
+
+ @mute_logger('odoo.http') # greeting_none called ignoring args {'debug'}
+ def test_session0_debug_mode(self):
+ session = self.authenticate(None, None)
+ self.assertEqual(session.debug, '')
+ self.db_url_open('/test_http/greeting').raise_for_status()
+ self.assertEqual(session.debug, '')
+ self.db_url_open('/test_http/greeting?debug=1').raise_for_status()
+ self.assertEqual(session.debug, '1')
+ self.db_url_open('/test_http/greeting').raise_for_status()
+ self.assertEqual(session.debug, '1')
+ self.db_url_open('/test_http/greeting?debug=').raise_for_status()
+ self.assertEqual(session.debug, '')
+
+ def test_session1_default_session(self):
+ # The default session should not be saved on the filestore.
+ with patch.object(odoo.http.root.session_store, 'save') as mock_save:
+ res = self.db_url_open('/test_http/greeting')
+ res.raise_for_status()
+ try:
+ mock_save.assert_not_called()
+ except AssertionError as exc:
+ msg = f'save() was called with args: {mock_save.call_args}'
+ raise AssertionError(msg) from exc
+
+ def test_session2_geoip(self):
+ real_save = odoo.http.root.session_store.save
+ with patch.object(odoo.http.root.geoip_resolver, 'resolve') as mock_resolve,\
+ patch.object(odoo.http.root.session_store, 'save') as mock_save:
+ mock_resolve.return_value = GEOIP_ODOO_FARM_2
+ mock_save.side_effect = real_save
+
+ # Geoip is lazy: it should be computed only when necessary.
+ self.nodb_url_open('/test_http/greeting').raise_for_status()
+ mock_resolve.assert_not_called()
+
+ # Geoip is like the defaut session: the session should not
+ # be stored only due to geoip.
+ mock_resolve.reset_mock()
+ mock_save.reset_mock()
+ res = self.nodb_url_open('/test_http/geoip')
+ res.raise_for_status()
+ self.assertEqual(res.text, str(GEOIP_ODOO_FARM_2))
+ mock_save.assert_not_called()
+
+ # Geoip is cached on the session: we shouldn't geolocate the
+ # same ip multiple times.
+ mock_resolve.reset_mock()
+ mock_save.reset_mock()
+ self.nodb_url_open('/test_http/save_session').raise_for_status()
+ self.nodb_url_open('/test_http/geoip').raise_for_status()
+ res = self.nodb_url_open('/test_http/geoip')
+ res.raise_for_status()
+ self.assertEqual(res.text, str(GEOIP_ODOO_FARM_2))
+ mock_resolve.assert_called_once()
diff --git a/odoo/addons/test_http/tests/test_static.py b/odoo/addons/test_http/tests/test_static.py
new file mode 100644
index 00000000000..cd0d982214c
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_static.py
@@ -0,0 +1,263 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, timedelta
+from os.path import basename, join as opj
+from unittest.mock import patch
+from freezegun import freeze_time
+
+from odoo.tests import tagged
+from odoo.tools import config, file_open
+
+from .test_common import TestHttpBase
+
+
+@tagged('post_install', '-at_install')
+class TestHttpStaticCommon(TestHttpBase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.classPatch(config, 'options', {**config.options, 'x_sendfile': False})
+
+ with file_open('test_http/static/src/img/gizeh.png', 'rb') as file:
+ cls.gizeh_data = file.read()
+
+ def assertDownload(
+ self, url, assert_status_code, assert_headers, assert_content=None
+ ):
+ res = self.db_url_open(url)
+ res.raise_for_status()
+ self.assertEqual(res.status_code, assert_status_code)
+ for header_name, header_value in assert_headers.items():
+ self.assertEqual(res.headers.get(header_name), header_value)
+ if assert_content:
+ self.assertEqual(res.content, assert_content)
+ return res
+
+ def assertDownloadGizeh(self, url, x_sendfile=None, assert_filename='gizeh.png'):
+ headers = {
+ 'Content-Length': '814',
+ 'Content-Type': 'image/png',
+ 'Content-Disposition': f'inline; filename={assert_filename}'
+ }
+
+ if x_sendfile:
+ sha = basename(x_sendfile)
+ headers['X-Sendfile'] = x_sendfile
+ headers['X-Accel-Redirect'] = f'/web/filestore/{self.cr.dbname}/{sha[:2]}/{sha}'
+ headers['Content-Length'] = '0'
+
+ return self.assertDownload(url, 200, headers, b'' if x_sendfile else self.gizeh_data)
+
+
+@tagged('post_install', '-at_install')
+class TestHttpStatic(TestHttpStaticCommon):
+ def test_static00_static(self):
+ with self.subTest(x_sendfile=False):
+ res = self.assertDownloadGizeh('/test_http/static/src/img/gizeh.png')
+ self.assertEqual(res.headers.get('Cache-Control', ''), 'public, max-age=604800')
+
+ with self.subTest(x_sendfile=True), \
+ patch.object(config, 'options', {**config.options, 'x_sendfile': True}):
+ # The file is outside of the filestore, X-Sendfile disabled
+ res = self.assertDownloadGizeh('/test_http/static/src/img/gizeh.png', x_sendfile=False)
+ self.assertEqual(res.headers.get('Cache-Control', ''), 'public, max-age=604800')
+
+ def test_static01_debug_assets(self):
+ session = self.authenticate(None, None)
+ session.debug = 'assets'
+
+ res = self.assertDownloadGizeh('/test_http/static/src/img/gizeh.png')
+ self.assertEqual(res.headers.get('Cache-Control', ''), 'no-cache, max-age=0')
+
+ def test_static02_not_found(self):
+ res = self.nodb_url_open("/test_http/static/i-dont-exist")
+ self.assertEqual(res.status_code, 404)
+
+ def test_static03_attachment_fallback(self):
+ attachment = self.env.ref('test_http.gizeh_png')
+
+ with self.subTest(x_sendfile=False):
+ self.assertDownloadGizeh(attachment.url)
+
+ with self.subTest(x_sendfile=True), \
+ patch.object(config, 'options', {**config.options, 'x_sendfile': True}):
+ self.assertDownloadGizeh(
+ attachment.url,
+ x_sendfile=opj(config.filestore(self.env.cr.dbname), attachment.store_fname),
+ )
+
+ def test_static04_web_content(self):
+ attachment = self.env.ref('test_http.gizeh_png')
+
+ with self.subTest(x_sendfile=False):
+ self.assertDownloadGizeh('/web/content/test_http.gizeh_png')
+
+ with self.subTest(x_sendfile=True), \
+ patch.object(config, 'options', {**config.options, 'x_sendfile': True}):
+ self.assertDownloadGizeh(
+ '/web/content/test_http.gizeh_png',
+ x_sendfile=opj(config.filestore(self.env.cr.dbname), attachment.store_fname),
+ )
+
+ def test_static05_web_image(self):
+ attachment = self.env.ref('test_http.gizeh_png')
+
+ with self.subTest(x_sendfile=False):
+ self.assertDownloadGizeh('/web/image/test_http.gizeh_png')
+
+ with self.subTest(x_sendfile=True), \
+ patch.object(config, 'options', {**config.options, 'x_sendfile': True}):
+ self.assertDownloadGizeh(
+ '/web/image/test_http.gizeh_png',
+ x_sendfile=opj(config.filestore(self.env.cr.dbname), attachment.store_fname),
+ )
+
+ def test_static06_attachment_internal_url(self):
+ with self.subTest(x_sendfile=False):
+ self.assertDownloadGizeh('/web/image/test_http.gizeh_url')
+
+ with self.subTest(x_sendfile=True), \
+ patch.object(config, 'options', {**config.options, 'x_sendfile': True}):
+ # The file is outside of the filestore, X-Sendfile disabled
+ self.assertDownloadGizeh('/web/image/test_http.gizeh_url', x_sendfile=False)
+
+ def test_static07_attachment_external_url(self):
+ res = self.db_url_open('/web/content/test_http.rickroll')
+ res.raise_for_status()
+ self.assertEqual(res.status_code, 301)
+ self.assertEqual(res.headers.get('Location'), 'https://www.youtube.com/watch?v=dQw4w9WgXcQ')
+
+ def test_static08_binary_field_attach(self):
+ earth = self.env.ref('test_http.earth')
+ attachment = self.env['ir.attachment'].search([
+ ('res_model', '=', 'test_http.stargate'),
+ ('res_id', '=', earth.id),
+ ('res_field', '=', 'glyph_attach')
+ ], limit=1)
+ attachment_path = opj(config.filestore(self.env.cr.dbname), attachment.store_fname)
+
+ with self.subTest(x_sendfile=False):
+ self.assertDownloadGizeh(
+ f'/web/content/test_http.stargate/{earth.id}/glyph_attach',
+ assert_filename='Earth.png'
+ )
+
+ with self.subTest(x_sendfile=True), \
+ patch.object(config, 'options', {**config.options, 'x_sendfile': True}):
+ self.assertDownloadGizeh(
+ f'/web/content/test_http.stargate/{earth.id}/glyph_attach',
+ x_sendfile=attachment_path,
+ assert_filename='Earth.png'
+ )
+
+ def test_static09_binary_field_inline(self):
+ self.assertDownloadGizeh(
+ '/web/content/test_http.earth?field=glyph_inline',
+ assert_filename='Earth.png'
+ )
+
+ def test_static10_filename(self):
+ with self.subTest("record name"):
+ self.assertDownloadGizeh(
+ '/web/content/test_http.gizeh_png',
+ assert_filename='gizeh.png',
+ )
+
+ with self.subTest("forced name"):
+ self.assertDownloadGizeh(
+ '/web/content/test_http.gizeh_png?filename=pyramid.png',
+ assert_filename='pyramid.png',
+ )
+
+ with self.subTest("filename field"):
+ self.assertDownloadGizeh(
+ '/web/content/test_http.earth?field=glyph_inline&filename_field=address',
+ assert_filename='sq5Abt.png',
+ )
+
+ def test_static11_bad_filenames(self):
+ with self.subTest("missing record name"):
+ gizeh = self.env.ref('test_http.gizeh_png')
+ realname = gizeh.name
+ gizeh.name = ''
+ try:
+ self.assertDownloadGizeh(
+ '/web/content/test_http.gizeh_png',
+ assert_filename=f'ir_attachment-{gizeh.id}-raw.png'
+ )
+ finally:
+ gizeh.name = realname
+
+ with self.subTest("missing file extension"):
+ self.assertDownloadGizeh(
+ '/web/content/test_http.gizeh_png?filename=pyramid',
+ assert_filename='pyramid.png',
+ )
+
+ with self.subTest("wrong file extension"):
+ res = self.assertDownloadGizeh(
+ '/web/content/test_http.gizeh_png?filename=pyramid.jpg',
+ assert_filename='pyramid.jpg',
+ )
+ self.assertEqual(res.headers['Content-Type'], 'image/png')
+
+ with self.subTest("dotted name"):
+ res = self.assertDownloadGizeh(
+ '/web/content/test_http.gizeh_png?filename=pyramid.of.gizeh',
+ assert_filename='pyramid.of.gizeh.png',
+ )
+
+
+class TestHttpStaticCache(TestHttpStaticCommon):
+ @freeze_time(datetime.utcnow())
+ def test_static_cache0_standard(self, domain=''):
+ # Wed, 21 Oct 2015 07:28:00 GMT
+ # The timezone should be %Z (instead of 'GMT' hardcoded) but
+ # somehow strftime doesn't set it.
+ http_date_format = '%a, %d %b %Y %H:%M:%S GMT'
+ one_week_away = (datetime.utcnow() + timedelta(weeks=1)).strftime(http_date_format)
+
+ res1 = self.nodb_url_open(f'{domain}/test_http/static/src/img/gizeh.png')
+ res1.raise_for_status()
+ self.assertEqual(res1.status_code, 200)
+ self.assertEqual(res1.headers.get('Cache-Control'), 'public, max-age=604800') # one week
+ self.assertEqual(res1.headers.get('Expires'), one_week_away)
+ self.assertIn('ETag', res1.headers)
+
+ res2 = self.nodb_url_open(f'{domain}/test_http/static/src/img/gizeh.png', headers={
+ 'If-None-Match': res1.headers['ETag']
+ })
+ res2.raise_for_status()
+ self.assertEqual(res2.status_code, 304, "We should not download the file again.")
+
+ @freeze_time(datetime.utcnow())
+ def test_static_cache1_unique(self, domain=''):
+ # Wed, 21 Oct 2015 07:28:00 GMT
+ # The timezone should be %Z (instead of 'GMT' hardcoded) but
+ # somehow strftime doesn't set it.
+ http_date_format = '%a, %d %b %Y %H:%M:%S GMT'
+ one_year_away = (datetime.utcnow() + timedelta(days=365)).strftime(http_date_format)
+
+ res1 = self.assertDownloadGizeh(f'{domain}/web/content/test_http.gizeh_png?unique=1')
+ self.assertEqual(res1.headers.get('Cache-Control'), 'public, max-age=31536000') # one year
+ self.assertEqual(res1.headers.get('Expires'), one_year_away)
+ self.assertIn('ETag', res1.headers)
+
+ res2 = self.db_url_open(f'{domain}/web/content/test_http.gizeh_png?unique=1', headers={
+ 'If-None-Match': res1.headers['ETag']
+ })
+ res2.raise_for_status()
+ self.assertEqual(res2.status_code, 304, "We should not download the file again.")
+
+ @freeze_time(datetime.utcnow())
+ def test_static_cache2_nocache(self, domain=''):
+ res1 = self.assertDownloadGizeh(f'{domain}/web/content/test_http.gizeh_png?nocache=1')
+ self.assertEqual(res1.headers.get('Cache-Control'), 'no-cache')
+ self.assertNotIn('Expires', res1.headers)
+ self.assertIn('ETag', res1.headers)
+
+ res2 = self.db_url_open(f'{domain}/web/content/test_http.gizeh_png?nocache=1', headers={
+ 'If-None-Match': res1.headers['ETag']
+ })
+ res2.raise_for_status()
+ self.assertEqual(res2.status_code, 304, "We should not download the file again.")
diff --git a/odoo/addons/test_http/tests/test_web_server.py b/odoo/addons/test_http/tests/test_web_server.py
new file mode 100644
index 00000000000..4d3db6b03e4
--- /dev/null
+++ b/odoo/addons/test_http/tests/test_web_server.py
@@ -0,0 +1,27 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from os import getenv
+from odoo.tests import tagged
+from .test_static import TestHttpStatic, TestHttpStaticCache
+
+
+# Small configuration to run the tests against a web server.
+# WEB_SERVER_URL=http://localhost:80 odoo-bin -i test_http --test-tags webserver
+WEB_SERVER_URL = getenv('WEB_SERVER_URL', 'http://localhost:80')
+
+
+@tagged('webserver', '-standard', '-at-install')
+class TestHttpStaticWebServer(TestHttpStatic, TestHttpStaticCache):
+ @classmethod
+ def base_url(cls):
+ return WEB_SERVER_URL
+
+ def assertDownloadGizeh(self, url, x_sendfile=None, assert_filename='gizeh.png'):
+ # X-Sendfile and X-Accel-Redirect http response headers should
+ # have been consummed by the web server. We should get the
+ # ultimate response which holds the file.
+ return super().assertDownloadGizeh(
+ url,
+ x_sendfile=False,
+ assert_filename=assert_filename
+ )
diff --git a/odoo/http.py b/odoo/http.py
index 9b38b7b8cac..345631d7233 100644
--- a/odoo/http.py
+++ b/odoo/http.py
@@ -108,6 +108,7 @@ endpoint
The @route(...) decorated controller method.
"""
+import base64
import cgi
import collections
import collections.abc
@@ -129,7 +130,11 @@ import warnings
import zlib
from abc import ABC, abstractmethod
from datetime import datetime
+from io import BytesIO
from os.path import join as opj
+from pathlib import Path
+from urllib.parse import urlparse
+from zlib import adler32
import babel.core
import psycopg2
@@ -149,13 +154,18 @@ try:
except ImportError:
from werkzeug.contrib.fixers import ProxyFix
+try:
+ from werkzeug.utils import send_file as _send_file
+except ImportError:
+ from .tools._vendor.send_file import send_file as _send_file
+
import odoo
from .exceptions import UserError, AccessError, AccessDenied
from .modules.module import get_manifest
from .modules.registry import Registry
from .service import security, model as service_model
-from .tools import (config, consteq, date_utils, profiler, resolve_attr,
- submap, unique, ustr,)
+from .tools import (config, consteq, date_utils, file_path, profiler,
+ resolve_attr, submap, unique, ustr,)
from .tools.geoipresolver import GeoIPResolver
from .tools.func import filter_kwargs, lazy_property
from .tools.mimetypes import guess_mimetype
@@ -247,9 +257,6 @@ ROUTING_KEYS = {
'alias', 'host', 'methods',
}
-# The mimetypes of safe image types
-SAFE_IMAGE_MIMETYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/x-icon'}
-
# The duration of a user session before it is considered expired,
# three months.
SESSION_LIFETIME = 60 * 60 * 24 * 90
@@ -261,6 +268,7 @@ STATIC_CACHE = 60 * 60 * 24 * 7
# content (usually using a hash), one year.
STATIC_CACHE_LONG = 60 * 60 * 24 * 365
+
# =========================================================
# Helpers
# =========================================================
@@ -331,84 +339,6 @@ def db_filter(dbs, host=None):
def is_cors_preflight(request, endpoint):
return request.httprequest.method == 'OPTIONS' and endpoint.routing.get('cors', False)
-def send_file(filepath_or_fp, filename=None, mimetype=None, mtime=None,
- as_attachment=False, cache_timeout=STATIC_CACHE):
- """
- Fle streaming utility with mime and cache handling, it takes a
- file-object or immediately the content as bytes/str.
-
- Sends the content of a file to the client. This will use the most
- efficient method available and configured. By default it will try to
- use the WSGI server's file_wrapper support.
-
- If filename of file.name is provided it will try to guess the
- mimetype for you, but you can also explicitly provide one.
-
- For extra security you probably want to send certain files as
- attachment (e.g. HTML).
-
- :param Union[os.PathLike,io.FileIO] filepath_or_fp: the filename of
- the file to send. Alternatively a file object might be provided
- in which case `X-Sendfile` might not work and fall back to the
- traditional method. Make sure that the file pointer is position-
- ed at the start of data to send before calling :func:`send_file`
- :param str filename: optional if file has a 'name' attribute, used
- for attachment name and mimetype guess.
- :param str mimetype: the mimetype of the file if provided, otherwise
- auto detection happens based on the name.
- :param datetime mtime: optional if file has a 'name' attribute, last
- modification time used for conditional response.
- :param bool as_attachment: set to `True` if you want to send this
- file with a ``Content-Disposition: attachment`` header.
- :param int cache_timeout: set to `False` to disable etags and
- conditional response handling (last modified and etags)
- :returns: the HTTP response that streams the file.
- """
- if isinstance(filepath_or_fp, str):
- if not filename:
- filename = os.path.basename(filepath_or_fp)
- file = open(filepath_or_fp, 'rb')
- else:
- file = filepath_or_fp
- if not filename:
- filename = getattr(file, 'name', None)
-
- # Only used when filename or mtime argument is not provided
- path = getattr(file, 'name', 'file.bin')
-
- if not filename:
- filename = os.path.basename(path)
-
- if not mimetype:
- mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
-
- file.seek(0, 2)
- size = file.tell()
- file.seek(0)
-
- data = werkzeug.wsgi.wrap_file(request.httprequest.environ, file)
-
- res = werkzeug.wrappers.Response(data, mimetype=mimetype, direct_passthrough=True)
- res.content_length = size
-
- if as_attachment:
- res.headers.add('Content-Disposition', 'attachment', filename=filename)
-
- if cache_timeout:
- if not mtime:
- with contextlib.suppress(FileNotFoundError):
- mtime = datetime.fromtimestamp(os.path.getmtime(path))
- if mtime:
- res.last_modified = mtime
- crc = zlib.adler32(filename.encode('utf-8') if isinstance(filename, str) else filename) & 0xffffffff
- etag = f'odoo-{mtime}-{size}-{crc}'
- if not werkzeug.http.is_resource_modified(request.httprequest.environ, etag, last_modified=mtime):
- res = werkzeug.wrappers.Response(status=304)
- else:
- res.cache_control.public = True
- res.cache_control.max_age = cache_timeout
- res.set_etag(etag)
- return res
def serialize_exception(exception):
name = type(exception).__name__
@@ -422,20 +352,201 @@ def serialize_exception(exception):
'context': getattr(exception, 'context', {}),
}
-def set_safe_image_headers(headers, content):
- """Return new headers based on `headers` but with `Content-Length` and
- `Content-Type` set appropriately depending on the given `content` only if it
- is safe to do, as well as `X-Content-Type-Options: nosniff` so that if the
- file is of an unsafe type, it is not interpreted as that type if the
- `Content-type` header was already set to a different mimetype
+
+# =========================================================
+# File Streaming
+# =========================================================
+
+def send_file(filepath_or_fp, mimetype=None, as_attachment=False, filename=None, mtime=None,
+ add_etags=True, cache_timeout=STATIC_CACHE, conditional=True):
+ warnings.warn('odoo.http.send_file is deprecated, please use odoo.http.Stream instead.', DeprecationWarning, stacklevel=2)
+ return _send_file(
+ filepath_or_fp,
+ request.httprequest.environ,
+ mimetype=mimetype,
+ as_attachment=as_attachment,
+ download_name=filename,
+ last_modified=mtime,
+ etag=add_etags,
+ max_age=cache_timeout,
+ conditional=conditional
+ )
+
+
+class Stream:
"""
- headers = werkzeug.datastructures.Headers(headers)
- content_type = guess_mimetype(content)
- if content_type in SAFE_IMAGE_MIMETYPES:
- headers['Content-Type'] = content_type
- headers['X-Content-Type-Options'] = 'nosniff'
- headers['Content-Length'] = len(content)
- return list(headers)
+ Send the content of a file, an attachment or a binary field via HTTP
+
+ This utility is safe, cache-aware and uses the best available
+ streaming strategy. Works best with the --x-sendfile cli option.
+
+ Create a Stream via one of the constructors: :meth:`~from_path`:,
+ :meth:`~from_attachment`: or :meth:`~from_binary_field`:, generate
+ the corresponding HTTP response object via :meth:`~get_response`:.
+
+ Instantiating a Stream object manually without using one of the
+ dedicated constructors is discouraged.
+ """
+
+ type: str = '' # 'data' or 'path' or 'url'
+ data = None
+ path = None
+ url = None
+
+ mimetype = None
+ as_attachment = False
+ download_name = None
+ conditional = True
+ etag = True
+ last_modified = None
+ max_age = None
+ size = None
+
+ def __init__(self, **kwargs):
+ self.__dict__.update(kwargs)
+
+ @classmethod
+ def from_path(cls, path, filter_ext=('',)):
+ """ Create a :class:`~Stream`: from an addon resource. """
+ path = file_path(path, filter_ext)
+ check = adler32(path.encode())
+ stat = os.stat(path)
+ return cls(
+ type='path',
+ path=path,
+ download_name=os.path.basename(path),
+ etag=f'{int(stat.st_mtime)}-{stat.st_size}-{check}',
+ last_modified=stat.st_mtime,
+ size=stat.st_size,
+ )
+
+ @classmethod
+ def from_attachment(cls, attachment):
+ """ Create a :class:`~Stream`: from an ir.attachment record. """
+ attachment.ensure_one()
+
+ self = cls(
+ mimetype=attachment.mimetype,
+ download_name=attachment.name,
+ conditional=True,
+ etag=attachment.checksum,
+ )
+
+ if attachment.store_fname:
+ self.type = 'path'
+ self.path = werkzeug.security.safe_join(
+ os.path.abspath(config.filestore(request.db)),
+ attachment.store_fname
+ )
+ stat = os.stat(self.path)
+ self.last_modified = stat.st_mtime
+ self.size = stat.st_size
+
+ elif attachment.db_datas:
+ self.type = 'data'
+ self.data = attachment.raw
+ self.last_modified = attachment['__last_update']
+ self.size = len(self.data)
+
+ elif attachment.url:
+ # When the URL targets a file located in an addon, assume it
+ # is a path to the resource. It saves an indirection and
+ # stream the file right away.
+ static_path = root.get_static_file(
+ attachment.url,
+ host=request.httprequest.environ.get('HTTP_HOST', '')
+ )
+ if static_path:
+ self = cls.from_path(static_path)
+ else:
+ self.type = 'url'
+ self.url = attachment.url
+
+ else:
+ self.type = 'data'
+ self.data = b''
+ self.size = 0
+
+ return self
+
+ @classmethod
+ def from_binary_field(cls, record, field_name):
+ """ Create a :class:`~Stream`: from a binary field. """
+ data_b64 = record[field_name]
+ data = base64.b64decode(data_b64) if data_b64 else b''
+ return cls(
+ type='data',
+ data=data,
+ etag=request.env['ir.attachment']._compute_checksum(data),
+ last_modified=record['__last_update'] if record._log_access else None,
+ size=len(data),
+ )
+
+ def read(self):
+ """ Get the stream content as bytes. """
+ if self.type == 'url':
+ raise ValueError("Cannot read an URL")
+
+ if self.type == 'data':
+ return self.data
+
+ with open(self.path, 'rb') as file:
+ return file.read()
+
+ def get_response(self, as_attachment=None, **send_file_kwargs):
+ """
+ Create the corresponding :class:`~Response` for the current stream.
+
+ :param bool as_attachment: Indicate to the browser that it
+ should offer to save the file instead of displaying it.
+ :param send_file_kwargs: Other keyword arguments to send to
+ :func:`odoo.tools._vendor.send_file.send_file` instead of
+ the stream sensitive values. Discouraged.
+ """
+ assert self.type in ('url', 'data', 'path'), "Invalid type: {self.type!r}, should be 'url', 'data' or 'path'."
+ assert getattr(self, self.type) is not None, "There is nothing to stream, missing {self.type!r} attribute."
+
+ if self.type == 'url':
+ return request.redirect(self.url, code=301, local=False)
+
+ if as_attachment is None:
+ as_attachment = self.as_attachment
+
+ send_file_kwargs = {
+ 'mimetype': self.mimetype,
+ 'as_attachment': as_attachment,
+ 'download_name': self.download_name,
+ 'conditional': self.conditional,
+ 'etag': self.etag,
+ 'last_modified': self.last_modified,
+ 'max_age': self.max_age,
+ 'environ': request.httprequest.environ,
+ 'response_class': Response,
+ **send_file_kwargs,
+ }
+
+ if self.type == 'data':
+ return _send_file(BytesIO(self.data), **send_file_kwargs)
+
+ # self.type == 'path'
+ send_file_kwargs['use_x_sendfile'] = False
+ if config['x_sendfile']:
+ with contextlib.suppress(ValueError): # outside of the filestore
+ fspath = Path(self.path).relative_to(opj(config['data_dir'], 'filestore'))
+ x_accel_redirect = f'/web/filestore/{fspath}'
+ send_file_kwargs['use_x_sendfile'] = True
+
+ res = _send_file(self.path, **send_file_kwargs)
+
+ if 'X-Sendfile' in res.headers:
+ res.headers['X-Accel-Redirect'] = x_accel_redirect
+
+ # In case of X-Sendfile/X-Accel-Redirect, the body is empty,
+ # yet werkzeug gives the length of the file. This makes
+ # NGINX wait for content that'll never arrive.
+ res.headers['Content-Length'] = '0'
+
+ return res
# =========================================================
@@ -1339,7 +1450,9 @@ class Request:
try:
directory = root.statics[module]
filepath = werkzeug.security.safe_join(directory, path)
- return send_file(filepath)
+ return Stream.from_path(filepath).get_response(
+ max_age=0 if 'assets' in self.session.debug else STATIC_CACHE,
+ )
except KeyError:
raise NotFound(f'Module "{module}" not found.\n')
except OSError: # cover both missing file and invalid permissions
@@ -1680,6 +1793,36 @@ class Application:
mod2path[module] = static_path
return mod2path
+ def get_static_file(self, url, host=''):
+ """
+ Get the full-path of the file if the url resolves to a local
+ static file, otherwise return None.
+
+ Without the second host parameters, ``url`` must be an absolute
+ path, others URLs are considered faulty.
+
+ With the second host parameters, ``url`` can also be a full URI
+ and the authority found in the URL (if any) is validated against
+ the given ``host``.
+ """
+
+ netloc, path = urlparse(url)[1:3]
+ try:
+ path_netloc, module, static, resource = path.split('/', 3)
+ except ValueError:
+ return None
+
+ if ((netloc and netloc != host) or (path_netloc and path_netloc != host)):
+ return None
+
+ if (module not in self.statics or static != 'static' or not resource):
+ return None
+
+ try:
+ return file_path(f'{module}/static/{resource}')
+ except FileNotFoundError:
+ return None
+
@lazy_property
def nodb_routing_map(self):
nodb_routing_map = werkzeug.routing.Map(strict_slashes=False, converters=None)
@@ -1721,6 +1864,7 @@ class Application:
return
headers['Content-Security-Policy'] = "default-src 'none'"
+ headers['X-Content-Type-Options'] = 'nosniff'
def __call__(self, environ, start_response):
"""
@@ -1764,13 +1908,9 @@ class Application:
current_thread.url = httprequest.url
try:
- segments = httprequest.path.split('/')
- if len(segments) >= 4 and segments[2] == 'static':
- with contextlib.suppress(NotFound):
- response = request._serve_static()
- return response(environ, start_response)
-
- if request.db:
+ if self.get_static_file(httprequest.path):
+ response = request._serve_static()
+ elif request.db:
with request._get_profiler_context_manager():
response = request._serve_db()
else:
diff --git a/odoo/models.py b/odoo/models.py
index dabf4e88d38..253b86ae3b9 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -6779,9 +6779,6 @@ class BaseModel(metaclass=MetaModel):
""" Returns the filename of the placeholder to use,
set on web/static/img by default, or the
complete path to access it (eg: module/path/to/image.png).
-
- If a falsy value is returned, "ir.http"._placeholder() will use
- the default placeholder 'web/static/img/placeholder.png'.
"""
return False
diff --git a/odoo/tools/_vendor/send_file.py b/odoo/tools/_vendor/send_file.py
new file mode 100644
index 00000000000..95bf18108e8
--- /dev/null
+++ b/odoo/tools/_vendor/send_file.py
@@ -0,0 +1,223 @@
+"""
+Vendored copy of the werkzeug.utils.send_file function defined in
+werkzeug2 which is packaged in Debian 12 "Bookworm" and Ubuntu 22.04
+"Jammy". Odoo is compatible with werkzeug2 since saas-15.4.
+
+This vendored copy is deprecated, only present to ensure backward
+compatibility with older operating systems.
+
+:copyright: 2007 Pallets
+:license: BSD-3-Clause
+"""
+
+import io
+import logging
+import mimetypes
+import os
+import typing as t
+import unicodedata
+from datetime import datetime
+from time import time
+from zlib import adler32
+
+from werkzeug.datastructures import Headers
+from werkzeug.exceptions import RequestedRangeNotSatisfiable
+from werkzeug.urls import url_quote
+from werkzeug.wrappers import Response
+from werkzeug.wsgi import wrap_file
+
+_logger = logging.getLogger(__name__)
+
+
+def send_file(
+ path_or_file: t.Union[os.PathLike, str, t.IO[bytes]],
+ environ: "WSGIEnvironment",
+ mimetype: t.Optional[str] = None,
+ as_attachment: bool = False,
+ download_name: t.Optional[str] = None,
+ conditional: bool = True,
+ etag: t.Union[bool, str] = True,
+ last_modified: t.Optional[t.Union[datetime, int, float]] = None,
+ max_age: t.Optional[
+ t.Union[int, t.Callable[[t.Optional[str]], t.Optional[int]]]
+ ] = None,
+ use_x_sendfile: bool = False,
+ response_class: t.Optional[t.Type["Response"]] = None,
+ _root_path: t.Optional[t.Union[os.PathLike, str]] = None,
+) -> "Response":
+ """Send the contents of a file to the client.
+
+ The first argument can be a file path or a file-like object. Paths
+ are preferred in most cases because Werkzeug can manage the file and
+ get extra information from the path. Passing a file-like object
+ requires that the file is opened in binary mode, and is mostly
+ useful when building a file in memory with :class:`io.BytesIO`.
+
+ Never pass file paths provided by a user. The path is assumed to be
+ trusted, so a user could craft a path to access a file you didn't
+ intend.
+
+ If the WSGI server sets a ``file_wrapper`` in ``environ``, it is
+ used, otherwise Werkzeug's built-in wrapper is used. Alternatively,
+ if the HTTP server supports ``X-Sendfile``, ``use_x_sendfile=True``
+ will tell the server to send the given path, which is much more
+ efficient than reading it in Python.
+
+ :param path_or_file: The path to the file to send, relative to the
+ current working directory if a relative path is given.
+ Alternatively, a file-like object opened in binary mode. Make
+ sure the file pointer is seeked to the start of the data.
+ :param environ: The WSGI environ for the current request.
+ :param mimetype: The MIME type to send for the file. If not
+ provided, it will try to detect it from the file name.
+ :param as_attachment: Indicate to a browser that it should offer to
+ save the file instead of displaying it.
+ :param download_name: The default name browsers will use when saving
+ the file. Defaults to the passed file name.
+ :param conditional: Enable conditional and range responses based on
+ request headers. Requires passing a file path and ``environ``.
+ :param etag: Calculate an ETag for the file, which requires passing
+ a file path. Can also be a string to use instead.
+ :param last_modified: The last modified time to send for the file,
+ in seconds. If not provided, it will try to detect it from the
+ file path.
+ :param max_age: How long the client should cache the file, in
+ seconds. If set, ``Cache-Control`` will be ``public``, otherwise
+ it will be ``no-cache`` to prefer conditional caching.
+ :param use_x_sendfile: Set the ``X-Sendfile`` header to let the
+ server to efficiently send the file. Requires support from the
+ HTTP server. Requires passing a file path.
+ :param response_class: Build the response using this class. Defaults
+ to :class:`~werkzeug.wrappers.Response`.
+ :param _root_path: Do not use. For internal use only. Use
+ :func:`send_from_directory` to safely send files under a path.
+ """
+ if response_class is None:
+ response_class = Response
+
+ path = None
+ file = None
+ size = None
+ mtime = None
+ headers = Headers()
+
+ if isinstance(path_or_file, (os.PathLike, str)) or hasattr(
+ path_or_file, "__fspath__"
+ ):
+
+ # Flask will pass app.root_path, allowing its send_file wrapper
+ # to not have to deal with paths.
+ if _root_path is not None:
+ path = os.path.join(_root_path, path_or_file)
+ else:
+ path = os.path.abspath(path_or_file)
+
+ stat = os.stat(path)
+ size = stat.st_size
+ mtime = stat.st_mtime
+ else:
+ file = path_or_file
+
+ if download_name is None and path is not None:
+ download_name = os.path.basename(path)
+
+ if mimetype is None:
+ if download_name is None:
+ raise TypeError(
+ "Unable to detect the MIME type because a file name is"
+ " not available. Either set 'download_name', pass a"
+ " path instead of a file, or set 'mimetype'."
+ )
+
+ mimetype, encoding = mimetypes.guess_type(download_name)
+
+ if mimetype is None:
+ mimetype = "application/octet-stream"
+
+ # Don't send encoding for attachments, it causes browsers to
+ # save decompress tar.gz files.
+ if encoding is not None and not as_attachment:
+ headers.set("Content-Encoding", encoding)
+ if use_x_sendfile and path is not None:
+ headers["X-Accel-Charset"] = encoding
+
+ if download_name is not None:
+ try:
+ download_name.encode("ascii")
+ except UnicodeEncodeError:
+ simple = unicodedata.normalize("NFKD", download_name)
+ simple = simple.encode("ascii", "ignore").decode("ascii")
+ quoted = url_quote(download_name, safe="")
+ names = {"filename": simple, "filename*": f"UTF-8''{quoted}"}
+ else:
+ names = {"filename": download_name}
+
+ value = "attachment" if as_attachment else "inline"
+ headers.set("Content-Disposition", value, **names)
+ elif as_attachment:
+ raise TypeError(
+ "No name provided for attachment. Either set"
+ " 'download_name' or pass a path instead of a file."
+ )
+
+ if use_x_sendfile and path is not None:
+ headers["X-Sendfile"] = path
+ data = None
+ else:
+ if file is None:
+ file = open(path, "rb") # type: ignore
+ elif isinstance(file, io.BytesIO):
+ size = file.getbuffer().nbytes
+ elif isinstance(file, io.TextIOBase):
+ raise ValueError("Files must be opened in binary mode or use BytesIO.")
+
+ data = wrap_file(environ, file)
+
+ rv = response_class(
+ data, mimetype=mimetype, headers=headers, direct_passthrough=True
+ )
+
+ if size is not None:
+ rv.content_length = size
+
+ if last_modified is not None:
+ rv.last_modified = last_modified # type: ignore
+ elif mtime is not None:
+ rv.last_modified = mtime # type: ignore
+
+ rv.cache_control.no_cache = True
+
+ # Flask will pass app.get_send_file_max_age, allowing its send_file
+ # wrapper to not have to deal with paths.
+ if callable(max_age):
+ max_age = max_age(path)
+
+ if max_age is not None:
+ if max_age > 0:
+ rv.cache_control.no_cache = None
+ rv.cache_control.public = True
+
+ rv.cache_control.max_age = max_age
+ rv.expires = int(time() + max_age) # type: ignore
+
+ if isinstance(etag, str):
+ rv.set_etag(etag)
+ elif etag and path is not None:
+ check = adler32(path.encode("utf-8")) & 0xFFFFFFFF
+ rv.set_etag(f"{mtime}-{size}-{check}")
+
+ if conditional:
+ try:
+ rv = rv.make_conditional(environ, accept_ranges=True, complete_length=size)
+ except RequestedRangeNotSatisfiable:
+ if file is not None:
+ file.close()
+
+ raise
+
+ # Some x-sendfile implementations incorrectly ignore the 304
+ # status code and send the file anyway.
+ if rv.status_code == 304:
+ rv.headers.pop("x-sendfile", None)
+
+ return rv
diff --git a/odoo/tools/config.py b/odoo/tools/config.py
index 2db5ecd39a4..02d988a06d5 100644
--- a/odoo/tools/config.py
+++ b/odoo/tools/config.py
@@ -139,6 +139,10 @@ class configmanager(object):
group.add_option("--proxy-mode", dest="proxy_mode", action="store_true", my_default=False,
help="Activate reverse proxy WSGI wrappers (headers rewriting) "
"Only enable this when running behind a trusted web proxy!")
+ group.add_option("--x-sendfile", dest="x_sendfile", action="store_true", my_default=False,
+ help="Activate X-Sendfile (apache) and X-Accel-Redirect (nginx) "
+ "HTTP response header to delegate the delivery of large "
+ "files (assets/attachments) to the web server.")
# HTTP: hidden backwards-compatibility for "*xmlrpc*" options
hidden = optparse.SUPPRESS_HELP
group.add_option("--xmlrpc-interface", dest="http_interface", help=hidden)
@@ -438,7 +442,7 @@ class configmanager(object):
self.options['server_wide_modules'] = 'base,web'
# if defined do not take the configfile value even if the defined value is None
- keys = ['http_interface', 'http_port', 'longpolling_port', 'http_enable',
+ keys = ['http_interface', 'http_port', 'longpolling_port', 'http_enable', 'x_sendfile',
'db_name', 'db_user', 'db_password', 'db_host', 'db_sslmode',
'db_port', 'db_template', 'logfile', 'pidfile', 'smtp_port',
'email_from', 'smtp_server', 'smtp_user', 'smtp_password', 'from_filter',
diff --git a/odoo/tools/mimetypes.py b/odoo/tools/mimetypes.py
index b89434117ce..0d1f794f1a5 100644
--- a/odoo/tools/mimetypes.py
+++ b/odoo/tools/mimetypes.py
@@ -8,6 +8,7 @@ import collections
import functools
import io
import logging
+import mimetypes
import re
import zipfile
@@ -197,11 +198,25 @@ def neuter_mimetype(mimetype, user):
return mimetype
def get_extension(filename):
- """ Return the extension the current filename based on the heuristic that
- ext is less than or equal to 10 chars and is alphanumeric.
+ # A file has no extension if it has no dot (ignoring the leading one
+ # of hidden files) or that what follow the last dot is not a single
+ # word, e.g. "Mr. Doe"
+ _stem, dot, ext = filename.lstrip('.').rpartition('.')
+ if not dot or not ext.isalnum():
+ return ''
- :param str filename: filename to try and guess a extension for
- :returns: detected extension or ``
- """
- ext = '.' in filename and filename.split('.')[-1]
- return ext and len(ext) <= 10 and ext.isalnum() and '.' + ext.lower() or ''
+ # Assume all 4-chars extensions to be valid extensions even if it is
+ # not known from the mimetypes database. In /etc/mime.types, only 7%
+ # known extensions are longer.
+ if len(ext) <= 4:
+ return f'.{ext}'.lower()
+
+ # Use the mimetype database to determine the extension of the file.
+ guessed_mimetype, guessed_ext = mimetypes.guess_type(filename)
+ if guessed_ext:
+ return guessed_ext
+ if guessed_mimetype:
+ return f'.{ext}'.lower()
+
+ # Unknown extension.
+ return ''