flectra/flectra/addons/base/ir/ir_qweb/assetsbundle.py

734 lines
29 KiB
Python

# -*- coding: utf-8 -*-
import base64
import os
import re
import hashlib
import itertools
import json
import textwrap
import uuid
from datetime import datetime
from subprocess import Popen, PIPE
from collections import OrderedDict
from flectra import fields, tools
from flectra.tools.pycompat import string_types, to_text
from flectra.http import request
from flectra.modules.module import get_resource_path
from flectra.addons.base.ir.ir_qweb.qweb import escape
import psycopg2
from flectra.tools import func, misc
import logging
_logger = logging.getLogger(__name__)
MAX_CSS_RULES = 4095
def rjsmin(script):
""" Minify js with a clever regex.
Taken from http://opensource.perlig.de/rjsmin
Apache License, Version 2.0 """
def subber(match):
""" Substitution callback """
groups = match.groups()
return (
groups[0] or
groups[1] or
groups[2] or
groups[3] or
(groups[4] and '\n') or
(groups[5] and ' ') or
(groups[6] and ' ') or
(groups[7] and ' ') or
''
)
result = re.sub(
r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script
).strip()
return result
class AssetError(Exception):
pass
class AssetNotFound(AssetError):
pass
class AssetsBundle(object):
rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
# remains attribute is depreciated and will remove after v11
def __init__(self, name, files, remains=None, env=None):
self.name = name
self.env = request.env if env is None else env
self.max_css_rules = self.env.context.get('max_css_rules', MAX_CSS_RULES)
self.javascripts = []
self.stylesheets = []
self.css_errors = []
self._checksum = None
self.files = files
for f in files:
if f['atype'] == 'text/sass':
self.stylesheets.append(SassStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media']))
elif f['atype'] == 'text/less':
self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media']))
elif f['atype'] == 'text/css':
self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media']))
elif f['atype'] == 'text/javascript':
self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
# depreciated and will remove after v11
def to_html(self, sep=None, css=True, js=True, debug=False, async_load=False, url_for=(lambda url: url), **kw):
if 'async' in kw:
_logger.warning("Using deprecated argument 'async' in to_html call, use 'async_load' instead.")
async_load = kw['async']
nodes = self.to_node(css=css, js=js, debug=debug, async_load=async_load)
if sep is None:
sep = u'\n '
response = []
for tagName, attributes, content in nodes:
html = u"<%s " % tagName
for name, value in attributes.items():
if value or isinstance(value, string_types):
html += u' %s="%s"' % (name, escape(to_text(value)))
if content is None:
html += u'/>'
else:
html += u'>%s</%s>' % (escape(to_text(content)), tagName)
response.append(html)
return sep + sep.join(response)
def to_node(self, css=True, js=True, debug=False, async_load=False, **kw):
"""
:returns [(tagName, attributes, content)] if the tag is auto close
"""
if 'async' in kw:
_logger.warning("Using deprecated argument 'async' in to_node call, use 'async_load' instead.")
async_load = kw['async']
response = []
if debug == 'assets':
if css and self.stylesheets:
is_css_preprocessed, old_attachments = self.is_css_preprocessed()
if not is_css_preprocessed:
self.preprocess_css(debug=debug, old_attachments=old_attachments)
if self.css_errors:
msg = '\n'.join(self.css_errors)
response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_node())
response.append(StylesheetAsset(self, url="/web/static/lib/bootstrap/css/bootstrap.css").to_node())
if not self.css_errors:
for style in self.stylesheets:
response.append(style.to_node())
if js:
for jscript in self.javascripts:
response.append(jscript.to_node())
else:
if css and self.stylesheets:
css_attachments = self.css() or []
for attachment in css_attachments:
attr = OrderedDict([
["type", "text/css"],
["rel", "stylesheet"],
["href", attachment.url],
])
response.append(("link", attr, None))
if self.css_errors:
msg = '\n'.join(self.css_errors)
response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_node())
if js and self.javascripts:
attr = OrderedDict([
["async", "async" if async_load else None],
["type", "text/javascript"],
["src", self.js().url],
])
response.append(("script", attr, None))
return response
@func.lazy_property
def last_modified(self):
"""Returns last modified date of linked files"""
return max(itertools.chain(
(asset.last_modified for asset in self.javascripts),
(asset.last_modified for asset in self.stylesheets),
))
@func.lazy_property
def version(self):
return self.checksum[0:7]
@func.lazy_property
def checksum(self):
"""
Not really a full checksum.
We compute a SHA1 on the rendered bundle + max linked files last_modified date
"""
check = u"%s%s" % (json.dumps(self.files, sort_keys=True), self.last_modified)
return hashlib.sha1(check.encode('utf-8')).hexdigest()
def clean_attachments(self, type):
""" Takes care of deleting any outdated ir.attachment records associated to a bundle before
saving a fresh one.
When `type` is css we need to check that we are deleting a different version (and not *any*
version) because css may be paginated and, therefore, may produce multiple attachments for
the same bundle's version.
When `type` is js we need to check that we are deleting a different version (and not *any*
version) because, as one of the creates in `save_attachment` can trigger a rollback, the
call to `clean_attachments ` is made at the end of the method in order to avoid the rollback
of an ir.attachment unlink (because we cannot rollback a removal on the filestore), thus we
must exclude the current bundle.
"""
ira = self.env['ir.attachment']
domain = [
('url', '=like', '/web/content/%-%/{0}%.{1}'.format(self.name, type)), # The wilcards are id, version and pagination number (if any)
'!', ('url', '=like', '/web/content/%-{}/%'.format(self.version))
]
# force bundle invalidation on other workers
self.env['ir.qweb'].clear_caches()
return ira.sudo().search(domain).unlink()
def get_attachments(self, type, ignore_version=False):
""" Return the ir.attachment records for a given bundle. This method takes care of mitigating
an issue happening when parallel transactions generate the same bundle: while the file is not
duplicated on the filestore (as it is stored according to its hash), there are multiple
ir.attachment records referencing the same version of a bundle. As we don't want to source
multiple time the same bundle in our `to_html` function, we group our ir.attachment records
by file name and only return the one with the max id for each group.
"""
version = "%" if ignore_version else self.version
url_pattern = '/web/content/%-{0}/{1}{2}.{3}'.format(version, self.name, '.%' if type == 'css' else '', type)
self.env.cr.execute("""
SELECT max(id)
FROM ir_attachment
WHERE url like %s
GROUP BY datas_fname
ORDER BY datas_fname
""", [url_pattern])
attachment_ids = [r[0] for r in self.env.cr.fetchall()]
return self.env['ir.attachment'].sudo().browse(attachment_ids)
def save_attachment(self, type, content, inc=None):
assert type in ('js', 'css')
ira = self.env['ir.attachment']
fname = '%s%s.%s' % (self.name, ('' if inc is None else '.%s' % inc), type)
mimetype = 'application/javascript' if type == 'js' else 'text/css'
values = {
'name': "/web/content/%s" % type,
'datas_fname': fname,
'mimetype' : mimetype,
'res_model': 'ir.ui.view',
'res_id': False,
'type': 'binary',
'public': True,
'datas': base64.b64encode(content.encode('utf8')),
}
attachment = ira.sudo().create(values)
url = '/web/content/%s-%s/%s' % (attachment.id, self.version, fname)
values = {
'name': url,
'url': url,
}
attachment.write(values)
if self.env.context.get('commit_assetsbundle') is True:
self.env.cr.commit()
self.clean_attachments(type)
return attachment
def js(self):
attachments = self.get_attachments('js')
if not attachments:
content = ';\n'.join(asset.minify() for asset in self.javascripts)
return self.save_attachment('js', content)
return attachments[0]
def css(self):
attachments = self.get_attachments('css')
if not attachments:
# get css content
css = self.preprocess_css()
if self.css_errors:
return self.get_attachments('css', ignore_version=True)
# move up all @import rules to the top
matches = []
css = re.sub(self.rx_css_import, lambda matchobj: matches.append(matchobj.group(0)) and '', css)
matches.append(css)
css = u'\n'.join(matches)
# split for browser max file size and browser max expression
re_rules = '([^{]+\{(?:[^{}]|\{[^{}]*\})*\})'
re_selectors = '()(?:\s*@media\s*[^{]*\{)?(?:\s*(?:[^,{]*(?:,|\{(?:[^}]*\}))))'
page = []
pages = [page]
page_selectors = 0
for rule in re.findall(re_rules, css):
selectors = len(re.findall(re_selectors, rule))
if page_selectors + selectors <= self.max_css_rules:
page_selectors += selectors
page.append(rule)
else:
pages.append([rule])
page = pages[-1]
page_selectors = selectors
for idx, page in enumerate(pages):
self.save_attachment("css", ' '.join(page), inc=idx)
attachments = self.get_attachments('css')
return attachments
def dialog_message(self, message):
return """
(function (message) {
if (window.__assetsBundleErrorSeen) return;
window.__assetsBundleErrorSeen = true;
document.addEventListener("DOMContentLoaded", function () {
var alertTimeout = setTimeout(alert.bind(window, message), 0);
if (typeof flectra === "undefined") return;
flectra.define("AssetsBundle.ErrorMessage", function (require) {
"use strict";
var base = require("web_editor.base");
var core = require("web.core");
var Dialog = require("web.Dialog");
var _t = core._t;
clearTimeout(alertTimeout);
base.ready().then(function () {
new Dialog(null, {
title: _t("Style error"),
$content: $("<div/>")
.append($("<p/>", {text: _t("The style compilation failed, see the error below. Your recent actions may be the cause, please try reverting the changes you made.")}))
.append($("<pre/>", {html: message})),
}).open();
});
});
});
})("%s");
""" % message.replace('"', '\\"').replace('\n', '&NewLine;')
def is_css_preprocessed(self):
preprocessed = True
attachments = None
for atype in (SassStylesheetAsset, LessStylesheetAsset):
outdated = False
assets = dict((asset.html_url, asset) for asset in self.stylesheets if isinstance(asset, atype))
if assets:
assets_domain = [('url', 'in', list(assets.keys()))]
attachments = self.env['ir.attachment'].sudo().search(assets_domain)
for attachment in attachments:
asset = assets[attachment.url]
if asset.last_modified > fields.Datetime.from_string(attachment['__last_update']):
outdated = True
break
if asset._content is None:
asset._content = attachment.datas and base64.b64decode(attachment.datas).decode('utf8') or ''
if not asset._content and attachment.file_size > 0:
asset._content = None # file missing, force recompile
if any(asset._content is None for asset in assets.values()):
outdated = True
if outdated:
preprocessed = False
return preprocessed, attachments
def preprocess_css(self, debug=False, old_attachments=None):
"""
Checks if the bundle contains any sass/less content, then compiles it to css.
Returns the bundle's flat css.
"""
for atype in (SassStylesheetAsset, LessStylesheetAsset):
assets = [asset for asset in self.stylesheets if isinstance(asset, atype)]
if assets:
cmd = assets[0].get_command()
source = '\n'.join([asset.get_source() for asset in assets])
compiled = self.compile_css(cmd, source)
if not self.css_errors and old_attachments:
old_attachments.unlink()
fragments = self.rx_css_split.split(compiled)
at_rules = fragments.pop(0)
if at_rules:
# Sass and less moves @at-rules to the top in order to stay css 2.1 compatible
self.stylesheets.insert(0, StylesheetAsset(self, inline=at_rules))
while fragments:
asset_id = fragments.pop(0)
asset = next(asset for asset in self.stylesheets if asset.id == asset_id)
asset._content = fragments.pop(0)
if debug:
try:
fname = os.path.basename(asset.url)
url = asset.html_url
with self.env.cr.savepoint():
self.env['ir.attachment'].sudo().create(dict(
datas=base64.b64encode(asset.content.encode('utf8')),
mimetype='text/css',
type='binary',
name=url,
url=url,
datas_fname=fname,
res_model=False,
res_id=False,
))
if self.env.context.get('commit_assetsbundle') is True:
self.env.cr.commit()
except psycopg2.Error:
pass
return '\n'.join(asset.minify() for asset in self.stylesheets)
def compile_css(self, cmd, source):
"""Sanitizes @import rules, remove duplicates @import rules, then compile"""
imports = []
def sanitize(matchobj):
ref = matchobj.group(2)
line = '@import "%s"%s' % (ref, matchobj.group(3))
if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')):
imports.append(line)
return line
msg = "Local import '%s' is forbidden for security reasons." % ref
_logger.warning(msg)
self.css_errors.append(msg)
return ''
source = re.sub(self.rx_preprocess_imports, sanitize, source)
try:
compiler = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
except Exception:
msg = "Could not execute command %r" % cmd[0]
_logger.error(msg)
self.css_errors.append(msg)
return ''
result = compiler.communicate(input=source.encode('utf-8'))
if compiler.returncode:
cmd_output = ''.join(misc.ustr(result))
if not cmd_output:
cmd_output = "Process exited with return code %d\n" % compiler.returncode
error = self.get_preprocessor_error(cmd_output, source=source)
_logger.warning(error)
self.css_errors.append(error)
return ''
compiled = result[0].strip().decode('utf8')
return compiled
def get_preprocessor_error(self, stderr, source=None):
"""Improve and remove sensitive information from sass/less compilator error messages"""
error = misc.ustr(stderr).split('Load paths')[0].replace(' Use --trace for backtrace.', '')
if 'Cannot load compass' in error:
error += "Maybe you should install the compass gem using this extra argument:\n\n" \
" $ sudo gem install compass --pre\n"
error += "This error occured while compiling the bundle '%s' containing:" % self.name
for asset in self.stylesheets:
if isinstance(asset, PreprocessedCSS):
error += '\n - %s' % (asset.url if asset.url else '<inline sass>')
return error
class WebAsset(object):
html_url_format = '%s'
_content = None
_filename = None
_ir_attach = None
_id = None
def __init__(self, bundle, inline=None, url=None, filename=None):
self.bundle = bundle
self.inline = inline
self._filename = filename
self.url = url
self.html_url_args = url
if not inline and not url:
raise Exception("An asset should either be inlined or url linked, defined in bundle '%s'" % bundle.name)
@func.lazy_property
def id(self):
if self._id is None: self._id = str(uuid.uuid4())
return self._id
@func.lazy_property
def name(self):
name = '<inline asset>' if self.inline else self.url
return "%s defined in bundle '%s'" % (name, self.bundle.name)
@property
def html_url(self):
return self.html_url_format % self.html_url_args
def stat(self):
if not (self.inline or self._filename or self._ir_attach):
path = (segment for segment in self.url.split('/') if segment)
self._filename = get_resource_path(*path)
if self._filename:
return
try:
# Test url against ir.attachments
fields = ['__last_update', 'datas', 'mimetype']
domain = [('type', '=', 'binary'), ('url', '=', self.url)]
attach = self.bundle.env['ir.attachment'].sudo().search_read(domain, fields)
self._ir_attach = attach[0]
except Exception:
raise AssetNotFound("Could not find %s" % self.name)
# depreciated and will remove after v1
def to_html(self):
tagName, attributes, content = self.to_node()
html = u"<%s " % tagName
for name, value in attributes.items():
if value or isinstance(value, string_types):
html += u' %s="%s"' % (name, escape(to_text(value)))
if content is None:
html += u'/>'
else:
html += u'>%s</%s>' % (escape(to_text(content)), tagName)
return html
def to_node(self):
raise NotImplementedError()
@func.lazy_property
def last_modified(self):
try:
self.stat()
if self._filename:
return datetime.fromtimestamp(os.path.getmtime(self._filename))
elif self._ir_attach:
server_format = tools.DEFAULT_SERVER_DATETIME_FORMAT
last_update = self._ir_attach['__last_update']
try:
return datetime.strptime(last_update, server_format + '.%f')
except ValueError:
return datetime.strptime(last_update, server_format)
except Exception:
pass
return datetime(1970, 1, 1)
@property
def content(self):
if self._content is None:
self._content = self.inline or self._fetch_content()
return self._content
def _fetch_content(self):
""" Fetch content from file or database"""
try:
self.stat()
if self._filename:
with open(self._filename, 'rb') as fp:
return fp.read().decode('utf-8')
else:
return base64.b64decode(self._ir_attach['datas']).decode('utf-8')
except UnicodeDecodeError:
raise AssetError('%s is not utf-8 encoded.' % self.name)
except IOError:
raise AssetNotFound('File %s does not exist.' % self.name)
except:
raise AssetError('Could not get content for %s.' % self.name)
def minify(self):
return self.content
def with_header(self, content=None):
if content is None:
content = self.content
return '\n/* %s */\n%s' % (self.name, content)
class JavascriptAsset(WebAsset):
def minify(self):
return self.with_header(rjsmin(self.content))
def _fetch_content(self):
try:
return super(JavascriptAsset, self)._fetch_content()
except AssetError as e:
return u"console.error(%s);" % json.dumps(to_text(e))
def to_node(self):
if self.url:
return ("script", OrderedDict([
["type", "text/javascript"],
["src", self.html_url],
]), None)
else:
return ("script", OrderedDict([
["type", "text/javascript"],
["charset", "utf-8"],
]), self.with_header())
class StylesheetAsset(WebAsset):
rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
def __init__(self, *args, **kw):
self.media = kw.pop('media', None)
super(StylesheetAsset, self).__init__(*args, **kw)
@property
def content(self):
content = super(StylesheetAsset, self).content
if self.media:
content = '@media %s { %s }' % (self.media, content)
return content
def _fetch_content(self):
try:
content = super(StylesheetAsset, self)._fetch_content()
web_dir = os.path.dirname(self.url)
if self.rx_import:
content = self.rx_import.sub(
r"""@import \1%s/""" % (web_dir,),
content,
)
if self.rx_url:
content = self.rx_url.sub(
r"url(\1%s/" % (web_dir,),
content,
)
if self.rx_charset:
# remove charset declarations, we only support utf-8
content = self.rx_charset.sub('', content)
return content
except AssetError as e:
self.bundle.css_errors.append(str(e))
return ''
def minify(self):
# remove existing sourcemaps, make no sense after re-mini
content = self.rx_sourceMap.sub('', self.content)
# comments
content = re.sub(r'/\*.*?\*/', '', content, flags=re.S)
# space
content = re.sub(r'\s+', ' ', content)
content = re.sub(r' *([{}]) *', r'\1', content)
return self.with_header(content)
def to_node(self):
if self.url:
attr = OrderedDict([
["type", "text/css"],
["rel", "stylesheet"],
["href", self.html_url],
["media", escape(to_text(self.media)) if self.media else None]
])
return ("link", attr, None)
else:
attr = OrderedDict([
["type", "text/css"],
["media", escape(to_text(self.media)) if self.media else None]
])
return ("style", attr, self.with_header())
class PreprocessedCSS(StylesheetAsset):
rx_import = None
def __init__(self, *args, **kw):
super(PreprocessedCSS, self).__init__(*args, **kw)
self.html_url_format = '%%s/%s/%%s.css' % self.bundle.name
self.html_url_args = tuple(self.url.rsplit('/', 1))
def get_source(self):
content = self.inline or self._fetch_content()
return "/*! %s */\n%s" % (self.id, content)
def get_command(self):
raise NotImplementedError
class SassStylesheetAsset(PreprocessedCSS):
rx_indent = re.compile(r'^( +|\t+)', re.M)
indent = None
reindent = ' '
def minify(self):
return self.with_header()
def get_source(self):
content = textwrap.dedent(self.inline or self._fetch_content())
def fix_indent(m):
# Indentation normalization
ind = m.group()
if self.indent is None:
self.indent = ind
if self.indent == self.reindent:
# Don't reindent the file if identation is the final one (reindent)
raise StopIteration()
return ind.replace(self.indent, self.reindent)
try:
content = self.rx_indent.sub(fix_indent, content)
except StopIteration:
pass
return "/*! %s */\n%s" % (self.id, content)
def get_command(self):
try:
sass = misc.find_in_path('sass')
except IOError:
sass = 'sass'
return [sass, '--stdin', '-t', 'compressed', '--unix-newlines', '--compass',
'-r', 'bootstrap-sass']
class LessStylesheetAsset(PreprocessedCSS):
def get_command(self):
try:
if os.name == 'nt':
lessc = misc.find_in_path('lessc.cmd')
else:
lessc = misc.find_in_path('lessc')
except IOError:
lessc = 'lessc'
lesspath = get_resource_path('web', 'static', 'lib', 'bootstrap', 'less')
return [lessc, '-', '--no-js', '--no-color', '--include-path=%s' % lesspath]