The generation inside the rendering has some drawbacks: - `commit_assetsbundle` is needed for reports rendering because the template rendering may generate some assets that will be accessed by another transaction before the transaction is committed. But this solution is not ideal since the transaction is committed in the middle of the request - when the first rendered page is a 404, the assets are not committed and the page is broken. - when starting, deleting an attachment can create a concurrent update error and the request is retried. This will occur once per attachment and for all worker trying to access the same resource. The whole transaction is rollbacked, even the previously created assets bundle. - The cold page load is a slower since there is more work to do. - Implementing a readonly request is difficult because it could be transformed to read write and re-executed if the assets bundle does not exist. Generating assets when needed solves those issues. The concurrency when deleting an assets could still occur but only once per bundle, and in a smaller transaction. This could be solved with a lock now that we have more control on the transaction. The commit_assetsbundle can be removed and 404 page should have a correct layout. The cold page load could be a little faster because the assets bundle can be generated in parallel requests instead of sequentially when rendering the page. Part-of: odoo/odoo#131353
316 lines
14 KiB
Python
316 lines
14 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import functools
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import unicodedata
|
|
|
|
import werkzeug.exceptions
|
|
|
|
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 SUPERUSER_ID, _, http
|
|
from odoo.exceptions import AccessError, UserError
|
|
from odoo.http import request, Response
|
|
from odoo.tools import file_open, file_path, replace_exceptions
|
|
from odoo.tools.image import image_guess_size_from_field_name
|
|
from odoo.tools.mimetypes import guess_mimetype
|
|
|
|
_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', '')
|
|
|
|
|
|
class Binary(http.Controller):
|
|
|
|
@http.route('/web/filestore/<path:_path>', 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/<string:xmlid>',
|
|
'/web/content/<string:xmlid>/<string:filename>',
|
|
'/web/content/<int:id>',
|
|
'/web/content/<int:id>/<string:filename>',
|
|
'/web/content/<string:model>/<int:id>/<string:field>',
|
|
'/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public")
|
|
# 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['immutable'] = True
|
|
send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
|
|
if nocache:
|
|
send_file_kwargs['max_age'] = None
|
|
|
|
res = stream.get_response(**send_file_kwargs)
|
|
res.headers['Content-Security-Policy'] = "default-src 'none'"
|
|
return res
|
|
|
|
@http.route([
|
|
'/web/assets/<path:extra>/<string:filename>',
|
|
'/web/assets/<string:unique>/<path:extra>/<string:filename>'], type='http', auth="public")
|
|
def content_assets(self, filename=None, unique='%', extra='-', nocache=False):
|
|
debug_assets = unique == 'debug'
|
|
attachment = None
|
|
if unique != 'debug':
|
|
url = f'/web/assets/{unique}/{extra}/{filename}'
|
|
domain = [
|
|
('public', '=', True),
|
|
('url', '!=', False),
|
|
('url', '=like', url),
|
|
('res_model', '=', 'ir.ui.view'),
|
|
('res_id', '=', 0),
|
|
('create_uid', '=', SUPERUSER_ID),
|
|
]
|
|
attachment = request.env['ir.attachment'].sudo().search(domain, limit=1)
|
|
if not attachment:
|
|
# try to generate one
|
|
with replace_exceptions(ValueError, by=werkzeug.exceptions.BadRequest):
|
|
extra_parts = extra.split('+')
|
|
params = request.env['ir.asset']._parse_assets_extra(extra_parts)
|
|
if debug_assets:
|
|
bundle_name, asset_type = filename.rsplit('.', 1)
|
|
else:
|
|
bundle_name, min_, asset_type = filename.rsplit('.', 2)
|
|
if min_ != 'min':
|
|
_logger.error("'min' expected in extension in non debug mode")
|
|
raise request.not_found()
|
|
css = asset_type == 'css'
|
|
js = asset_type == 'js'
|
|
if not js and not css:
|
|
_logger.error('Only js and css assets bundle are supported for now')
|
|
raise request.not_found()
|
|
bundle = request.env['ir.qweb']._get_asset_bundle(
|
|
bundle_name,
|
|
css=css,
|
|
js=js,
|
|
debug_assets=debug_assets,
|
|
rtl='rtl' in extra_parts,
|
|
assets_params=params,
|
|
)
|
|
# check if the version matches. If not, redirect to the last version
|
|
if not debug_assets and unique != '%' and unique != bundle.get_version(asset_type):
|
|
return request.redirect(bundle.get_link(asset_type))
|
|
if css and bundle.stylesheets:
|
|
attachment = bundle.css()
|
|
elif js and bundle.javascripts:
|
|
attachment = bundle.js()
|
|
if not attachment:
|
|
raise request.not_found()
|
|
stream = request.env['ir.binary']._get_stream_from(attachment, 'raw', filename)
|
|
send_file_kwargs = {'as_attachment': False}
|
|
if unique and unique != 'debug':
|
|
send_file_kwargs['immutable'] = True
|
|
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/image',
|
|
'/web/image/<string:xmlid>',
|
|
'/web/image/<string:xmlid>/<string:filename>',
|
|
'/web/image/<string:xmlid>/<int:width>x<int:height>',
|
|
'/web/image/<string:xmlid>/<int:width>x<int:height>/<string:filename>',
|
|
'/web/image/<string:model>/<int:id>/<string:field>',
|
|
'/web/image/<string:model>/<int:id>/<string:field>/<string:filename>',
|
|
'/web/image/<string:model>/<int:id>/<string:field>/<int:width>x<int:height>',
|
|
'/web/image/<string:model>/<int:id>/<string:field>/<int:width>x<int:height>/<string:filename>',
|
|
'/web/image/<int:id>',
|
|
'/web/image/<int:id>/<string:filename>',
|
|
'/web/image/<int:id>/<int:width>x<int:height>',
|
|
'/web/image/<int:id>/<int:width>x<int:height>/<string:filename>',
|
|
'/web/image/<int:id>-<string:unique>',
|
|
'/web/image/<int:id>-<string:unique>/<string:filename>',
|
|
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>',
|
|
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], 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', filename=None, mimetype=None, unique=False,
|
|
download=False, width=0, height=0, crop=False, access_token=None,
|
|
nocache=False):
|
|
try:
|
|
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=int(width), height=int(height), crop=crop,
|
|
)
|
|
|
|
send_file_kwargs = {'as_attachment': download}
|
|
if unique:
|
|
send_file_kwargs['immutable'] = True
|
|
send_file_kwargs['max_age'] = http.STATIC_CACHE_LONG
|
|
if nocache:
|
|
send_file_kwargs['max_age'] = None
|
|
|
|
res = stream.get_response(**send_file_kwargs)
|
|
res.headers['Content-Security-Policy'] = "default-src 'none'"
|
|
return res
|
|
|
|
@http.route('/web/binary/upload_attachment', type='http', auth="user")
|
|
def upload_attachment(self, model, id, ufile, callback=None):
|
|
files = request.httprequest.files.getlist('ufile')
|
|
Model = request.env['ir.attachment']
|
|
out = """<script language="javascript" type="text/javascript">
|
|
var win = window.top.window;
|
|
win.jQuery(win).trigger(%s, %s);
|
|
</script>"""
|
|
args = []
|
|
for ufile in files:
|
|
|
|
filename = ufile.filename
|
|
if request.httprequest.user_agent.browser == 'safari':
|
|
# Safari sends NFD UTF-8 (where é is composed by 'e' and [accent])
|
|
# we need to send it the same stuff, otherwise it'll fail
|
|
filename = unicodedata.normalize('NFD', ufile.filename)
|
|
|
|
try:
|
|
attachment = Model.create({
|
|
'name': filename,
|
|
'raw': ufile.read(),
|
|
'res_model': model,
|
|
'res_id': int(id)
|
|
})
|
|
attachment._post_add_create()
|
|
except AccessError:
|
|
args.append({'error': _("You are not allowed to upload an attachment here.")})
|
|
except Exception:
|
|
args.append({'error': _("Something horrible happened")})
|
|
_logger.exception("Fail to upload attachment %s", ufile.filename)
|
|
else:
|
|
args.append({
|
|
'filename': clean(filename),
|
|
'mimetype': attachment.mimetype,
|
|
'id': attachment.id,
|
|
'size': attachment.file_size
|
|
})
|
|
return out % (json.dumps(clean(callback)), json.dumps(args)) if callback else json.dumps(args)
|
|
|
|
@http.route([
|
|
'/web/binary/company_logo',
|
|
'/logo',
|
|
'/logo.png',
|
|
], type='http', auth="none", cors="*")
|
|
def company_logo(self, dbname=None, **kw):
|
|
imgname = 'logo'
|
|
imgext = '.png'
|
|
dbname = request.db
|
|
uid = (request.session.uid if dbname else None) or odoo.SUPERUSER_ID
|
|
|
|
if not dbname:
|
|
response = http.Stream.from_path(file_path('web/static/img/logo.png')).get_response()
|
|
else:
|
|
try:
|
|
# create an empty registry
|
|
registry = odoo.modules.registry.Registry(dbname)
|
|
with registry.cursor() as cr:
|
|
company = int(kw['company']) if kw and kw.get('company') else False
|
|
if company:
|
|
cr.execute("""SELECT logo_web, write_date
|
|
FROM res_company
|
|
WHERE id = %s
|
|
""", (company,))
|
|
else:
|
|
cr.execute("""SELECT c.logo_web, c.write_date
|
|
FROM res_users u
|
|
LEFT JOIN res_company c
|
|
ON c.id = u.company_id
|
|
WHERE u.id = %s
|
|
""", (uid,))
|
|
row = cr.fetchone()
|
|
if row and row[0]:
|
|
image_base64 = base64.b64decode(row[0])
|
|
image_data = io.BytesIO(image_base64)
|
|
mimetype = guess_mimetype(image_base64, default='image/png')
|
|
imgext = '.' + mimetype.split('/')[1]
|
|
if imgext == '.svg+xml':
|
|
imgext = '.svg'
|
|
response = send_file(
|
|
image_data,
|
|
request.httprequest.environ,
|
|
download_name=imgname + imgext,
|
|
mimetype=mimetype,
|
|
last_modified=row[1],
|
|
response_class=Response,
|
|
)
|
|
else:
|
|
response = http.Stream.from_path(file_path('web/static/img/nologo.png')).get_response()
|
|
except Exception:
|
|
response = http.Stream.from_path(file_path(f'web/static/img/{imgname}{imgext}')).get_response()
|
|
|
|
return response
|
|
|
|
@http.route(['/web/sign/get_fonts', '/web/sign/get_fonts/<string:fontname>'], type='json', auth='public')
|
|
def get_fonts(self, fontname=None):
|
|
"""This route will return a list of base64 encoded fonts.
|
|
|
|
Those fonts will be proposed to the user when creating a signature
|
|
using mode 'auto'.
|
|
|
|
:return: base64 encoded fonts
|
|
:rtype: list
|
|
"""
|
|
supported_exts = ('.ttf', '.otf', '.woff', '.woff2')
|
|
fonts = []
|
|
fonts_directory = file_path('web/static/fonts/sign')
|
|
if fontname:
|
|
font_path = os.path.join(fonts_directory, fontname)
|
|
with file_open(font_path, 'rb', filter_ext=supported_exts) as font_file:
|
|
font = base64.b64encode(font_file.read())
|
|
fonts.append(font)
|
|
else:
|
|
font_filenames = sorted([fn for fn in os.listdir(fonts_directory) if fn.endswith(supported_exts)])
|
|
for filename in font_filenames:
|
|
font_file = file_open(os.path.join(fonts_directory, filename), 'rb', filter_ext=supported_exts)
|
|
font = base64.b64encode(font_file.read())
|
|
fonts.append(font)
|
|
return fonts
|