# -*- coding: utf-8 -*- import base64 import os import re import hashlib import itertools import json import textwrap import uuid try: import sass as libsass except ImportError: # If the `sass` python library isn't found, we fallback on the # `sassc` executable in the path. libsass = None from datetime import datetime from subprocess import Popen, PIPE from collections import OrderedDict from odoo import fields, tools, SUPERUSER_ID from odoo.tools.pycompat import to_text from odoo.http import request from odoo.modules.module import get_resource_path from .qweb import escape import psycopg2 from odoo.tools import func, misc import logging _logger = logging.getLogger(__name__) class CompileError(RuntimeError): pass 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.javascripts = [] self.stylesheets = [] self.css_errors = [] self._checksum = None self.files = files self.user_direction = self.env['res.lang'].search( [('code', '=', (self.env.context.get('lang') or self.env.user.lang))] ).direction 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'], direction=self.user_direction)) elif f['atype'] == 'text/scss': self.stylesheets.append(ScssStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) elif f['atype'] == 'text/less': self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) elif f['atype'] == 'text/css': self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) 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)): 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, str): 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): """ :returns [(tagName, attributes, content)] if the tag is auto close """ 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 _get_asset_template_url(self): return "/web/content/{id}-{unique}/{extra}{name}{sep}{type}" def _get_asset_url_values(self, id, unique, extra, name, sep, type): # extra can contain direction or/and website return { 'id': id, 'unique': unique, 'extra': extra, 'name': name, 'sep': sep, 'type': type, } def get_asset_url(self, id='%', unique='%', extra='', name='%', sep="%", type='%'): return self._get_asset_template_url().format( **self._get_asset_url_values(id=id, unique=unique, extra=extra, name=name, sep=sep, type=type) ) 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 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'] url = self.get_asset_url( extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''), name=self.name, type=type ) domain = [ ('url', '=like', url), '!', ('url', '=like', self.get_asset_url(unique=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. """ unique = "%" if ignore_version else self.version url_pattern = self.get_asset_url( unique=unique, extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''), name=self.name, sep='', type='.%s' % type ) self.env.cr.execute(""" SELECT max(id) FROM ir_attachment WHERE create_uid = %s AND url like %s GROUP BY datas_fname ORDER BY datas_fname """, [SUPERUSER_ID, 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): assert type in ('js', 'css') ira = self.env['ir.attachment'] # Set user direction in name to store two bundles # 1 for ltr and 1 for rtl, this will help during cleaning of assets bundle # and allow to only clear the current direction bundle # (this applies to css bundles only) fname = '%s.%s' % (self.name, 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 = self.get_asset_url( id=attachment.id, unique=self.version, extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''), name=fname, sep='', # included in fname type='' ) 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) self.save_attachment("css", css) attachments = self.get_attachments('css') return attachments def dialog_message(self, message): return """ (function (message) { if (window.__assetsBundleErrorSeen) return; window.__assetsBundleErrorSeen = true; var loaded = function () { clearTimeout(loadedTimeout); var alertTimeout = setTimeout(alert.bind(window, message), 0); var odoo = window.top.odoo; if (!odoo || !odoo.define) return; odoo.define("AssetsBundle.ErrorMessage", function (require) { "use strict"; require('web.dom_ready'); var core = require("web.core"); var Dialog = require("web.Dialog"); var _t = core._t; clearTimeout(alertTimeout); new Dialog(null, { title: _t("Style error"), $content: $("
") .append($("", {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($("", {html: message})), }).open(); }); } var loadedTimeout = setTimeout(loaded, 5000); document.addEventListener("DOMContentLoaded", loaded); })("%s"); """ % message.replace('"', '\\"').replace('\n', '
') def is_css_preprocessed(self): preprocessed = True attachments = None asset_types = [SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset] if self.user_direction == 'rtl': asset_types.append(StylesheetAsset) for atype in asset_types: 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 > 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. If user language direction is Right to Left then consider css files to call run_rtlcss, css files are also stored in ir.attachment after processing done by rtlcss. Returns the bundle's flat css. """ if self.stylesheets: compiled = "" for atype in (SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset): assets = [asset for asset in self.stylesheets if isinstance(asset, atype)] if assets: source = '\n'.join([asset.get_source() for asset in assets]) compiled += self.compile_css(assets[0].compile, source) # We want to run rtlcss on normal css, so merge it in compiled if self.user_direction == 'rtl': stylesheet_assets = [asset for asset in self.stylesheets if not isinstance(asset, (SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset))] compiled += '\n'.join([asset.get_source() for asset in stylesheet_assets]) compiled = self.run_rtlcss(compiled) if not self.css_errors and old_attachments: old_attachments.unlink() old_attachments = None 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, compiler, source): """Sanitizes @import rules, remove duplicates @import rules, then compile""" imports = [] def handle_compile_error(e, source): error = self.get_preprocessor_error(e, source=source) _logger.warning(error) self.css_errors.append(error) return '' 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. Please remove all @import {your_file} imports in your custom files. In Odoo you have to import all files in the assets, and not through the @import statement." % ref _logger.warning(msg) self.css_errors.append(msg) return '' source = re.sub(self.rx_preprocess_imports, sanitize, source) compiled = '' try: compiled = compiler(source) except CompileError as e: return handle_compile_error(e, source=source) compiled = compiled.strip() # Post process the produced css to add required vendor prefixes here compiled = re.sub(r'(appearance: (\w+);)', r'-webkit-appearance: \2; -moz-appearance: \2; \1', compiled); compiled = re.sub(r'(display: ((?:inline-)?)flex((?: ?!important)?);)', r'display: -webkit-\2box\3; display: -webkit-\2flex\3; \1', compiled) # For PhantomJS tests and wkhtmltopdf compiled = re.sub(r'(justify-content: flex-(\w+)((?: ?!important)?);)', r'-webkit-box-pack: \2\3; \1', compiled) # For wkhtmltopdf compiled = re.sub(r'(flex-flow: (\w+ \w+);)', r'-webkit-flex-flow: \2; \1', compiled) # For PhantomJS tests compiled = re.sub(r'(flex-direction: (column);)', r'-webkit-box-orient: vertical; -webkit-box-direction: normal; -webkit-flex-direction: \2; \1', compiled) # For wkhtmltopdf compiled = re.sub(r'(flex: ((\d)+ \d+ (?:\d+|auto));)', r'-webkit-box-flex: \3; -webkit-flex: \2; \1', compiled) # For PhantomJS tests and wkhtmltopdf return compiled def run_rtlcss(self, source): rtlcss = 'rtlcss' if os.name == 'nt': try: rtlcss = misc.find_in_path('rtlcss.cmd') except IOError: rtlcss = 'rtlcss' cmd = [rtlcss, '-'] try: rtlcss = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) except Exception: # Check the presence of rtlcss, if rtlcss not available then we should return normal less file try: process = Popen( ['rtlcss', '--version'], stdout=PIPE, stderr=PIPE ) except (OSError, IOError): _logger.warning('You need https://rtlcss.com/ to convert css file to right to left compatiblity. Use: npm install -g rtlcss') return source msg = "Could not execute command %r" % cmd[0] _logger.error(msg) self.css_errors.append(msg) return '' result = rtlcss.communicate(input=source.encode('utf-8')) if rtlcss.returncode: cmd_output = ''.join(misc.ustr(result)) if not cmd_output: cmd_output = "Process exited with return code %d\n" % rtlcss.returncode error = self.get_rtlcss_error(cmd_output, source=source) _logger.warning(error) self.css_errors.append(error) return '' rtlcss_result = result[0].strip().decode('utf8') return rtlcss_result 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 '