diff --git a/.pylintrc b/.pylintrc index fba35186cc..fcf0968f10 100644 --- a/.pylintrc +++ b/.pylintrc @@ -57,7 +57,8 @@ confidence= disable=fixme, missing-docstring, invalid-name, - too-many-lines + too-many-lines, + old-style-class # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where diff --git a/dash/_utils.py b/dash/_utils.py index 17dc247ebe..d91eab8a86 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -1,3 +1,24 @@ +import functools +import hashlib +import base64 +import pkgutil + + +def memoize(func): + results = {} + + @functools.wraps(func) + def wrapper(*args, **kwargs): + key = hash((args, frozenset(kwargs.items()))) + cached = results.get(key) + if cached: + return cached + results[key] = r = func(*args, **kwargs) + return r + + return wrapper + + def interpolate_str(template, **data): s = template for k, v in data.items(): @@ -37,6 +58,37 @@ def get_asset_path( ]) +def pluck(obj, *props, **additions): + return dict({k: v for k, v in obj.items() if k in props}, **additions) + + +def first_key(data, *keys): + for key in keys: + value = data.get(key) + if value: + return key, value + return None, None + + +def _integrity_hash(b): + h = hashlib.sha384(b) + + return 'sha384-{}'.format( + base64.b64encode(h.digest()).decode() + ) + + +@memoize +def integrity_hash_from_file(filename): + with open(filename, 'rb') as f: + return _integrity_hash(f.read()) + + +@memoize +def integrity_hash_from_package(namespace, path): + return _integrity_hash(pkgutil.get_data(namespace, path)) + + class AttributeDict(dict): """ Dictionary subclass enabling attribute lookup/assignment of keys/values. diff --git a/dash/dash.py b/dash/dash.py index 0a68d70bce..41cd7a1c79 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -25,6 +25,7 @@ from ._utils import interpolate_str as _interpolate from ._utils import format_tag as _format_tag from ._utils import get_asset_path as _get_asset_path +from ._utils import pluck as _pluck from . import _configs @@ -313,6 +314,7 @@ def _collect_and_register_resources(self, resources): def _relative_url_path(relative_package_path='', namespace=''): # track the registered packages + # FIXME it append a new path each time the index is served ? if namespace in self.registered_paths: self.registered_paths[namespace].append(relative_package_path) else: @@ -332,23 +334,34 @@ def _relative_url_path(relative_package_path='', namespace=''): modified ) + def pack(src, **kwargs): + return dict(kwargs, src=src) + srcs = [] for resource in resources: if 'relative_package_path' in resource: if isinstance(resource['relative_package_path'], str): - srcs.append(_relative_url_path(**resource)) + srcs.append( + pack( + _relative_url_path( + resource['relative_package_path'], + resource['namespace']), + **resource) + ) else: for rel_path in resource['relative_package_path']: - srcs.append(_relative_url_path( + srcs.append(pack(_relative_url_path( relative_package_path=rel_path, - namespace=resource['namespace'] - )) + namespace=resource['namespace'], + ), **resource)) elif 'external_url' in resource: if isinstance(resource['external_url'], str): - srcs.append(resource['external_url']) + srcs.append( + pack(resource['external_url'], **resource) + ) else: for url in resource['external_url']: - srcs.append(url) + srcs.append(pack(url)) elif 'absolute_path' in resource: raise Exception( 'Serving files from absolute_path isn\'t supported yet' @@ -357,7 +370,7 @@ def _relative_url_path(relative_package_path='', namespace=''): static_url = self.get_asset_url(resource['asset_path']) # Add a bust query param static_url += '?m={}'.format(resource['ts']) - srcs.append(static_url) + srcs.append(pack(static_url, **resource)) return srcs def _generate_css_dist_html(self): @@ -365,7 +378,22 @@ def _generate_css_dist_html(self): self._collect_and_register_resources(self.css.get_all_css()) return '\n'.join([ - _format_tag('link', link, opened=True) + _format_tag( + 'link', + _pluck( + link, + 'integrity', + 'crossorigin', + 'media', + 'charset', + 'rev', + 'target', + 'type', + href=link.get('src', link.get('href')), + rel='stylesheet' + ), + opened=True + ) if isinstance(link, dict) else ''.format(link) for link in links @@ -392,7 +420,15 @@ def _generate_scripts_html(self): )) return '\n'.join([ - _format_tag('script', src) + _format_tag('script', _pluck( + src, + 'src', + 'integrity', + 'crossorigin', + 'charset', + 'async', + 'defer', + 'type')) if isinstance(src, dict) else ''.format(src) for src in srcs @@ -492,7 +528,7 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument checks = ( (_re_index_entry_id.search(index), '#react-entry-point'), (_re_index_config_id.search(index), '#_dash-configs'), - (_re_index_scripts_id.search(index), 'dash-renderer'), + # (_re_index_scripts_id.search(index), 'dash-renderer'), ) missing = [missing for check, missing in checks if not check] diff --git a/dash/resources.py b/dash/resources.py index aa1ce871d9..d9a060ec0f 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -4,6 +4,27 @@ import os from .development.base_component import Component +from ._utils import \ + first_key, integrity_hash_from_file, integrity_hash_from_package + + +# pylint: disable=inconsistent-return-statements +def find_unpkg(value, relative_package_paths): + # find the local file for a unpkg url. + # The structure of the _js_dist/_css_dist does not allow + # for easy translation between local and external dependencies. + v = value.replace('https://unpkg.com/', '') + s = v.split('/') + lib, version = s[0].split('@') + filename = s[-1] + ext = filename.split('.')[-1] + + for i in relative_package_paths: + if (i == filename and i not in [ + 'index.js', 'index.css', 'style.css', 'style.min.css', + 'styles.css', 'styles.min.css']) \ + or (lib in i and version in i and i.endswith(ext)): + return i # pylint: disable=old-style-class @@ -16,14 +37,17 @@ def __init__(self, resource_name, layout): def append_resource(self, resource): self._resources.append(resource) + # pylint: disable=too-many-branches def _filter_resources(self, all_resources, dev_bundles=False): filtered_resources = [] for s in all_resources: filtered_resource = {} + added = False if 'namespace' in s: filtered_resource['namespace'] = s['namespace'] if 'external_url' in s and not self.config.serve_locally: filtered_resource['external_url'] = s['external_url'] + filtered_resource['local_file'] = s['relative_package_path'] elif 'dev_package_path' in s and dev_bundles: filtered_resource['relative_package_path'] = ( s['dev_package_path'] @@ -36,6 +60,9 @@ def _filter_resources(self, all_resources, dev_bundles=False): filtered_resource['absolute_path'] = s['absolute_path'] elif 'asset_path' in s: info = os.stat(s['filepath']) + filtered_resource['integrity'] = integrity_hash_from_file( + s['filepath']) + filtered_resource['crossorigin'] = 'anonymous' filtered_resource['asset_path'] = s['asset_path'] filtered_resource['ts'] = info.st_mtime elif self.config.serve_locally: @@ -54,7 +81,41 @@ def _filter_resources(self, all_resources, dev_bundles=False): ) ) - filtered_resources.append(filtered_resource) + if 'integrity' not in filtered_resource \ + and 'namespace' in filtered_resource: + key, filename = first_key( + filtered_resource, + 'external_url', + 'dev_package_path', + 'relative_package_path' + ) + if isinstance(filename, list): + # flatten these dependencies and add a hash for each. + for f in filename: + local_file = filtered_resource.get('local_file') + filename = find_unpkg(f, local_file) \ + if local_file else f.split('/')[-1] + filtered_resources.append({ + key: f, + 'integrity': integrity_hash_from_package( + filtered_resource['namespace'], + filename), + 'crossorigin': 'anonymous', + 'namespace': filtered_resource['namespace'] + }) + added = True + else: + filename = filtered_resource.get( + 'local_file', filename.split('/')[-1]) + filtered_resource['integrity'] = \ + integrity_hash_from_package( + filtered_resource['namespace'], + filename + ) + filtered_resource['crossorigin'] = 'anonymous' + + if not added: + filtered_resources.append(filtered_resource) return filtered_resources