1
from __future__ import absolute_import
3
from email.utils import parsedate, formatdate
8
from time import gmtime
9
from wsgiref.headers import Headers
12
class StaticFile(object):
16
def __init__(self, path):
18
self.headers = Headers([])
21
class WhiteNoise(object):
23
BLOCK_SIZE = 16 * 4096
25
ACCEPT_GZIP_RE = re.compile(r'\bgzip\b')
26
# All mimetypes starting 'text/' take a charset parameter, plus the
27
# additions in this set
28
MIMETYPES_WITH_CHARSET = frozenset((
29
'application/javascript', 'application/xml'))
30
# Ten years is what nginx sets a max age if you use 'expires max;'
31
# so we'll follow its lead
32
FOREVER = 10*365*24*60*60
34
# Attributes that can be set by keyword args in the constructor
35
config_attrs = ('max_age', 'allow_all_origins', 'charset')
37
# Set 'Access-Control-Allow-Orign: *' header on all files.
38
# As these are all public static files this is safe (See
39
# http://www.w3.org/TR/cors/#security) and ensures that things (e.g
40
# webfonts in Firefox) still work as expected when your static files are
41
# served from a CDN, rather than your primary domain.
42
allow_all_origins = True
45
def __init__(self, application, root=None, prefix=None, **kwargs):
46
for attr in self.config_attrs:
48
setattr(self, attr, kwargs.pop(attr))
52
raise TypeError("Unexpected keyword argument '{}'".format(
53
list(kwargs.keys())[0]))
54
self.application = application
57
self.add_files(root, prefix)
59
def __call__(self, environ, start_response):
60
static_file = self.files.get(environ['PATH_INFO'])
61
if static_file is None:
62
return self.application(environ, start_response)
64
return self.serve(static_file, environ, start_response)
66
def serve(self, static_file, environ, start_response):
67
method = environ['REQUEST_METHOD']
68
if method != 'GET' and method != 'HEAD':
69
start_response('405 Method Not Allowed', [('Allow', 'GET, HEAD')])
71
if self.file_not_modified(static_file, environ):
72
start_response('304 Not Modified', [])
74
path, headers = self.get_path_and_headers(static_file, environ)
75
start_response('200 OK', headers.items())
78
file_wrapper = environ.get('wsgi.file_wrapper', self.yield_file)
79
fileobj = open(path, 'rb')
80
return file_wrapper(fileobj)
82
def get_path_and_headers(self, static_file, environ):
83
if static_file.gzip_path:
84
if self.ACCEPT_GZIP_RE.search(environ.get('HTTP_ACCEPT_ENCODING', '')):
85
return static_file.gzip_path, static_file.gzip_headers
86
return static_file.path, static_file.headers
88
def file_not_modified(self, static_file, environ):
90
last_requested = environ['HTTP_IF_MODIFIED_SINCE']
93
# Exact match, no need to parse
94
if last_requested == static_file.headers['Last-Modified']:
96
return parsedate(last_requested) >= static_file.mtime_tuple
98
def yield_file(self, fileobj):
99
# Only used as a fallback in case environ doesn't supply a
103
block = fileobj.read(self.BLOCK_SIZE)
111
def add_files(self, root, prefix=None, followlinks=False):
112
prefix = (prefix or '').strip('/')
113
prefix = '/{}/'.format(prefix) if prefix else '/'
115
for dir_path, _, filenames in os.walk(root, followlinks=followlinks):
116
for filename in filenames:
117
file_path = os.path.join(dir_path, filename)
118
url = prefix + os.path.relpath(file_path, root).replace('\\', '/')
119
files[url] = self.get_static_file(file_path, url)
120
self.find_gzipped_alternatives(files)
121
self.files.update(files)
123
def get_static_file(self, file_path, url):
124
static_file = StaticFile(file_path)
125
self.add_stat_headers(static_file, url)
126
self.add_mime_headers(static_file, url)
127
self.add_cache_headers(static_file, url)
128
self.add_cors_headers(static_file, url)
129
self.add_extra_headers(static_file, url)
132
def add_stat_headers(self, static_file, url):
133
stat = os.stat(static_file.path)
134
static_file.mtime_tuple = gmtime(stat.st_mtime)
135
static_file.headers['Last-Modified'] = formatdate(
136
stat.st_mtime, usegmt=True)
137
static_file.headers['Content-Length'] = str(stat.st_size)
139
def add_mime_headers(self, static_file, url):
140
mimetype, encoding = mimetypes.guess_type(static_file.path)
141
mimetype = mimetype or 'application/octet-stream'
142
charset = self.get_charset(mimetype, static_file, url)
143
params = {'charset': charset} if charset else {}
144
static_file.headers.add_header('Content-Type', mimetype, **params)
146
static_file.headers['Content-Encoding'] = encoding
148
def get_charset(self, mimetype, static_file, url):
149
if (mimetype.startswith('text/')
150
or mimetype in self.MIMETYPES_WITH_CHARSET):
153
def add_cache_headers(self, static_file, url):
154
if self.is_immutable_file(static_file, url):
155
max_age = self.FOREVER
157
max_age = self.max_age
158
if max_age is not None:
159
cache_control = 'public, max-age={}'.format(max_age)
160
static_file.headers['Cache-Control'] = cache_control
162
def is_immutable_file(self, static_file, url):
164
This should be implemented by sub-classes (see e.g. DjangoWhiteNoise)
168
def add_cors_headers(self, static_file, url):
169
if self.allow_all_origins:
170
static_file.headers['Access-Control-Allow-Origin'] = '*'
172
def add_extra_headers(self, static_file, url):
174
This is provided as a hook for sub-classes, by default a no-op
178
def find_gzipped_alternatives(self, files):
179
for url, static_file in files.items():
180
gzip_url = url + self.GZIP_SUFFIX
182
gzip_file = files[gzip_url]
185
static_file.gzip_path = gzip_file.path
186
static_file.headers['Vary'] = 'Accept-Encoding'
187
# Copy the headers and add the appropriate encoding and length
188
gzip_headers = Headers(static_file.headers.items())
189
gzip_headers['Content-Encoding'] = 'gzip'
190
gzip_headers['Content-Length'] = gzip_file.headers['Content-Length']
191
static_file.gzip_headers = gzip_headers