Skip to content

Commit 1cbfc77

Browse files
committed
0.13dev: Added [http://code.google.com/speed/page-speed/docs/caching.html#LeverageBrowserCaching fingerprinting] of static resources, to avoid stale .css and .js after updating Trac or plugins.
The fingerprinting can be controlled with the `[trac] fingerprint_resources` option: * `content`: Calculate the fingerprint from the content of all static resources. * `meta`: Calculate the fingerprint from the metadata (size and mtime) of all static resources. * `disabled`: Disable fingerprinting. Closes #9936. git-svn-id: http://trac.edgewall.org/intertrac/log:/trunk@10769 af82e41b-90c4-0310-8c96-b1721e28e2e2
1 parent 5f99a3b commit 1cbfc77

File tree

5 files changed

+93
-27
lines changed

5 files changed

+93
-27
lines changed

trac/mimeview/tests/patch.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ class PatchRendererTestCase(unittest.TestCase):
2828

2929
def setUp(self):
3030
env = EnvironmentStub(enable=[Chrome, PatchRenderer])
31-
req = Mock(base_path='', chrome={}, args={}, session={},
32-
abs_href=Href('/'), href=Href('/'), locale='',
31+
req = Mock(base_path='', chrome={'static_hash': None}, args={},
32+
session={}, abs_href=Href('/'), href=Href('/'), locale='',
3333
perm=MockPerm(), authname=None, tz=None)
3434
self.context = web_context(req)
3535
self.patch = Mimeview(env).renderers[0]

trac/test.py

+3
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,9 @@ def __init__(self, default_data=False, enable=None, disable=None,
283283
if default_data or init_global:
284284
self.reset_db(default_data)
285285

286+
# -- avoid chrome URL fingerprinting
287+
self.config.set('trac', 'fingerprint_resources', 'disabled')
288+
286289
from trac.web.href import Href
287290
self.href = Href('/trac.cgi')
288291
self.abs_href = Href('http://example.org/trac.cgi')

trac/web/api.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ def send_error(self, exc_info, template='error.html',
522522
self.write(data)
523523
raise RequestDone
524524

525-
def send_file(self, path, mimetype=None):
525+
def send_file(self, path, mimetype=None, expires=None):
526526
"""Send a local file to the browser.
527527
528528
This method includes the "Last-Modified", "Content-Type" and
@@ -551,6 +551,8 @@ def send_file(self, path, mimetype=None):
551551
self.send_header('Content-Type', mimetype)
552552
self.send_header('Content-Length', stat.st_size)
553553
self.send_header('Last-Modified', last_modified)
554+
if expires is not None:
555+
self.send_header('Expires', http_date(expires))
554556
self.end_headers()
555557

556558
if self.method != 'HEAD':

trac/web/chrome.py

+84-23
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from trac.mimeview.api import RenderingContext, get_mimetype
5050
from trac.resource import *
5151
from trac.util import compat, get_reporter_id, presentation, get_pkginfo, \
52-
pathjoin, translation
52+
lazy, pathjoin, sha1, translation
5353
from trac.util.html import escape, plaintext
5454
from trac.util.text import pretty_size, obfuscate_email_address, \
5555
shorten_line, unicode_quote_plus, to_unicode, \
@@ -141,10 +141,10 @@ def add_stylesheet(req, filename, mimetype='text/css', media=None):
141141
elif filename.startswith('common/') and 'htdocs_location' in req.chrome:
142142
href = Href(req.chrome['htdocs_location'])(filename[7:])
143143
else:
144-
href = req.href
145-
if not filename.startswith('/'):
146-
href = href.chrome
147-
href = href(filename)
144+
if filename.startswith('/'):
145+
href = req.href(filename)
146+
else:
147+
href = req.href.chrome(req.chrome['static_hash'], filename)
148148
add_link(req, 'stylesheet', href, mimetype=mimetype, media=media)
149149

150150
def add_script(req, filename, mimetype='text/javascript', charset='utf-8',
@@ -164,10 +164,10 @@ def add_script(req, filename, mimetype='text/javascript', charset='utf-8',
164164
elif filename.startswith('common/') and 'htdocs_location' in req.chrome:
165165
href = Href(req.chrome['htdocs_location'])(filename[7:])
166166
else:
167-
href = req.href
168-
if not filename.startswith('/'):
169-
href = href.chrome
170-
href = href(filename)
167+
if filename.startswith('/'):
168+
href = req.href(filename)
169+
else:
170+
href = req.href.chrome(req.chrome['static_hash'], filename)
171171
script = {'href': href, 'type': mimetype, 'charset': charset,
172172
'prefix': Markup('<!--[if %s]>' % ie_if) if ie_if else None,
173173
'suffix': Markup('<![endif]-->') if ie_if else None}
@@ -343,6 +343,22 @@ class Chrome(Component):
343343
auto_reload = BoolOption('trac', 'auto_reload', False,
344344
"""Automatically reload template files after modification.""")
345345

346+
fingerprint_resources = ChoiceOption('trac', 'fingerprint_resources',
347+
['content', 'meta', 'disabled'],
348+
"""Control the fingerprinting of static resources.
349+
350+
URLs to static resources below `/chrome` have the form
351+
`/chrome/![0-9a-f]{8}/.*`, where the second element is a fingerprint
352+
of the ''content'' of all resources (for "content") or their
353+
''metadata'' (size and mtime, for "meta"). This allows aggressive
354+
caching of static resources on the browser, while still ensuring that
355+
they are reloaded when they change.
356+
357+
Setting this option to "disabled" disables fingerprinting, and
358+
reverts the URLs to static resources to `/chrome/.*`.
359+
360+
(''since 0.13'')""")
361+
346362
genshi_cache_size = IntOption('trac', 'genshi_cache_size', 128,
347363
"""The maximum number of templates that the template loader will cache
348364
in memory. The default value is 128. You may want to choose a higher
@@ -537,15 +553,20 @@ def upgrade_environment(self, db):
537553

538554
# IRequestHandler methods
539555

556+
_chrome_path_re = re.compile(r'/chrome/(?:(?P<hash>![0-9a-f]+)/)?'
557+
r'(?P<prefix>[^/]+)/+(?P<filename>.+)')
558+
540559
def match_request(self, req):
541-
match = re.match(r'/chrome/(?P<prefix>[^/]+)/+(?P<filename>.+)',
542-
req.path_info)
560+
match = self._chrome_path_re.match(req.path_info)
543561
if match:
562+
req.args['hash'] = match.group('hash')
544563
req.args['prefix'] = match.group('prefix')
545564
req.args['filename'] = match.group('filename')
546565
return True
547566

548567
def process_request(self, req):
568+
hash_matches = self.static_hash \
569+
and req.args['hash'] == self.static_hash
549570
prefix = req.args['prefix']
550571
filename = req.args['filename']
551572

@@ -558,7 +579,10 @@ def process_request(self, req):
558579
path = os.path.normpath(os.path.join(dir, filename))
559580
assert os.path.commonprefix([dir, path]) == dir
560581
if os.path.isfile(path):
561-
req.send_file(path, get_mimetype(path))
582+
req.send_file(path, get_mimetype(path),
583+
expires=datetime.datetime.now(utc)
584+
+ datetime.timedelta(days=365)
585+
if hash_matches else None)
562586

563587
self.log.warning('File %s not found in any of %s', filename, dirs)
564588
raise HTTPNotFound('File %s not found', filename)
@@ -587,11 +611,42 @@ def get_link_resolvers(self):
587611

588612
def _format_link(self, formatter, ns, file, label):
589613
file, query, fragment = formatter.split_link(file)
590-
href = formatter.href.chrome('site', file) + query + fragment
614+
href = formatter.href.chrome(self.static_hash, 'site', file) + query \
615+
+ fragment
591616
return tag.a(label, href=href)
592617

593618
# Public API methods
594619

620+
@lazy
621+
def static_hash(self):
622+
"""Return a hash of all available static resources."""
623+
if self.fingerprint_resources == 'content':
624+
def update(path):
625+
with open(path, 'rb') as f:
626+
while True:
627+
data = f.read(65536)
628+
if not data:
629+
break
630+
hash.update(data)
631+
elif self.fingerprint_resources == 'meta':
632+
def update(path):
633+
st = os.stat(path)
634+
hash.update(str(st.st_size) + str(st.st_mtime))
635+
else:
636+
return None
637+
638+
all_dirs = [dir[1] for provider in self.template_providers
639+
for dir in provider.get_htdocs_dirs() or []]
640+
all_dirs.sort()
641+
hash = sha1()
642+
for dir in all_dirs:
643+
for path, dirs, files in os.walk(dir):
644+
dirs.sort()
645+
files.sort()
646+
for name in files:
647+
update(os.path.join(path, name))
648+
return '!' + hash.hexdigest()[:8]
649+
595650
def get_all_templates_dirs(self):
596651
"""Return a list of the names of all known templates directories."""
597652
dirs = []
@@ -610,9 +665,11 @@ def prepare_request(self, req, handler=None):
610665

611666
chrome = {'metas': [], 'links': {}, 'scripts': [], 'script_data': {},
612667
'ctxtnav': [], 'warnings': [], 'notices': []}
613-
setattr(req, 'chrome', chrome)
668+
req.chrome = chrome
614669

615-
htdocs_location = self.htdocs_location or req.href.chrome('common')
670+
chrome['static_hash'] = self.static_hash
671+
htdocs_location = self.htdocs_location \
672+
or req.href.chrome(self.static_hash, 'common')
616673
chrome['htdocs_location'] = htdocs_location.rstrip('/') + '/'
617674

618675
# HTML <head> links
@@ -721,11 +778,14 @@ def get_icon_data(self, req):
721778
if icon_src:
722779
if not icon_src.startswith('/') and icon_src.find('://') == -1:
723780
if '/' in icon_src:
724-
icon_abs_src = req.abs_href.chrome(icon_src)
725-
icon_src = req.href.chrome(icon_src)
781+
icon_abs_src = req.abs_href.chrome(self.static_hash,
782+
icon_src)
783+
icon_src = req.href.chrome(self.static_hash, icon_src)
726784
else:
727-
icon_abs_src = req.abs_href.chrome('common', icon_src)
728-
icon_src = req.href.chrome('common', icon_src)
785+
icon_abs_src = req.abs_href.chrome(self.static_hash,
786+
'common', icon_src)
787+
icon_src = req.href.chrome(self.static_hash, 'common',
788+
icon_src)
729789
mimetype = get_mimetype(icon_src)
730790
icon = {'src': icon_src, 'abs_src': icon_abs_src,
731791
'mimetype': mimetype}
@@ -742,12 +802,13 @@ def get_logo_data(self, href, abs_href=None):
742802
logo_src_abs = logo_src
743803
elif '/' in logo_src:
744804
# Like 'common/trac_banner.png' or 'site/my_banner.png'
745-
logo_src_abs = abs_href.chrome(logo_src)
746-
logo_src = href.chrome(logo_src)
805+
logo_src_abs = abs_href.chrome(self.static_hash, logo_src)
806+
logo_src = href.chrome(self.static_hash, logo_src)
747807
else:
748808
# Like 'trac_banner.png'
749-
logo_src_abs = abs_href.chrome('common', logo_src)
750-
logo_src = href.chrome('common', logo_src)
809+
logo_src_abs = abs_href.chrome(self.static_hash, 'common',
810+
logo_src)
811+
logo_src = href.chrome(self.static_hash, 'common', logo_src)
751812
width = self.logo_width if self.logo_width > -1 else None
752813
height = self.logo_height if self.logo_height > -1 else None
753814
logo = {

trac/web/tests/chrome.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
class Request(object):
1010
locale = None
1111
def __init__(self, **kwargs):
12-
self.chrome = {}
12+
self.chrome = {'static_hash': None}
1313
for k, v in kwargs.items():
1414
setattr(self, k, v)
1515

0 commit comments

Comments
 (0)