From b5274fefb9cb5dda5069f194161e9b683304d73b Mon Sep 17 00:00:00 2001 From: Ethan Dalool Date: Sun, 18 Mar 2018 21:42:31 -0700 Subject: [PATCH] Add caching.py to enable browser caching of files. --- .../etiquette_flask/caching.py | 72 +++++++++++++++++++ .../etiquette_flask/etiquette_flask/common.py | 17 ++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 frontends/etiquette_flask/etiquette_flask/caching.py diff --git a/frontends/etiquette_flask/etiquette_flask/caching.py b/frontends/etiquette_flask/etiquette_flask/caching.py new file mode 100644 index 0000000..1515791 --- /dev/null +++ b/frontends/etiquette_flask/etiquette_flask/caching.py @@ -0,0 +1,72 @@ +''' +This file provides the FileCacheManager to serve ETag and Cache-Control headers +for files on disk. + +We consider the following cases: + +Client does not have the file (or has disabled their cache, effectively same): + Server sends file, provides ETag, and tells client to save it for max-age. + +Client has the file, but it has been a long time, beyond the max-age: + Client provides the old ETag. If it's still valid, Server responds with + 304 Not Modified and no data. Client keeps the file. + +Client has the file, and it is within the max-age: + Client does not make a request at all. + +This FileCacheManager uses the file's MD5 hash as the ETag, and will only +recalculate it if the file's mtime has changed since the last request. +''' + +import time + +import etiquette + +from voussoirkit import cacheclass +from voussoirkit import pathclass + +class FileCacheManager: + def __init__(self, maxlen, max_filesize, max_age): + self.cache = cacheclass.Cache(maxlen=maxlen) + self.max_filesize = int(max_filesize) + self.max_age = int(max_age) + + def get(self, filepath): + if (self.max_filesize is not None) and (filepath.size > self.max_filesize): + #print('I\'m not going to cache that!') + return None + + try: + return self.cache[filepath] + except KeyError: + pass + cache_file = CacheFile(filepath, max_age=self.max_age) + self.cache[filepath] = cache_file + return cache_file + +class CacheFile: + def __init__(self, filepath, max_age): + self.filepath = filepath + self.max_age = max_age + self._stored_hash_time = None + self._stored_hash_value = None + + def get_etag(self): + if self._stored_hash_value is None: + refresh = True + elif self.filepath.stat.st_mtime > self._stored_hash_time: + refresh = True + else: + refresh = False + + if refresh: + self._stored_hash_time = self.filepath.stat.st_mtime + self._stored_hash_value = etiquette.helpers.hash_file_md5(self.filepath) + return self._stored_hash_value + + def get_headers(self): + headers = { + 'ETag': self.get_etag(), + 'Cache-Control': 'max-age=%d' % self.max_age, + } + return headers diff --git a/frontends/etiquette_flask/etiquette_flask/common.py b/frontends/etiquette_flask/etiquette_flask/common.py index 7a46299..57fa35f 100644 --- a/frontends/etiquette_flask/etiquette_flask/common.py +++ b/frontends/etiquette_flask/etiquette_flask/common.py @@ -5,8 +5,10 @@ import traceback import etiquette +from voussoirkit import bytestring from voussoirkit import pathclass +from . import caching from . import jsonify from . import sessions @@ -34,7 +36,11 @@ site.debug = True P = etiquette.photodb.PhotoDB() session_manager = sessions.SessionManager(maxlen=10000) - +file_cache_manager = caching.FileCacheManager( + maxlen=10000, + max_filesize=5 * bytestring.MIBIBYTE, + max_age=180, +) def P_wrapper(function): def P_wrapped(thingid, response_type='html'): @@ -100,6 +106,13 @@ def send_file(filepath, override_mimetype=None): if not filepath.is_file: flask.abort(404) + cache_file = file_cache_manager.get(filepath) + if cache_file is not None: + client_etag = request.headers.get('If-None-Match', None) + if client_etag and client_etag == cache_file.get_etag(): + response = flask.Response(status=304, headers=cache_file.get_headers()) + return response + outgoing_headers = {} if override_mimetype is not None: mimetype = override_mimetype @@ -147,6 +160,8 @@ def send_file(filepath, override_mimetype=None): outgoing_headers['Accept-Ranges'] = 'bytes' outgoing_headers['Content-Length'] = (range_max - range_min) + 1 + if cache_file is not None: + outgoing_headers.update(cache_file.get_headers()) if request.method == 'HEAD': outgoing_data = bytes()