summaryrefslogtreecommitdiff
path: root/repoze/bfg/static.py
blob: 71db132b7ab17552b86f62569e56e077cd112647 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import os
import pkg_resources
from urlparse import urljoin
from urlparse import urlparse

from paste import httpexceptions
from paste import request
from paste.httpheaders import ETAG
from paste.urlparser import StaticURLParser

from zope.interface import implements

from repoze.bfg.interfaces import IStaticURLInfo
from repoze.bfg.path import caller_package
from repoze.bfg.resource import resolve_resource_spec
from repoze.bfg.url import route_url

class PackageURLParser(StaticURLParser):
    """ This probably won't work with zipimported resources """
    def __init__(self, package_name, resource_name, root_resource=None,
                 cache_max_age=None):
        self.package_name = package_name
        self.resource_name = os.path.normpath(resource_name)
        if root_resource is None:
            root_resource = self.resource_name
        self.root_resource = root_resource
        self.cache_max_age = cache_max_age

    def __call__(self, environ, start_response):
        path_info = environ.get('PATH_INFO', '')
        if not path_info:
            return self.add_slash(environ, start_response)
        if path_info == '/':
            # @@: This should obviously be configurable
            filename = 'index.html'
        else:
            filename = request.path_info_pop(environ)
        resource = os.path.normcase(os.path.normpath(
                    self.resource_name + '/' + filename))
        if ( (self.root_resource is not None) and
            (not resource.startswith(self.root_resource)) ):
            # Out of bounds
            return self.not_found(environ, start_response)
        if not pkg_resources.resource_exists(self.package_name, resource):
            return self.not_found(environ, start_response)
        if pkg_resources.resource_isdir(self.package_name, resource):
            # @@: Cache?
            child_root = (self.root_resource is not None and
                          self.root_resource or self.resource_name)
            return self.__class__(
                self.package_name, resource, root_resource=child_root,
                cache_max_age=self.cache_max_age)(environ, start_response)
        if (environ.get('PATH_INFO')
            and environ.get('PATH_INFO') != '/'): # pragma: no cover
            return self.error_extra_path(environ, start_response) 
        full = pkg_resources.resource_filename(self.package_name, resource)
        if_none_match = environ.get('HTTP_IF_NONE_MATCH')
        if if_none_match:
            mytime = os.stat(full).st_mtime
            if str(mytime) == if_none_match:
                headers = []
                ETAG.update(headers, mytime)
                start_response('304 Not Modified', headers)
                return [''] # empty body

        fa = self.make_app(full)
        if self.cache_max_age:
            fa.cache_control(max_age=self.cache_max_age)
        return fa(environ, start_response)

    def not_found(self, environ, start_response, debug_message=None):
        comment=('SCRIPT_NAME=%r; PATH_INFO=%r; looking in package %s; '
                 'subdir %s ;debug: %s' % (environ.get('SCRIPT_NAME'),
                                           environ.get('PATH_INFO'),
                                           self.package_name,
                                           self.resource_name,
                                           debug_message or '(none)'))
        exc = httpexceptions.HTTPNotFound(
            'The resource at %s could not be found'
            % request.construct_url(environ),
            comment=comment)
        return exc.wsgi_application(environ, start_response)

    def __repr__(self):
        return '<%s %s:%s at %s>' % (self.__class__.__name__, self.package_name,
                                     self.root_resource, id(self))

