Download Install Tutorial Docs FAQ Tools WikiLicense Team IRC Planet Involvement Shop Book

root/trunk/cherrypy/lib/cptools.py

Revision 2016 (checked in by nick125, 5 months ago)

* Test case and fix for Ticket #780

  • Property svn:eol-style set to native
Line 
1 """Functions for builtin CherryPy tools."""
2
3 import logging
4 import md5
5 import re
6
7 import cherrypy
8 from cherrypy.lib import http as _http
9
10
11 #                     Conditional HTTP request support                     #
12
13 def validate_etags(autotags=False):
14     """Validate the current ETag against If-Match, If-None-Match headers.
15     
16     If autotags is True, an ETag response-header value will be provided
17     from an MD5 hash of the response body (unless some other code has
18     already provided an ETag header). If False (the default), the ETag
19     will not be automatic.
20     
21     WARNING: the autotags feature is not designed for URL's which allow
22     methods other than GET. For example, if a POST to the same URL returns
23     no content, the automatic ETag will be incorrect, breaking a fundamental
24     use for entity tags in a possibly destructive fashion. Likewise, if you
25     raise 304 Not Modified, the response body will be empty, the ETag hash
26     will be incorrect, and your application will break.
27     See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.24
28     """
29     response = cherrypy.response
30    
31     # Guard against being run twice.
32     if hasattr(response, "ETag"):
33         return
34    
35     status, reason, msg = _http.valid_status(response.status)
36    
37     etag = response.headers.get('ETag')
38    
39     # Automatic ETag generation. See warning in docstring.
40     if (not etag) and autotags:
41         if status == 200:
42             etag = response.collapse_body()
43             etag = '"%s"' % md5.new(etag).hexdigest()
44             response.headers['ETag'] = etag
45    
46     response.ETag = etag
47    
48     # "If the request would, without the If-Match header field, result in
49     # anything other than a 2xx or 412 status, then the If-Match header
50     # MUST be ignored."
51     if status >= 200 and status <= 299:
52         request = cherrypy.request
53        
54         conditions = request.headers.elements('If-Match') or []
55         conditions = [str(x) for x in conditions]
56         if conditions and not (conditions == ["*"] or etag in conditions):
57             raise cherrypy.HTTPError(412, "If-Match failed: ETag %r did "
58                                      "not match %r" % (etag, conditions))
59        
60         conditions = request.headers.elements('If-None-Match') or []
61         conditions = [str(x) for x in conditions]
62         if conditions == ["*"] or etag in conditions:
63             if request.method in ("GET", "HEAD"):
64                 raise cherrypy.HTTPRedirect([], 304)
65             else:
66                 raise cherrypy.HTTPError(412, "If-None-Match failed: ETag %r "
67                                          "matched %r" % (etag, conditions))
68
69 def validate_since():
70     """Validate the current Last-Modified against If-Modified-Since headers.
71     
72     If no code has set the Last-Modified response header, then no validation
73     will be performed.
74     """
75     response = cherrypy.response
76     lastmod = response.headers.get('Last-Modified')
77     if lastmod:
78         status, reason, msg = _http.valid_status(response.status)
79        
80         request = cherrypy.request
81        
82         since = request.headers.get('If-Unmodified-Since')
83         if since and since != lastmod:
84             if (status >= 200 and status <= 299) or status == 412:
85                 raise cherrypy.HTTPError(412)
86        
87         since = request.headers.get('If-Modified-Since')
88         if since and since == lastmod:
89             if (status >= 200 and status <= 299) or status == 304:
90                 if request.method in ("GET", "HEAD"):
91                     raise cherrypy.HTTPRedirect([], 304)
92                 else:
93                     raise cherrypy.HTTPError(412)
94
95
96 #                                Tool code                                #
97
98 def proxy(base=None, local='X-Forwarded-Host', remote='X-Forwarded-For',
99           scheme='X-Forwarded-Proto'):
100     """Change the base URL (scheme://host[:port][/path]).
101     
102     For running a CP server behind Apache, lighttpd, or other HTTP server.
103     
104     If you want the new request.base to include path info (not just the host),
105     you must explicitly set base to the full base path, and ALSO set 'local'
106     to '', so that the X-Forwarded-Host request header (which never includes
107     path info) does not override it.
108     
109     cherrypy.request.remote.ip (the IP address of the client) will be
110     rewritten if the header specified by the 'remote' arg is valid.
111     By default, 'remote' is set to 'X-Forwarded-For'. If you do not
112     want to rewrite remote.ip, set the 'remote' arg to an empty string.
113     """
114    
115     request = cherrypy.request
116    
117     if scheme:
118         s = request.headers.get(scheme, None)
119         if s == 'on' and 'ssl' in scheme.lower():
120             # This handles e.g. webfaction's 'X-Forwarded-Ssl: on' header
121             scheme = 'https'
122         else:
123             # This is for lighttpd/pound/Mongrel's 'X-Forwarded-Proto: https'
124             scheme = s
125     if not scheme:
126         scheme = request.base[:request.base.find("://")]
127    
128     if local:
129         base = request.headers.get(local, base)
130     if not base:
131         port = cherrypy.request.local.port
132         if port == 80:
133             base = '127.0.0.1'
134         else:
135             base = '127.0.0.1:%s' % port
136    
137     if base.find("://") == -1:
138         # add http:// or https:// if needed
139         base = scheme + "://" + base
140    
141     request.base = base
142    
143     if remote:
144         xff = request.headers.get(remote)
145         if xff:
146             if remote == 'X-Forwarded-For':
147                 # See http://bob.pythonmac.org/archives/2005/09/23/apache-x-forwarded-for-caveat/
148                 xff = xff.split(',')[-1].strip()
149             request.remote.ip = xff
150
151
152 def ignore_headers(headers=('Range',)):
153     """Delete request headers whose field names are included in 'headers'.
154     
155     This is a useful tool for working behind certain HTTP servers;
156     for example, Apache duplicates the work that CP does for 'Range'
157     headers, and will doubly-truncate the response.
158     """
159     request = cherrypy.request
160     for name in headers:
161         if name in request.headers:
162             del request.headers[name]
163
164
165 def response_headers(headers=None):
166     """Set headers on the response."""
167     for name, value in (headers or []):
168         cherrypy.response.headers[name] = value
169 response_headers.failsafe = True
170
171
172 def referer(pattern, accept=True, accept_missing=False, error=403,
173             message='Forbidden Referer header.'):
174     """Raise HTTPError if Referer header does/does not match the given pattern.
175     
176     pattern: a regular expression pattern to test against the Referer.
177     accept: if True, the Referer must match the pattern; if False,
178         the Referer must NOT match the pattern.
179     accept_missing: if True, permit requests with no Referer header.
180     error: the HTTP error code to return to the client on failure.
181     message: a string to include in the response body on failure.
182     """
183     try:
184         match = bool(re.match(pattern, cherrypy.request.headers['Referer']))
185         if accept == match:
186             return
187     except KeyError:
188         if accept_missing:
189             return
190    
191     raise cherrypy.HTTPError(error, message)
192
193
194 class SessionAuth(object):
195     """Assert that the user is logged in."""
196    
197     session_key = "username"
198    
199     def check_username_and_password(self, username, password):
200         pass
201    
202     def anonymous(self):
203         """Provide a temporary user name for anonymous users."""
204         pass
205    
206     def on_login(self, username):
207         pass
208    
209     def on_logout(self, username):
210         pass
211    
212     def on_check(self, username):
213         pass
214    
215     def login_screen(self, from_page='..', username='', error_msg='', **kwargs):
216         return """<html><body>
217 Message: %(error_msg)s
218 <form method="post" action="do_login">
219     Login: <input type="text" name="username" value="%(username)s" size="10" /><br />
220     Password: <input type="password" name="password" size="10" /><br />
221     <input type="hidden" name="from_page" value="%(from_page)s" /><br />
222     <input type="submit" />
223 </form>
224 </body></html>""" % {'from_page': from_page, 'username': username,
225                      'error_msg': error_msg}
226    
227     def do_login(self, username, password, from_page='..', **kwargs):
228         """Login. May raise redirect, or return True if request handled."""
229         error_msg = self.check_username_and_password(username, password)
230         if error_msg:
231             body = self.login_screen(from_page, username, error_msg)
232             cherrypy.response.body = body
233             if cherrypy.response.headers.has_key("Content-Length"):
234                 # Delete Content-Length header so finalize() recalcs it.
235                 del cherrypy.response.headers["Content-Length"]
236             return True
237         else:
238             cherrypy.session[self.session_key] = cherrypy.request.login = username
239             self.on_login(username)
240             raise cherrypy.HTTPRedirect(from_page or "/")
241    
242     def do_logout(self, from_page='..', **kwargs):
243         """Logout. May raise redirect, or return True if request handled."""
244         sess = cherrypy.session
245         username = sess.get(self.session_key)
246         sess[self.session_key] = None
247         if username:
248             cherrypy.request.login = None
249             self.on_logout(username)
250         raise cherrypy.HTTPRedirect(from_page)
251    
252     def do_check(self):
253         """Assert username. May raise redirect, or return True if request handled."""
254         sess = cherrypy.session
255         request = cherrypy.request
256        
257         username = sess.get(self.session_key)
258         if not username:
259             sess[self.session_key] = username = self.anonymous()
260         if not username:
261             cherrypy.response.body = self.login_screen(cherrypy.url(qs=request.query_string))
262             if cherrypy.response.headers.has_key("Content-Length"):
263                 # Delete Content-Length header so finalize() recalcs it.
264                 del cherrypy.response.headers["Content-Length"]
265             return True
266         cherrypy.request.login = username
267         self.on_check(username)
268    
269     def run(self):
270         request = cherrypy.request
271         path = request.path_info
272         if path.endswith('login_screen'):
273             return self.login_screen(**request.params)
274         elif path.endswith('do_login'):
275             return self.do_login(**request.params)
276         elif path.endswith('do_logout'):
277             return self.do_logout(**request.params)
278         else:
279             return self.do_check()
280
281
282 def session_auth(**kwargs):
283     sa = SessionAuth()
284     for k, v in kwargs.iteritems():
285         setattr(sa, k, v)
286     return sa.run()
287 session_auth.__doc__ = """Session authentication hook.
288
289 Any attribute of the SessionAuth class may be overridden via a keyword arg
290 to this function:
291
292 """ + "\n".join(["%s: %s" % (k, type(getattr(SessionAuth, k)).__name__)
293                  for k in dir(SessionAuth) if not k.startswith("__")])
294
295
296 def log_traceback(severity=logging.DEBUG):
297     """Write the last error's traceback to the cherrypy error log."""
298     cherrypy.log("", "HTTP", severity=severity, traceback=True)
299
300 def log_request_headers():
301     """Write request headers to the cherrypy error log."""
302     h = ["  %s: %s" % (k, v) for k, v in cherrypy.request.header_list]
303     cherrypy.log('\nRequest Headers:\n' + '\n'.join(h), "HTTP")
304
305 def log_hooks():
306     """Write request.hooks to the cherrypy error log."""
307     msg = []
308     # Sort by the standard points if possible.
309     from cherrypy import _cprequest
310     points = _cprequest.hookpoints
311     for k in cherrypy.request.hooks.keys():
312         if k not in points:
313             points.append(k)
314    
315     for k in points:
316         msg.append("    %s:" % k)
317         v = cherrypy.request.hooks.get(k, [])
318         v.sort()
319         for h in v:
320             msg.append("        %r" % h)
321     cherrypy.log('\nRequest Hooks for ' + cherrypy.url() +
322                  ':\n' + '\n'.join(msg), "HTTP")
323
324 def redirect(url='', internal=True):
325     """Raise InternalRedirect or HTTPRedirect to the given url."""
326     if internal:
327         raise cherrypy.InternalRedirect(url)
328     else:
329         raise cherrypy.HTTPRedirect(url)
330
331 def trailing_slash(missing=True, extra=False):
332     """Redirect if path_info has (missing|extra) trailing slash."""
333     request = cherrypy.request
334     pi = request.path_info
335    
336     if request.is_index is True:
337         if missing:
338             if not pi.endswith('/'):
339                 new_url = cherrypy.url(pi + '/', request.query_string)
340                 raise cherrypy.HTTPRedirect(new_url)
341     elif request.is_index is False:
342         if extra:
343             # If pi == '/', don't redirect to ''!
344             if pi.endswith('/') and pi != '/':
345                 new_url = cherrypy.url(pi[:-1], request.query_string)
346                 raise cherrypy.HTTPRedirect(new_url)
347
348 def flatten():
349     """Wrap response.body in a generator that recursively iterates over body.
350     
351     This allows cherrypy.response.body to consist of 'nested generators';
352     that is, a set of generators that yield generators.
353     """
354     import types
355     def flattener(input):
356         for x in input:
357             if not isinstance(x, types.GeneratorType):
358                 yield x
359             else:
360                 for y in flattener(x):
361                     yield y
362     response = cherrypy.response
363     response.body = flattener(response.body)
364
365
366 def accept(media=None):
367     """Return the client's preferred media-type (from the given Content-Types).
368     
369     If 'media' is None (the default), no test will be performed.
370     
371     If 'media' is provided, it should be the Content-Type value (as a string)
372     or values (as a list or tuple of strings) which the current request
373     can emit. The client's acceptable media ranges (as declared in the
374     Accept request header) will be matched in order to these Content-Type
375     values; the first such string is returned. That is, the return value
376     will always be one of the strings provided in the 'media' arg (or None
377     if 'media' is None).
378     
379     If no match is found, then HTTPError 406 (Not Acceptable) is raised.
380     Note that most web browsers send */* as a (low-quality) acceptable
381     media range, which should match any Content-Type. In addition, "...if
382     no Accept header field is present, then it is assumed that the client
383     accepts all media types."
384     
385     Matching types are checked in order of client preference first,
386     and then in the order of the given 'media' values.
387     
388     Note that this function does not honor accept-params (other than "q").
389     """
390     if not media:
391         return
392     if isinstance(media, basestring):
393         media = [media]
394    
395     # Parse the Accept request header, and try to match one
396     # of the requested media-ranges (in order of preference).
397     ranges = cherrypy.request.headers.elements('Accept')
398     if not ranges:
399         # Any media type is acceptable.
400         return media[0]
401     else:
402         # Note that 'ranges' is sorted in order of preference
403         for element in ranges:
404             if element.qvalue > 0:
405                 if element.value == "*/*":
406                     # Matches any type or subtype
407                     return media[0]
408                 elif element.value.endswith("/*"):
409                     # Matches any subtype
410                     mtype = element.value[:-1]  # Keep the slash
411                     for m in media:
412                         if m.startswith(mtype):
413                             return m
414                 else:
415                     # Matches exact value
416                     if element.value in media:
417                         return element.value
418    
419     # No suitable media-range found.
420     ah = cherrypy.request.headers.get('Accept')
421     if ah is None:
422         msg = "Your client did not send an Accept header."
423     else:
424         msg = "Your client sent this Accept header: %s." % ah
425     msg += (" But this resource only emits these media types: %s." %
426             ", ".join(media))
427     raise cherrypy.HTTPError(406, msg)
428
Note: See TracBrowser for help on using the browser.

Hosted by WebFaction

Log in as guest/cpguest to create tickets