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