Steve Piercy
2018-09-22 2a45fe74f9598b4e726ab17ce17948d4e709894b
commit | author | age
c55903 1 # -*- coding: utf-8 -*-
3a4119 2 import json
ac15b5 3 import os
0c1c39 4
CM 5 from os.path import (
3a4119 6     getmtime,
0c1c39 7     normcase,
CM 8     normpath,
9     join,
10     isdir,
11     exists,
12     )
13
14 from pkg_resources import (
15     resource_exists,
934d7f 16     resource_filename,
0c1c39 17     resource_isdir,
CM 18     )
d809ac 19
f2ef79 20 from repoze.lru import lru_cache
b29429 21
1e1111 22 from pyramid.asset import (
MM 23     abspath_from_asset_spec,
24     resolve_asset_spec,
25 )
0c1c39 26
e6c2d2 27 from pyramid.compat import text_
0c1c39 28
CM 29 from pyramid.httpexceptions import (
30     HTTPNotFound,
31     HTTPMovedPermanently,
32     )
33
3a4119 34 from pyramid.path import caller_package
ed06c6 35
DG 36 from pyramid.response import (
37     _guess_type,
38     FileResponse,
39 )
40
f84147 41 from pyramid.traversal import traversal_path_info
e6c2d2 42
CM 43 slash = text_('/')
b29429 44
CM 45 class static_view(object):
46     """ An instance of this class is a callable which can act as a
fd5ae9 47     :app:`Pyramid` :term:`view callable`; this view will serve
b29429 48     static files from a directory on disk based on the ``root_dir``
CM 49     you provide to its constructor.
50
51     The directory may contain subdirectories (recursively); the static
52     view implementation will descend into these directories as
53     necessary based on the components of the URL in order to resolve a
54     path into a response.
55
56     You may pass an absolute or relative filesystem path or a
3e2f12 57     :term:`asset specification` representing the directory
b29429 58     containing static files as the ``root_dir`` argument to this
CM 59     class' constructor.
60
61     If the ``root_dir`` path is relative, and the ``package_name``
62     argument is ``None``, ``root_dir`` will be considered relative to
63     the directory in which the Python file which *calls* ``static``
64     resides.  If the ``package_name`` name argument is provided, and a
65     relative ``root_dir`` is provided, the ``root_dir`` will be
66     considered relative to the Python :term:`package` specified by
67     ``package_name`` (a dotted path to a Python package).
68
69     ``cache_max_age`` influences the ``Expires`` and ``Max-Age``
70     response headers returned by the view (default is 3600 seconds or
dab748 71     one hour).
b29429 72
56d0fe 73     ``use_subpath`` influences whether ``request.subpath`` will be used as
CM 74     ``PATH_INFO`` when calling the underlying WSGI application which actually
75     serves the static files.  If it is ``True``, the static application will
76     consider ``request.subpath`` as ``PATH_INFO`` input.  If it is ``False``,
f84147 77     the static application will consider request.environ[``PATH_INFO``] as
CM 78     ``PATH_INFO`` input. By default, this is ``False``.
56d0fe 79
012b97 80     .. note::
M 81
82        If the ``root_dir`` is relative to a :term:`package`, or is a
83        :term:`asset specification` the :app:`Pyramid`
84        :class:`pyramid.config.Configurator` method can be used to override
85        assets within the named ``root_dir`` package-relative directory.
86        However, if the ``root_dir`` is absolute, configuration will not be able
87        to override the assets it contains.
88     """
f2ef79 89
56d0fe 90     def __init__(self, root_dir, cache_max_age=3600, package_name=None,
3e2a69 91                  use_subpath=False, index='index.html'):
b29429 92         # package_name is for bw compat; it is preferred to pass in a
CM 93         # package-relative path as root_dir
94         # (e.g. ``anotherpackage:foo/static``).
fe1548 95         self.cache_max_age = cache_max_age
379b47 96         if package_name is None:
CM 97             package_name = caller_package().__name__
f2ef79 98         package_name, docroot = resolve_asset_spec(root_dir, package_name)
56d0fe 99         self.use_subpath = use_subpath
f2ef79 100         self.package_name = package_name
CM 101         self.docroot = docroot
102         self.norm_docroot = normcase(normpath(docroot))
103         self.index = index
b29429 104
CM 105     def __call__(self, context, request):
56d0fe 106         if self.use_subpath:
f2ef79 107             path_tuple = request.subpath
CM 108         else:
f84147 109             path_tuple = traversal_path_info(request.environ['PATH_INFO'])
1455ba 110         path = _secure_path(path_tuple)
f2ef79 111
CM 112         if path is None:
99e617 113             raise HTTPNotFound('Out of bounds: %s' % request.url)
f2ef79 114
CM 115         if self.package_name: # package resource
25c64c 116             resource_path = '%s/%s' % (self.docroot.rstrip('/'), path)
f2ef79 117             if resource_isdir(self.package_name, resource_path):
CM 118                 if not request.path_url.endswith('/'):
3d65af 119                     self.add_slash_redirect(request)
25c64c 120                 resource_path = '%s/%s' % (
JA 121                     resource_path.rstrip('/'), self.index
122                 )
123
f2ef79 124             if not resource_exists(self.package_name, resource_path):
99e617 125                 raise HTTPNotFound(request.url)
f2ef79 126             filepath = resource_filename(self.package_name, resource_path)
CM 127
128         else: # filesystem file
129
130             # os.path.normpath converts / to \ on windows
131             filepath = normcase(normpath(join(self.norm_docroot, path)))
132             if isdir(filepath):
133                 if not request.path_url.endswith('/'):
3d65af 134                     self.add_slash_redirect(request)
f2ef79 135                 filepath = join(filepath, self.index)
CM 136             if not exists(filepath):
99e617 137                 raise HTTPNotFound(request.url)
f2ef79 138
ed06c6 139         content_type, content_encoding = _guess_type(filepath)
DG 140         return FileResponse(
141             filepath, request, self.cache_max_age,
142             content_type, content_encoding=None)
f2ef79 143
CM 144     def add_slash_redirect(self, request):
145         url = request.path_url + '/'
146         qs = request.query_string
147         if qs:
148             url = url + '?' + qs
3d65af 149         raise HTTPMovedPermanently(url)
f2ef79 150
3bbe82 151 _seps = set(['/', os.sep])
CM 152 def _contains_slash(item):
153     for sep in _seps:
0ab0b9 154         if sep in item:
CM 155             return True
b1ba8c 156
ac15b5 157 _has_insecure_pathelement = set(['..', '.', '']).intersection
CM 158
f2ef79 159 @lru_cache(1000)
1455ba 160 def _secure_path(path_tuple):
3bbe82 161     if _has_insecure_pathelement(path_tuple):
05b8a6 162         # belt-and-suspenders security; this should never be true
CM 163         # unless someone screws up the traversal_path code
164         # (request.subpath is computed via traversal_path too)
f2ef79 165         return None
3bbe82 166     if any([_contains_slash(item) for item in path_tuple]):
05b8a6 167         return None
e6c2d2 168     encoded = slash.join(path_tuple) # will be unicode
c55903 169     return encoded
2a1ca8 170
780889 171 class QueryStringCacheBuster(object):
MM 172     """
173     An implementation of :class:`~pyramid.interfaces.ICacheBuster` which adds
174     a token for cache busting in the query string of an asset URL.
2a1ca8 175
780889 176     The optional ``param`` argument determines the name of the parameter added
MM 177     to the query string and defaults to ``'x'``.
178
179     To use this class, subclass it and provide a ``tokenize`` method which
4d19b8 180     accepts ``request, pathspec, kw`` and returns a token.
780889 181
MM 182     .. versionadded:: 1.6
183     """
184     def __init__(self, param='x'):
185         self.param = param
186
4d19b8 187     def __call__(self, request, subpath, kw):
MM 188         token = self.tokenize(request, subpath, kw)
780889 189         query = kw.setdefault('_query', {})
MM 190         if isinstance(query, dict):
191             query[self.param] = token
192         else:
193             kw['_query'] = tuple(query) + ((self.param, token),)
194         return subpath, kw
2a1ca8 195
780889 196 class QueryStringConstantCacheBuster(QueryStringCacheBuster):
f674a8 197     """
CR 198     An implementation of :class:`~pyramid.interfaces.ICacheBuster` which adds
199     an arbitrary token for cache busting in the query string of an asset URL.
200
201     The ``token`` parameter is the token string to use for cache busting and
202     will be the same for every request.
203
204     The optional ``param`` argument determines the name of the parameter added
205     to the query string and defaults to ``'x'``.
6b88bd 206
CM 207     .. versionadded:: 1.6
f674a8 208     """
CR 209     def __init__(self, token, param='x'):
4a9c13 210         super(QueryStringConstantCacheBuster, self).__init__(param=param)
f674a8 211         self._token = token
CR 212
4d19b8 213     def tokenize(self, request, subpath, kw):
f674a8 214         return self._token
3a4119 215
MM 216 class ManifestCacheBuster(object):
217     """
218     An implementation of :class:`~pyramid.interfaces.ICacheBuster` which
219     uses a supplied manifest file to map an asset path to a cache-busted
220     version of the path.
221
3fe24b 222     The ``manifest_spec`` can be an absolute path or a :term:`asset
SP 223     specification` pointing to a package-relative file.
1e1111 224
MM 225     The manifest file is expected to conform to the following simple JSON
226     format:
3a4119 227
MM 228     .. code-block:: json
229
230        {
231            "css/main.css": "css/main-678b7c80.css",
232            "images/background.png": "images/background-a8169106.png",
233        }
234
edad4c 235     By default, it is a JSON-serialized dictionary where the keys are the
3a4119 236     source asset paths used in calls to
3e0b1d 237     :meth:`~pyramid.request.Request.static_url`. For example:
3a4119 238
a996ac 239     .. code-block:: pycon
3a4119 240
MM 241        >>> request.static_url('myapp:static/css/main.css')
242        "http://www.example.com/static/css/main-678b7c80.css"
243
edad4c 244     The file format and location can be changed by subclassing and overriding
MM 245     :meth:`.parse_manifest`.
246
3a4119 247     If a path is not found in the manifest it will pass through unchanged.
MM 248
249     If ``reload`` is ``True`` then the manifest file will be reloaded when
250     changed. It is not recommended to leave this enabled in production.
251
252     If the manifest file cannot be found on disk it will be treated as
253     an empty mapping unless ``reload`` is ``False``.
254
255     .. versionadded:: 1.6
256     """
257     exists = staticmethod(exists) # testing
258     getmtime = staticmethod(getmtime) # testing
259
1e1111 260     def __init__(self, manifest_spec, reload=False):
MM 261         package_name = caller_package().__name__
262         self.manifest_path = abspath_from_asset_spec(
263             manifest_spec, package_name)
3a4119 264         self.reload = reload
MM 265
266         self._mtime = None
267         if not reload:
edad4c 268             self._manifest = self.get_manifest()
3a4119 269
edad4c 270     def get_manifest(self):
MM 271         with open(self.manifest_path, 'rb') as fp:
272             return self.parse_manifest(fp.read())
273
274     def parse_manifest(self, content):
3a4119 275         """
edad4c 276         Parse the ``content`` read from the ``manifest_path`` into a
MM 277         dictionary mapping.
3a4119 278
MM 279         Subclasses may override this method to use something other than
1e1111 280         ``json.loads`` to load any type of file format and return a conforming
MM 281         dictionary.
3a4119 282
MM 283         """
edad4c 284         return json.loads(content.decode('utf-8'))
3a4119 285
MM 286     @property
287     def manifest(self):
288         """ The current manifest dictionary."""
289         if self.reload:
290             if not self.exists(self.manifest_path):
291                 return {}
292             mtime = self.getmtime(self.manifest_path)
293             if self._mtime is None or mtime > self._mtime:
edad4c 294                 self._manifest = self.get_manifest()
3a4119 295                 self._mtime = mtime
MM 296         return self._manifest
297
4d19b8 298     def __call__(self, request, subpath, kw):
6e29b4 299         subpath = self.manifest.get(subpath, subpath)
MM 300         return (subpath, kw)