Package cherrypy :: Package lib :: Module cptools
[hide private]
[frames] | no frames]

Source Code for Module cherrypy.lib.cptools

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