Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set Cache-Control: immutable on static assets #277

Merged
merged 24 commits into from
Jul 18, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
WIP hack around static.File
  • Loading branch information
twm committed Jul 5, 2018
commit 1a5b5832edbf1fb1b1f45e9d99b65dad51ada566
52 changes: 41 additions & 11 deletions yarrharr/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ class Static(Resource):
In development, the files are served uncompressed and named like so::

main-afffb00fd22ca3ce0250.js
main-afffb00fd22ca3ce0250.js.map

The second dot-delimited section is a hash of the file's contents or source
material. As the filename changes each time the content does, these files
Expand All @@ -174,10 +173,9 @@ class Static(Resource):
In production, each file has two pre-compressed variants: one with
a ``.gz`` extension, and one with a ``.br`` extension. For example::

main-afffb00fd22ca3ce0250.js
main-afffb00fd22ca3ce0250.js.br
main-afffb00fd22ca3ce0250.js.map.br
main-afffb00fd22ca3ce0250.js.gz
main-afffb00fd22ca3ce0250.js.map.gz

The actual serving of the files is done by `twisted.web.static.File`, which
is fancy and supports range requests, conditional gets, etc.
Expand All @@ -190,26 +188,58 @@ class Static(Resource):
.. _cache-control: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
"""
_dir = FilePath(settings.STATIC_ROOT)
_validName = re.compile(br'^[a-zA-Z0-9]+\.[a-zA-Z0-9]+(\.[a-z]+)+$')
_validName = re.compile(br'^[a-zA-Z0-9]+-[a-zA-Z0-9]+(\.[a-z]+)+$')
_brToken = re.compile(br'(:?^|[\s,])br(:?$|\s,])', re.I)
_gzToken = re.compile(br'(:?^|[\s,])(:?x-)?gzip(:?$|\s,])', re.I)
_contentTypes = {
'.js': 'application/javascript',
'.css': 'text/css',
'.map': 'application/octet-stream',
'.ico': '', # TODO
'.svg': '', # TODO
'.png': 'image/png',
}

def _file(self, path, type, encoding=None):
"""
Construct a `twisted.web.static.File` customized to serve Yarrharr
static assets.

:param path: `twisted.internet.filepath.FilePath` instance
:returns: `twisted.web.resource.IResource`
"""
f = File(path.path)
f.type = type
f.encoding = encoding
return f

def getChild(self, path, request):
if not self._validName.match(path) or path.endswith((b'.gz', b'.br')):
if not self._validName.match(path):
return NoResource("Not found.")

try:
type = self._contentTypes[path[path.rindex(b'.') - 1:]]
except KeyError:
return NoResource("Unknown type.")

acceptEncoding = request.getHeader(b'accept-encoding') or b''

file = None
if self._brToken.search(acceptEncoding):
br = self._dir.child(path + b'.br')
if br.isfile():
return File(br.path, contentEncodings={'.br': 'br'})
file = self._file(br, type, 'br')

gz = self._dir.child(path + b'.gz')
if gz.isfile():
return File(gz.path)
if file is None and self._gzToken.search(acceptEncoding):
gz = self._dir.child(path + b'.gz')
if gz.isfile():
file = self._file(gz, type, 'gzip')

request.setHeader(b'Cache-Control', b'public, max-age=31536000, immutable')
if file is None:
file = self._file(self._dir.child(path), type)

return File(self._dir.child(path).path)
request.setHeader(b'Cache-Control', b'public, max-age=31536000, immutable')
return file


class Root(FallbackResource):
Expand Down
14 changes: 7 additions & 7 deletions yarrharr/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
<title>{% block title %}{{ title|default:"Yarrharr" }}{% endblock %}</title>
{# XXX Does user-scalable=no do anything anymore? #}
<meta name="viewport" content="width=device-width, user-scalable=no">
<link rel="shortcut icon" sizes="16x16 24x24 32x32 48x48 64x64" href="{% static 'icon.*.ico'|newest_static %}">
<link rel="icon" sizes="152x152" type="image/png" href="{% static 'icon.*.png'|newest_static %}">
<link rel="icon" sizes="any" type="image/svg+xml" href="{% static 'icon.*.svg'|newest_static %}">
<link rel="apple-touch-icon-precomposed" type="image/png" href="{% static 'icon.*.png'|newest_static %}">
<link rel="stylesheet" type="text/css" href="{% static 'main.*.css'|newest_static %}">
<script src="{% static 'vendor.*.js'|newest_static %}" defer></script>
<script src="{% static 'main.*.js'|newest_static %}" defer></script>
<link rel="shortcut icon" sizes="16x16 24x24 32x32 48x48 64x64" href="{% static 'icon-*.ico'|newest_static %}">
<link rel="icon" sizes="152x152" type="image/png" href="{% static 'icon-*.png'|newest_static %}">
<link rel="icon" sizes="any" type="image/svg+xml" href="{% static 'icon-*.svg'|newest_static %}">
<link rel="apple-touch-icon-precomposed" type="image/png" href="{% static 'icon-*.png'|newest_static %}">
<link rel="stylesheet" type="text/css" href="{% static 'main-*.css'|newest_static %}">
<script src="{% static 'vendor-*.js'|newest_static %}" defer></script>
<script src="{% static 'main-*.js'|newest_static %}" defer></script>
</head>
<body>
<main role=main>
Expand Down
2 changes: 1 addition & 1 deletion yarrharr/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

{% block content %}
<div class="login-page">
<h1><img width="64" height="64" src="{% static 'icon.*.svg'|newest_static %}" alt="">Yarrharr</h1>
<h1><img width="64" height="64" src="{% static 'icon-*.svg'|newest_static %}" alt="">Yarrharr</h1>

<form method=post action="{% url 'login' %}" class="small-form">
{{ form.as_p }}
Expand Down
127 changes: 125 additions & 2 deletions yarrharr/tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,22 @@
import unittest
from unittest import mock

from treq.testing import StubTreq
import attr
from treq.testing import HasHeaders, StubTreq
from twisted.logger import Logger, LogPublisher, FileLogObserver
from twisted.internet import defer, task
from twisted.python.log import LogPublisher as LegacyLogPublisher
from twisted.python.failure import Failure
from twisted.python.filepath import FilePath
from twisted.python.threadpool import ThreadPool
from twisted.test.proto_helpers import MemoryReactorClock
from twisted.trial.unittest import SynchronousTestCase
from twisted.web import static
from twisted.web.http_headers import Headers

from ..application import AdaptiveLoopingCall
from ..application import CSPReport
from ..application import Root
from ..application import Root, Static
from ..application import TwistedLoggerLogHandler, formatForSystemd


Expand Down Expand Up @@ -87,6 +91,125 @@ def test_fromJSONChrome(self):
self.assertIs(report.referrer, None)


class StaticTests(SynchronousTestCase):
"""
Test `yarrharr.application.Static`.

