summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarcel Hellkamp <marc@gsites.de>2016-07-05 13:40:14 +0200
committerGitHub <noreply@github.com>2016-07-05 13:40:14 +0200
commitd573675243ace953730b0255600533c1347c4f31 (patch)
tree26c36b054a180cc46eef4bcdd2e0e2fb78b752ea
parentfbf641da04bc2c7abec08792c2a3b73bd31d87d7 (diff)
parentd7a3ca9bec7f937670929702416edb6cf91adc9b (diff)
downloadbottle-d573675243ace953730b0255600533c1347c4f31.tar.gz
Merge pull request #861 from bottlepy/feature-etag
Added ETag support to static_file()
-rw-r--r--bottle.py66
-rwxr-xr-xtest/test_sendfile.py24
2 files changed, 70 insertions, 20 deletions
diff --git a/bottle.py b/bottle.py
index 4c5a88a..a4052ac 100644
--- a/bottle.py
+++ b/bottle.py
@@ -2649,26 +2649,39 @@ def _file_iter_range(fp, offset, bytes, maxread=1024 * 1024):
def static_file(filename, root,
- mimetype='auto',
+ mimetype=True,
download=False,
- charset='UTF-8'):
- """ Open a file in a safe way and return :exc:`HTTPResponse` with status
- code 200, 305, 403 or 404. The ``Content-Type``, ``Content-Encoding``,
- ``Content-Length`` and ``Last-Modified`` headers are set if possible.
- Special support for ``If-Modified-Since``, ``Range`` and ``HEAD``
- requests.
-
- :param filename: Name or path of the file to send.
+ charset='UTF-8',
+ etag=None):
+ """ Open a file in a safe way and return an instance of :exc:`HTTPResponse`
+ that can be sent back to the client.
+
+ :param filename: Name or path of the file to send, relative to ``root``.
:param root: Root path for file lookups. Should be an absolute directory
path.
- :param mimetype: Defines the content-type header (default: guess from
+ :param mimetype: Provide the content-type header (default: guess from
file extension)
:param download: If True, ask the browser to open a `Save as...` dialog
instead of opening the file with the associated program. You can
specify a custom filename as a string. If not specified, the
original filename is used (default: False).
- :param charset: The charset to use for files with a ``text/*``
- mime-type. (default: UTF-8)
+ :param charset: The charset for files with a ``text/*`` mime-type.
+ (default: UTF-8)
+ :param etag: Provide a pre-computed ETag header. If set to ``False``,
+ ETag handling is disabled. (default: auto-generate ETag header)
+
+ While checking user input is always a good idea, this function provides
+ additional protection against malicious ``filename`` parameters from
+ breaking out of the ``root`` directory and leaking sensitive information
+ to an attacker.
+
+ Read-protected files or files outside of the ``root`` directory are
+ answered with ``403 Access Denied``. Missing files result in a
+ ``404 Not Found`` response. Conditional requests (``If-Modified-Since``,
+ ``If-None-Match``) are answered with ``304 Not Modified`` whenever
+ possible. ``HEAD`` and ``Range`` requests (used by download managers to
+ check or continue partial downloads) are also handled automatically.
+
"""
root = os.path.join(os.path.abspath(root), '')
@@ -2682,7 +2695,7 @@ def static_file(filename, root,
if not os.access(filename, os.R_OK):
return HTTPError(403, "You do not have permission to access this file.")
- if mimetype == 'auto':
+ if mimetype is True:
if download and download != True:
mimetype, encoding = mimetypes.guess_type(download)
else:
@@ -2690,7 +2703,8 @@ def static_file(filename, root,
if encoding: headers['Content-Encoding'] = encoding
if mimetype:
- if (mimetype[:5] == 'text/' or mimetype == 'application/javascript') and charset and 'charset' not in mimetype:
+ if (mimetype[:5] == 'text/' or mimetype == 'application/javascript')\
+ and charset and 'charset' not in mimetype:
mimetype += '; charset=%s' % charset
headers['Content-Type'] = mimetype
@@ -2702,21 +2716,33 @@ def static_file(filename, root,
headers['Content-Length'] = clen = stats.st_size
lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime))
headers['Last-Modified'] = lm
+ headers['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
+
+ getenv = request.environ.get
+
+ if etag is None:
+ etag = '%d:%d:%d:%d:%s' % (stats.st_dev, stats.st_ino, stats.st_mtime,
+ clen, filename)
+ etag = hashlib.sha1(tob(etag)).hexdigest()
+
+ if etag:
+ headers['ETag'] = etag
+ check = getenv('HTTP_IF_NONE_MATCH')
+ if check and check == etag:
+ return HTTPResponse(status=304, **headers)
- ims = request.environ.get('HTTP_IF_MODIFIED_SINCE')
+ ims = getenv('HTTP_IF_MODIFIED_SINCE')
if ims:
ims = parse_date(ims.split(";")[0].strip())
if ims is not None and ims >= int(stats.st_mtime):
- headers['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT",
- time.gmtime())
return HTTPResponse(status=304, **headers)
body = '' if request.method == 'HEAD' else open(filename, 'rb')
headers["Accept-Ranges"] = "bytes"
- ranges = request.environ.get('HTTP_RANGE')
- if 'HTTP_RANGE' in request.environ:
- ranges = list(parse_range_header(request.environ['HTTP_RANGE'], clen))
+ range_header = getenv('HTTP_RANGE')
+ if range_header:
+ ranges = list(parse_range_header(range_header, clen))
if not ranges:
return HTTPError(416, "Requested Range Not Satisfiable")
offset, end = ranges[0]
diff --git a/test/test_sendfile.py b/test/test_sendfile.py
index fad5a57..3de6c50 100755
--- a/test/test_sendfile.py
+++ b/test/test_sendfile.py
@@ -1,5 +1,6 @@
import unittest
from bottle import static_file, request, response, parse_date, parse_range_header, Bottle, tob
+import bottle
import wsgiref.util
import os
import tempfile
@@ -8,6 +9,9 @@ import time
basename = os.path.basename(__file__)
root = os.path.dirname(__file__)
+basename2 = os.path.basename(bottle.__file__)
+root2 = os.path.dirname(bottle.__file__)
+
class TestDateParser(unittest.TestCase):
def test_rfc1123(self):
@@ -80,6 +84,26 @@ class TestSendFile(unittest.TestCase):
request.environ['HTTP_IF_MODIFIED_SINCE'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(100))
self.assertEqual(open(__file__,'rb').read(), static_file(basename, root=root).body.read())
+ def test_etag(self):
+ """ SendFile: If-Modified-Since"""
+ res = static_file(basename, root=root)
+ self.assertTrue('ETag' in res.headers)
+ self.assertEqual(200, res.status_code)
+ etag = res.headers['ETag']
+
+ request.environ['HTTP_IF_NONE_MATCH'] = etag
+ res = static_file(basename, root=root)
+ self.assertTrue('ETag' in res.headers)
+ self.assertEqual(etag, res.headers['ETag'])
+ self.assertEqual(304, res.status_code)
+
+ request.environ['HTTP_IF_NONE_MATCH'] = etag
+ res = static_file(basename2, root=root2)
+ self.assertTrue('ETag' in res.headers)
+ self.assertNotEqual(etag, res.headers['ETag'])
+ self.assertEqual(200, res.status_code)
+
+
def test_download(self):
""" SendFile: Download as attachment """
f = static_file(basename, root=root, download="foo.mp3")