T53890: Improving static content serving
Static files are now served with an 8-character hash before the last extension. For example, `tutti.min.js` is now served as `tutti.min.abcd1234.js`. When doing a request the hash is removed before serving the static file. The hash must be 8 characters long, and is taken from STATIC_FILE_HASH. It is up to the deployment to change this configuration variable whenever static files change. This forces browsers that download newly deployed HTML to also refresh the dependencies (most importantly JS/CSS). For this to work, the URL must be built with `url_for('static_xxx', filename='/path/to/file')`. The 'static' module still returns regular, hashless URLs.
This commit is contained in:
parent
0cf45c0d78
commit
12272750c3
@ -101,6 +101,7 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
self.log = logging.getLogger('%s.%s' % (__name__, self.__class__.__name__))
|
self.log = logging.getLogger('%s.%s' % (__name__, self.__class__.__name__))
|
||||||
self.log.info('Creating new instance from %r', self.app_root)
|
self.log.info('Creating new instance from %r', self.app_root)
|
||||||
|
|
||||||
|
self._config_url_map()
|
||||||
self._config_auth_token_hmac_key()
|
self._config_auth_token_hmac_key()
|
||||||
self._config_tempdirs()
|
self._config_tempdirs()
|
||||||
self._config_git()
|
self._config_git()
|
||||||
@ -171,6 +172,19 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
if self.config['DEBUG']:
|
if self.config['DEBUG']:
|
||||||
log.info('Pillar starting, debug=%s', self.config['DEBUG'])
|
log.info('Pillar starting, debug=%s', self.config['DEBUG'])
|
||||||
|
|
||||||
|
def _config_url_map(self):
|
||||||
|
"""Extend Flask url_map with our own converters."""
|
||||||
|
import secrets, re
|
||||||
|
from . import flask_extra
|
||||||
|
|
||||||
|
if not self.config.get('STATIC_FILE_HASH'):
|
||||||
|
self.log.warning('STATIC_FILE_HASH is empty, generating random one')
|
||||||
|
f = open('/data/git/blender-cloud/config_local.py', 'a')
|
||||||
|
h = re.sub(r'[_.~-]', '', secrets.token_urlsafe())[:8]
|
||||||
|
self.config['STATIC_FILE_HASH'] = h
|
||||||
|
|
||||||
|
self.url_map.converters['hashed_path'] = flask_extra.HashedPathConverter
|
||||||
|
|
||||||
def _config_auth_token_hmac_key(self):
|
def _config_auth_token_hmac_key(self):
|
||||||
"""Load AUTH_TOKEN_HMAC_KEY, falling back to SECRET_KEY."""
|
"""Load AUTH_TOKEN_HMAC_KEY, falling back to SECRET_KEY."""
|
||||||
|
|
||||||
@ -529,7 +543,7 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
from pillar.web.staticfile import PillarStaticFile
|
from pillar.web.staticfile import PillarStaticFile
|
||||||
|
|
||||||
view_func = PillarStaticFile.as_view(endpoint_name, static_folder=static_folder)
|
view_func = PillarStaticFile.as_view(endpoint_name, static_folder=static_folder)
|
||||||
self.add_url_rule('%s/<path:filename>' % url_prefix, view_func=view_func)
|
self.add_url_rule(f'{url_prefix}/<hashed_path:filename>', view_func=view_func)
|
||||||
|
|
||||||
def process_extensions(self):
|
def process_extensions(self):
|
||||||
"""This is about Eve extensions, not Pillar extensions."""
|
"""This is about Eve extensions, not Pillar extensions."""
|
||||||
|
@ -247,3 +247,11 @@ SMTP_TIMEOUT = 30 # timeout in seconds, https://docs.python.org/3/library/smtpl
|
|||||||
MAIL_RETRY = 180 # in seconds, delay until trying to send an email again.
|
MAIL_RETRY = 180 # in seconds, delay until trying to send an email again.
|
||||||
MAIL_DEFAULT_FROM_NAME = 'Blender Cloud'
|
MAIL_DEFAULT_FROM_NAME = 'Blender Cloud'
|
||||||
MAIL_DEFAULT_FROM_ADDR = 'cloudsupport@localhost'
|
MAIL_DEFAULT_FROM_ADDR = 'cloudsupport@localhost'
|
||||||
|
|
||||||
|
SEND_FILE_MAX_AGE_DEFAULT = 3600 * 24 * 365 # seconds
|
||||||
|
|
||||||
|
# MUST be 8 characters long, see pillar.flask_extra.HashedPathConverter
|
||||||
|
# Intended to be changed for every deploy. If it is empty, a random hash will
|
||||||
|
# be used. Note that this causes extra traffic, since every time the process
|
||||||
|
# restarts the URLs will be different.
|
||||||
|
STATIC_FILE_HASH = ''
|
||||||
|
@ -1,5 +1,34 @@
|
|||||||
|
import re
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
|
import werkzeug.routing
|
||||||
|
|
||||||
|
|
||||||
|
class HashedPathConverter(werkzeug.routing.PathConverter):
|
||||||
|
"""Allows for files `xxx.yyy.js` to be served as `xxx.yyy.abc123.js`.
|
||||||
|
|
||||||
|
The hash code is placed before the last extension.
|
||||||
|
"""
|
||||||
|
weight = 300
|
||||||
|
# Hash length is hard-coded to 8 characters for now.
|
||||||
|
hash_re = re.compile(r'\.([a-zA-Z0-9]{8})(?=\.[^.]+$)')
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1024)
|
||||||
|
def to_python(self, from_url: str) -> str:
|
||||||
|
return self.hash_re.sub('', from_url)
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1024)
|
||||||
|
def to_url(self, filepath: str) -> str:
|
||||||
|
try:
|
||||||
|
dotidx = filepath.rindex('.')
|
||||||
|
except ValueError:
|
||||||
|
# Happens when there is no dot. Very unlikely.
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
current_hash = flask.current_app.config['STATIC_FILE_HASH']
|
||||||
|
before, after = filepath[:dotidx], filepath[dotidx:]
|
||||||
|
return f'{before}.{current_hash}{after}'
|
||||||
|
|
||||||
|
|
||||||
def add_response_headers(headers: dict):
|
def add_response_headers(headers: dict):
|
||||||
|
@ -42,3 +42,6 @@ ELASTIC_INDICES = {
|
|||||||
'NODE': 'test_nodes',
|
'NODE': 'test_nodes',
|
||||||
'USER': 'test_users',
|
'USER': 'test_users',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# MUST be 8 characters long, see pillar.flask_extra.HashedPathConverter
|
||||||
|
STATIC_FILE_HASH = 'abcd1234'
|
||||||
|
@ -1,12 +1,21 @@
|
|||||||
"""Static file handling"""
|
"""Static file handling"""
|
||||||
|
import logging
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
import flask.views
|
import flask.views
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PillarStaticFile(flask.views.MethodView):
|
class PillarStaticFile(flask.views.MethodView):
|
||||||
def __init__(self, static_folder):
|
def __init__(self, static_folder):
|
||||||
self.static_folder = static_folder
|
self.static_folder = static_folder
|
||||||
|
|
||||||
def get(self, filename):
|
def get(self, filename):
|
||||||
|
log.debug('Request file %s/%s', self.static_folder, filename)
|
||||||
return flask.send_from_directory(self.static_folder, filename)
|
return flask.send_from_directory(self.static_folder, filename)
|
||||||
|
return flask.send_from_directory(
|
||||||
|
self.static_folder, filename,
|
||||||
|
conditional=True,
|
||||||
|
add_etags=True,
|
||||||
|
)
|
||||||
|
@ -39,8 +39,8 @@ html(lang="en")
|
|||||||
|
|
||||||
loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400" );
|
loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400" );
|
||||||
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js', v=17320171) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js') }}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js', v=17320171) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
|
||||||
|
|
||||||
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
|
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
|
||||||
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
|
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
|
||||||
@ -50,12 +50,12 @@ html(lang="en")
|
|||||||
| {% block head %}{% endblock %}
|
| {% block head %}{% endblock %}
|
||||||
|
|
||||||
| {% block css %}
|
| {% block css %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css', v=17320171) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/base.css', v=17320171) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
|
||||||
| {% if title == 'blog' %}
|
| {% if title == 'blog' %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css', v=17320171) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||||
| {% else %}
|
| {% else %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/main.css', v=17320171) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/main.css') }}", rel="stylesheet")
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ html(lang="en")
|
|||||||
| {% endblock footer %}
|
| {% endblock footer %}
|
||||||
| {% endblock footer_container%}
|
| {% endblock footer_container%}
|
||||||
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.bootstrap-3.3.7.min.js', v=17320171) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.bootstrap-3.3.7.min.js') }}")
|
||||||
|
|
||||||
|
|
||||||
| {% block footer_scripts_pre %}{% endblock %}
|
| {% block footer_scripts_pre %}{% endblock %}
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
| {% block css %}
|
| {% block css %}
|
||||||
| {{ super() }}
|
| {{ super() }}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css', v=17320171) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block project_context %}
|
| {% block project_context %}
|
||||||
|
@ -21,7 +21,7 @@ meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
|
|||||||
|
|
||||||
| {% block css %}
|
| {% block css %}
|
||||||
| {{ super() }}
|
| {{ super() }}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css', v=17320171) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block project_context %}
|
| {% block project_context %}
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
|
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
| {% block head %}
|
| {% block head %}
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js', v=9112017) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js') }}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js', v=9112017) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js') }}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js', v=9112017) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js') }}")
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block og %}
|
| {% block og %}
|
||||||
|
@ -66,20 +66,20 @@ meta(property="og:url", content="{{url_for('projects.view', project_url=project.
|
|||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block head %}
|
| {% block head %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/jstree/themes/default/style.min.css', v=9112017) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/jstree/themes/default/style.min.css') }}", rel="stylesheet")
|
||||||
| {% if node %}
|
| {% if node %}
|
||||||
link(rel="amphtml", href="{{ url_for('nodes.view', node_id=node._id, _external=True, format='amp') }}")
|
link(rel="amphtml", href="{{ url_for('nodes.view', node_id=node._id, _external=True, format='amp') }}")
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js', v=9112017) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js') }}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js', v=9112017) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js') }}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js', v=9112017) }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js') }}")
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block css %}
|
| {% block css %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css', v=9112017) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/base.css', v=9112017) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/project-main.css', v=9112017) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/project-main.css') }}", rel="stylesheet")
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block body %}
|
| {% block body %}
|
||||||
@ -280,7 +280,7 @@ link(href="{{ url_for('static_pillar', filename='assets/css/project-main.css', v
|
|||||||
|
|
||||||
| {% if project.has_method('PUT') %}
|
| {% if project.has_method('PUT') %}
|
||||||
| {# JS containing the Edit, Add, Featured, and Move functions #}
|
| {# JS containing the Edit, Add, Featured, and Move functions #}
|
||||||
script(type="text/javascript", src="{{ url_for('static_pillar', filename='assets/js/project-edit.min.js', v=9112017) }}")
|
script(type="text/javascript", src="{{ url_for('static_pillar', filename='assets/js/project-edit.min.js') }}")
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
script.
|
script.
|
||||||
|
@ -29,8 +29,8 @@ li
|
|||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block css %}
|
| {% block css %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css', v=171020161) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/theatre.css', v=171020161) }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/theatre.css') }}", rel="stylesheet")
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block body %}
|
| {% block body %}
|
||||||
|
@ -2,6 +2,8 @@ import unittest
|
|||||||
|
|
||||||
import flask
|
import flask
|
||||||
|
|
||||||
|
from pillar.tests import AbstractPillarTest
|
||||||
|
|
||||||
|
|
||||||
class FlaskExtraTest(unittest.TestCase):
|
class FlaskExtraTest(unittest.TestCase):
|
||||||
def test_vary_xhr(self):
|
def test_vary_xhr(self):
|
||||||
@ -84,3 +86,25 @@ class EnsureSchemaTest(unittest.TestCase):
|
|||||||
self.assertEqual('/some/path/only', pillar.flask_extra.ensure_schema('/some/path/only'))
|
self.assertEqual('/some/path/only', pillar.flask_extra.ensure_schema('/some/path/only'))
|
||||||
self.assertEqual('https://hostname/path',
|
self.assertEqual('https://hostname/path',
|
||||||
pillar.flask_extra.ensure_schema('//hostname/path'))
|
pillar.flask_extra.ensure_schema('//hostname/path'))
|
||||||
|
|
||||||
|
|
||||||
|
class HashedPathConverterTest(AbstractPillarTest):
|
||||||
|
def test_to_python(self):
|
||||||
|
from pillar.flask_extra import HashedPathConverter
|
||||||
|
|
||||||
|
hpc = HashedPathConverter({})
|
||||||
|
self.assertEqual('/path/to/file.min.js', hpc.to_python('/path/to/file.min.abcd1234.js'))
|
||||||
|
self.assertEqual('/path/to/file.js', hpc.to_python('/path/to/file.abcd1234.js'))
|
||||||
|
self.assertEqual('/path/to/file', hpc.to_python('/path/to/file'))
|
||||||
|
self.assertEqual('', hpc.to_python(''))
|
||||||
|
|
||||||
|
def test_to_url(self):
|
||||||
|
from pillar.flask_extra import HashedPathConverter
|
||||||
|
|
||||||
|
hpc = HashedPathConverter({})
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual('/path/to/file.min.abcd1234.js', hpc.to_url('/path/to/file.min.js'))
|
||||||
|
self.assertEqual('/path/to/file.abcd1234.js', hpc.to_url('/path/to/file.js'))
|
||||||
|
self.assertEqual('/path/to/file', hpc.to_url('/path/to/file'))
|
||||||
|
self.assertEqual('', hpc.to_url(''))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user