class StaticURLInfo(object):
    implements(IStaticURLInfo)

    route_url = staticmethod(route_url) # for testing only

    def __init__(self, config):
        self.config = config
        self.registrations = []

    def generate(self, path, request, **kw):
        for (name, spec, is_url) in self.registrations:
            if path.startswith(spec):
                subpath = path[len(spec):]
                if is_url:
                    return urljoin(name, subpath)
                else:
                    kw['subpath'] = subpath
                    return self.route_url(name, request, **kw)

        raise ValueError('No static URL definition matching %s' % path)

    def add(self, name, spec, **extra):
        # This feature only allows for the serving of a directory and
        # the files contained within, not of a single resource;
        # appending a slash here if the spec doesn't have one is
        # required for proper prefix matching done in ``generate``
        # (``subpath = path[len(spec):]``).
        if not spec.endswith('/'):
            spec = spec + '/'

        # we also make sure the name ends with a slash, purely as a
        # convenience: a name that is a url is required to end in a
        # slash, so that ``urljoin(name, subpath))`` will work above
        # when the name is a URL, and it doesn't hurt things for it to
        # have a name that ends in a slash if it's used as a route
        # name instead of a URL.
        if not name.endswith('/'):
            # make sure it ends with a slash
            name = name + '/'

        names = [ t[0] for t in self.registrations ]

        if name in names:
            idx = names.index(name)
            self.registrations.pop(idx)

        if urlparse(name)[0]:
            # it's a URL
            self.registrations.append((name, spec, True))
        else:
            # it's a view name
            _info = extra.pop('_info', None)
            cache_max_age = extra.pop('cache_max_age', None)
            view = static_view(spec, cache_max_age=cache_max_age)
            # register a route using this view
            self.config.add_route(
                name,
                "%s*subpath" % name, # name already ends with slash
                view=view,
                view_for=self.__class__,
                factory=lambda *x: self,
                _info=_info
                )
            self.registrations.append((name, spec, False))

class static_view(object):
    """ An instance of this class is a callable which can act as a
    :mod:`repoze.bfg` :term:`view callable`; this view will serve
    static files from a directory on disk based on the ``root_dir``
    you provide to its constructor.

    The directory may contain subdirectories (recursively); the static
    view implementation will descend into these directories as
    necessary based on the components of the URL in order to resolve a
    path into a response.

    You may pass an absolute or relative filesystem path or a
    :term:`resource specification` representing the directory
    containing static files as the ``root_dir`` argument to this
    class' constructor.

    If the ``root_dir`` path is relative, and the ``package_name``
    argument is ``None``, ``root_dir`` will be considered relative to
    the directory in which the Python file which *calls* ``static``
    resides.  If the ``package_name`` name argument is provided, and a
    relative ``root_dir`` is provided, the ``root_dir`` will be
    considered relative to the Python :term:`package` specified by
    ``package_name`` (a dotted path to a Python package).

    ``cache_max_age`` influences the ``Expires`` and ``Max-Age``
    response headers returned by the view (default is 3600 seconds or
    five minutes).

    .. note:: If the ``root_dir`` is relative to a :term:`package`, or
         is a :term:`resource specification` the :mod:`repoze.bfg`
         ``resource`` ZCML directive or
         :class:`repoze.bfg.configuration.Configurator` method can be
         used to override resources within the named ``root_dir``
         package-relative directory.  However, if the ``root_dir`` is
         absolute, the ``resource`` directive will not be able to
         override the resources it contains.  """
    
    def __init__(self, root_dir, cache_max_age=3600, package_name=None):
        # package_name is for bw compat; it is preferred to pass in a
        # package-relative path as root_dir
        # (e.g. ``anotherpackage:foo/static``).
        caller_package_name = caller_package().__name__
        package_name = package_name or caller_package_name
        package_name, root_dir = resolve_resource_spec(root_dir, package_name)
        if package_name is None:
            app = StaticURLParser(root_dir, cache_max_age=cache_max_age)
        else:
            app = PackageURLParser(
                package_name, root_dir, cache_max_age=cache_max_age)
        self.app = app

    def __call__(self, context, request):
        subpath = '/'.join(request.subpath)
        request_copy = request.copy()
        # Fix up PATH_INFO to get rid of everything but the "subpath"
        # (the actual path to the file relative to the root dir).
        request_copy.environ['PATH_INFO'] = '/' + subpath
        # Zero out SCRIPT_NAME for good measure.
        request_copy.environ['SCRIPT_NAME'] = ''
        return request_copy.get_response(self.app)