These tests all do HEAD requests because:

1. ``twisted.web.static.File`` uses an ``IPullProducer`` internally.
2. ``HTTPChannel`` (used internally by server.Site, used internally by
``treq.testing.RequestTraversalAgent``, used internally by StubTreq)
tries to adapt to a push producer using ``_PullToPush``.
3. ``twisted.internet._producer_helpers._PullToPush`` uses the global
cooperator.
4. The global cooperator users the global reactor to schedule work, which
is never started under the Django test runner.

At least, that's what I *think* is going on. There is other stuff touching
the global reactor too. See treq #225.

It's an ugly hack, but we rely on the Content-Length header to tell if the
right file is being served.
"""
def setUp(self):
self.dir = FilePath(self.mktemp())
self.dir.makedirs()
self.static = Static()
self.static._dir = self.dir
self.treq = StubTreq(self.static)

def assertResponse(self, d, *, code=200,
content_type='',
content_encoding='',
content_length='0'):
response = self.successResultOf(d)
self.assertEqual(200, response.code)
self.assertEqual([content_length],
response.headers.getRawHeaders('content-length'))
self.assertEqual(['public, max-age=31536000, immutable'],
response.headers.getRawHeaders('cache-control'))
self.assertEqual([content_type],
response.headers.getRawHeaders('content-type', ['']))
self.assertEqual([content_encoding],
response.headers.getRawHeaders('content-encoding', ['']))

def test_raw_js(self):
"""
JS is served as application/javascript, immutable.
"""
self.dir.child('foo-xxyy.js').touch()
self.dir.child('foo-xxyy.js.map').touch()

self.assertResponse(
self.treq.head('http://x/foo-xxyy.js'),
content_type='application/javascript',
)
self.assertResponse(
self.treq.head('http://x/foo-xxyy.js.map'),
content_type='application/octet-stream',
)

def test_raw_css(self):
"""
CSS is served as text/css, immutable.
"""
self.dir.child('bar-zz99.css').touch()
self.dir.child('bar-zz99.css.map').touch()

self.assertResponse(
self.treq.head('http://x/bar-zz99.css'),
content_type='text/css',
)
self.assertResponse(
self.treq.head('http://x/bar-zz99.css.map'),
content_type='application/octet-stream',
)

def test_accept_gzip(self):
self.dir.child('a-bcd.js').setContent(b'1')
self.dir.child('a-bcd.js.br').setContent(b'12')
self.dir.child('a-bcd.js.gz').setContent(b'123')

self.assertResponse(
self.treq.head('http://x/a-bcd.js', headers={
'accept-encoding': 'gzip',
}),
content_length='3',
content_type='application/javascript',
content_encoding='gzip',
)
self.assertResponse(
self.treq.head('http://x/a-bcd.js', headers={
'accept-encoding': 'x-gzip, deflate',
}),
content_length='3',
content_type='application/javascript',
content_encoding='gzip',
)
self.assertResponse(
self.treq.head('http://x/a-bcd.js', headers={
'accept-encoding': 'deflate,gzip',
}),
content_length='3',
content_type='application/javascript',
content_encoding='gzip',
)

def test_accept_brotli(self):
self.dir.child('a-bcd.js').touch()
self.dir.child('a-bcd.js.br').touch()
self.dir.child('a-bcd.js.gz').touch()

self.assertResponse(
self.treq.head('http://x/a-bcd.js', headers={
'accept-encoding': 'gzip, br',
}),
content_type='application/javascript',
content_encoding='br',
)


class RootTests(SynchronousTestCase):
def mkTreq(self):
"""
Expand Down