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