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

root/trunk/cherrypy/lib/cptools.py

Revision 2460 (checked in by fumanchu, 2 weeks ago)

All internals now use cherrypy.serving.request/response instead of request/response for a speed boost.

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

Hosted by WebFaction

Log in as guest/cpguest to create tickets