1 """Functions for builtin CherryPy tools."""
7 from cherrypy
._cpcompat
import basestring
, ntob
, md5
, set
8 from cherrypy
.lib
import httputil
as _httputil
11 # Conditional HTTP request support #
13 def validate_etags(autotags
=False, debug
=False):
14 """Validate the current ETag against If-Match, If-None-Match headers.
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.
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.
29 response
= cherrypy
.serving
.response
31 # Guard against being run twice.
32 if hasattr(response
, "ETag"):
35 status
, reason
, msg
= _httputil
.valid_status(response
.status
)
37 etag
= response
.headers
.get('ETag')
39 # Automatic ETag generation. See warning in docstring.
42 cherrypy
.log('ETag already set: %s' % etag
, 'TOOLS.ETAGS')
45 cherrypy
.log('Autotags off', 'TOOLS.ETAGS')
48 cherrypy
.log('Status not 200', 'TOOLS.ETAGS')
50 etag
= response
.collapse_body()
51 etag
= '"%s"' % md5(etag
).hexdigest()
53 cherrypy
.log('Setting ETag: %s' % etag
, 'TOOLS.ETAGS')
54 response
.headers
['ETag'] = etag
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
62 cherrypy
.log('Status: %s' % status
, 'TOOLS.ETAGS')
63 if status
>= 200 and status
<= 299:
64 request
= cherrypy
.serving
.request
66 conditions
= request
.headers
.elements('If-Match') or []
67 conditions
= [str(x
) for x
in conditions
]
69 cherrypy
.log('If-Match conditions: %s' % repr(conditions
),
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
))
75 conditions
= request
.headers
.elements('If-None-Match') or []
76 conditions
= [str(x
) for x
in conditions
]
78 cherrypy
.log('If-None-Match conditions: %s' % repr(conditions
),
80 if conditions
== ["*"] or etag
in conditions
:
82 cherrypy
.log('request.method: %s' % request
.method
, 'TOOLS.ETAGS')
83 if request
.method
in ("GET", "HEAD"):
84 raise cherrypy
.HTTPRedirect([], 304)
86 raise cherrypy
.HTTPError(412, "If-None-Match failed: ETag %r "
87 "matched %r" % (etag
, conditions
))
90 """Validate the current Last-Modified against If-Modified-Since headers.
92 If no code has set the Last-Modified response header, then no validation
95 response
= cherrypy
.serving
.response
96 lastmod
= response
.headers
.get('Last-Modified')
98 status
, reason
, msg
= _httputil
.valid_status(response
.status
)
100 request
= cherrypy
.serving
.request
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)
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)
113 raise cherrypy
.HTTPError(412)
118 def allow(methods
=None, debug
=False):
119 """Raise 405 if request.method not in methods (default GET/HEAD).
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.
125 Regardless of whether the current method is allowed or not, this
126 also emits an 'Allow' response header, containing the given methods.
128 if not isinstance(methods
, (tuple, list)):
130 methods
= [m
.upper() for m
in methods
if m
]
132 methods
= ['GET', 'HEAD']
133 elif 'GET' in methods
and 'HEAD' not in methods
:
134 methods
.append('HEAD')
136 cherrypy
.response
.headers
['Allow'] = ', '.join(methods
)
137 if cherrypy
.request
.method
not in methods
:
139 cherrypy
.log('request.method %r not in methods %r' %
140 (cherrypy
.request
.method
, methods
), 'TOOLS.ALLOW')
141 raise cherrypy
.HTTPError(405)
144 cherrypy
.log('request.method %r in methods %r' %
145 (cherrypy
.request
.method
, methods
), 'TOOLS.ALLOW')
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]).
152 For running a CP server behind Apache, lighttpd, or other HTTP server.
154 If you want the new request.base to include path info (not just the host),
155 you must explicitly set base to the full base path, and ALSO set 'local'
156 to '', so that the X-Forwarded-Host request header (which never includes
157 path info) does not override it. Regardless, the value for 'base' MUST
160 cherrypy.request.remote.ip (the IP address of the client) will be
161 rewritten if the header specified by the 'remote' arg is valid.
162 By default, 'remote' is set to 'X-Forwarded-For'. If you do not
163 want to rewrite remote.ip, set the 'remote' arg to an empty string.
166 request
= cherrypy
.serving
.request
169 s
= request
.headers
.get(scheme
, None)
171 cherrypy
.log('Testing scheme %r:%r' % (scheme
, s
), 'TOOLS.PROXY')
172 if s
== 'on' and 'ssl' in scheme
.lower():
173 # This handles e.g. webfaction's 'X-Forwarded-Ssl: on' header
176 # This is for lighttpd/pound/Mongrel's 'X-Forwarded-Proto: https'
179 scheme
= request
.base
[:request
.base
.find("://")]
182 lbase
= request
.headers
.get(local
, None)
184 cherrypy
.log('Testing local %r:%r' % (local
, lbase
), 'TOOLS.PROXY')
185 if lbase
is not None:
186 base
= lbase
.split(',')[0]
188 port
= request
.local
.port
192 base
= '127.0.0.1:%s' % port
194 if base
.find("://") == -1:
195 # add http:// or https:// if needed
196 base
= scheme
+ "://" + base
201 xff
= request
.headers
.get(remote
)
203 cherrypy
.log('Testing remote %r:%r' % (remote
, xff
), 'TOOLS.PROXY')
205 if remote
== 'X-Forwarded-For':
206 # See http://bob.pythonmac.org/archives/2005/09/23/apache-x-forwarded-for-caveat/
207 xff
= xff
.split(',')[-1].strip()
208 request
.remote
.ip
= xff
211 def ignore_headers(headers
=('Range',), debug
=False):
212 """Delete request headers whose field names are included in 'headers'.
214 This is a useful tool for working behind certain HTTP servers;
215 for example, Apache duplicates the work that CP does for 'Range'
216 headers, and will doubly-truncate the response.
218 request
= cherrypy
.serving
.request
220 if name
in request
.headers
:
222 cherrypy
.log('Ignoring request header %r' % name
,
223 'TOOLS.IGNORE_HEADERS')
224 del request
.headers
[name
]
227 def response_headers(headers
=None, debug
=False):
228 """Set headers on the response."""
230 cherrypy
.log('Setting response headers: %s' % repr(headers
),
231 'TOOLS.RESPONSE_HEADERS')
232 for name
, value
in (headers
or []):
233 cherrypy
.serving
.response
.headers
[name
] = value
234 response_headers
.failsafe
= True
237 def referer(pattern
, accept
=True, accept_missing
=False, error
=403,
238 message
='Forbidden Referer header.', debug
=False):
239 """Raise HTTPError if Referer header does/does not match the given pattern.
242 A regular expression pattern to test against the Referer.
245 If True, the Referer must match the pattern; if False,
246 the Referer must NOT match the pattern.
249 If True, permit requests with no Referer header.
252 The HTTP error code to return to the client on failure.
255 A string to include in the response body on failure.
259 ref
= cherrypy
.serving
.request
.headers
['Referer']
260 match
= bool(re
.match(pattern
, ref
))
262 cherrypy
.log('Referer %r matches %r' % (ref
, pattern
),
268 cherrypy
.log('No Referer header', 'TOOLS.REFERER')
272 raise cherrypy
.HTTPError(error
, message
)
275 class SessionAuth(object):
276 """Assert that the user is logged in."""
278 session_key
= "username"
281 def check_username_and_password(self
, username
, password
):
285 """Provide a temporary user name for anonymous users."""
288 def on_login(self
, username
):
291 def on_logout(self
, username
):
294 def on_check(self
, username
):
297 def login_screen(self
, from_page
='..', username
='', error_msg
='', **kwargs
):
298 return ntob("""<html><body>
299 Message: %(error_msg)s
300 <form method="post" action="do_login">
301 Login: <input type="text" name="username" value="%(username)s" size="10" /><br />
302 Password: <input type="password" name="password" size="10" /><br />
303 <input type="hidden" name="from_page" value="%(from_page)s" /><br />
304 <input type="submit" />
306 </body></html>""" % {'from_page': from_page
, 'username': username
,
307 'error_msg': error_msg
}, "utf-8")
309 def do_login(self
, username
, password
, from_page
='..', **kwargs
):
310 """Login. May raise redirect, or return True if request handled."""
311 response
= cherrypy
.serving
.response
312 error_msg
= self
.check_username_and_password(username
, password
)
314 body
= self
.login_screen(from_page
, username
, error_msg
)
316 if "Content-Length" in response
.headers
:
317 # Delete Content-Length header so finalize() recalcs it.
318 del response
.headers
["Content-Length"]
321 cherrypy
.serving
.request
.login
= username
322 cherrypy
.session
[self
.session_key
] = username
323 self
.on_login(username
)
324 raise cherrypy
.HTTPRedirect(from_page
or "/")
326 def do_logout(self
, from_page
='..', **kwargs
):
327 """Logout. May raise redirect, or return True if request handled."""
328 sess
= cherrypy
.session
329 username
= sess
.get(self
.session_key
)
330 sess
[self
.session_key
] = None
332 cherrypy
.serving
.request
.login
= None
333 self
.on_logout(username
)
334 raise cherrypy
.HTTPRedirect(from_page
)
337 """Assert username. May raise redirect, or return True if request handled."""
338 sess
= cherrypy
.session
339 request
= cherrypy
.serving
.request
340 response
= cherrypy
.serving
.response
342 username
= sess
.get(self
.session_key
)
344 sess
[self
.session_key
] = username
= self
.anonymous()
346 cherrypy
.log('No session[username], trying anonymous', 'TOOLS.SESSAUTH')
348 url
= cherrypy
.url(qs
=request
.query_string
)
350 cherrypy
.log('No username, routing to login_screen with '
351 'from_page %r' % url
, 'TOOLS.SESSAUTH')
352 response
.body
= self
.login_screen(url
)
353 if "Content-Length" in response
.headers
:
354 # Delete Content-Length header so finalize() recalcs it.
355 del response
.headers
["Content-Length"]
358 cherrypy
.log('Setting request.login to %r' % username
, 'TOOLS.SESSAUTH')
359 request
.login
= username
360 self
.on_check(username
)
363 request
= cherrypy
.serving
.request
364 response
= cherrypy
.serving
.response
366 path
= request
.path_info
367 if path
.endswith('login_screen'):
369 cherrypy
.log('routing %r to login_screen' % path
, 'TOOLS.SESSAUTH')
370 return self
.login_screen(**request
.params
)
371 elif path
.endswith('do_login'):
372 if request
.method
!= 'POST':
373 response
.headers
['Allow'] = "POST"
375 cherrypy
.log('do_login requires POST', 'TOOLS.SESSAUTH')
376 raise cherrypy
.HTTPError(405)
378 cherrypy
.log('routing %r to do_login' % path
, 'TOOLS.SESSAUTH')
379 return self
.do_login(**request
.params
)
380 elif path
.endswith('do_logout'):
381 if request
.method
!= 'POST':
382 response
.headers
['Allow'] = "POST"
383 raise cherrypy
.HTTPError(405)
385 cherrypy
.log('routing %r to do_logout' % path
, 'TOOLS.SESSAUTH')
386 return self
.do_logout(**request
.params
)
389 cherrypy
.log('No special path, running do_check', 'TOOLS.SESSAUTH')
390 return self
.do_check()
393 def session_auth(**kwargs
):
395 for k
, v
in kwargs
.items():
398 session_auth
.__doc
__ = """Session authentication hook.
400 Any attribute of the SessionAuth class may be overridden via a keyword arg
403 """ + "\n".join(["%s: %s" % (k
, type(getattr(SessionAuth
, k
)).__name
__)
404 for k
in dir(SessionAuth
) if not k
.startswith("__")])
407 def log_traceback(severity
=logging
.ERROR
, debug
=False):
408 """Write the last error's traceback to the cherrypy error log."""
409 cherrypy
.log("", "HTTP", severity
=severity
, traceback
=True)
411 def log_request_headers(debug
=False):
412 """Write request headers to the cherrypy error log."""
413 h
= [" %s: %s" % (k
, v
) for k
, v
in cherrypy
.serving
.request
.header_list
]
414 cherrypy
.log('\nRequest Headers:\n' + '\n'.join(h
), "HTTP")
416 def log_hooks(debug
=False):
417 """Write request.hooks to the cherrypy error log."""
418 request
= cherrypy
.serving
.request
421 # Sort by the standard points if possible.
422 from cherrypy
import _cprequest
423 points
= _cprequest
.hookpoints
424 for k
in request
.hooks
.keys():
429 msg
.append(" %s:" % k
)
430 v
= request
.hooks
.get(k
, [])
433 msg
.append(" %r" % h
)
434 cherrypy
.log('\nRequest Hooks for ' + cherrypy
.url() +
435 ':\n' + '\n'.join(msg
), "HTTP")
437 def redirect(url
='', internal
=True, debug
=False):
438 """Raise InternalRedirect or HTTPRedirect to the given url."""
440 cherrypy
.log('Redirecting %sto: %s' %
441 ({True: 'internal ', False: ''}[internal
], url
),
444 raise cherrypy
.InternalRedirect(url
)
446 raise cherrypy
.HTTPRedirect(url
)
448 def trailing_slash(missing
=True, extra
=False, status
=None, debug
=False):
449 """Redirect if path_info has (missing|extra) trailing slash."""
450 request
= cherrypy
.serving
.request
451 pi
= request
.path_info
454 cherrypy
.log('is_index: %r, missing: %r, extra: %r, path_info: %r' %
455 (request
.is_index
, missing
, extra
, pi
),
456 'TOOLS.TRAILING_SLASH')
457 if request
.is_index
is True:
459 if not pi
.endswith('/'):
460 new_url
= cherrypy
.url(pi
+ '/', request
.query_string
)
461 raise cherrypy
.HTTPRedirect(new_url
, status
=status
or 301)
462 elif request
.is_index
is False:
464 # If pi == '/', don't redirect to ''!
465 if pi
.endswith('/') and pi
!= '/':
466 new_url
= cherrypy
.url(pi
[:-1], request
.query_string
)
467 raise cherrypy
.HTTPRedirect(new_url
, status
=status
or 301)
469 def flatten(debug
=False):
470 """Wrap response.body in a generator that recursively iterates over body.
472 This allows cherrypy.response.body to consist of 'nested generators';
473 that is, a set of generators that yield generators.
476 def flattener(input):
479 if not isinstance(x
, types
.GeneratorType
):
483 for y
in flattener(x
):
487 cherrypy
.log('Flattened %d chunks' % numchunks
, 'TOOLS.FLATTEN')
488 response
= cherrypy
.serving
.response
489 response
.body
= flattener(response
.body
)
492 def accept(media
=None, debug
=False):
493 """Return the client's preferred media-type (from the given Content-Types).
495 If 'media' is None (the default), no test will be performed.
497 If 'media' is provided, it should be the Content-Type value (as a string)
498 or values (as a list or tuple of strings) which the current resource
499 can emit. The client's acceptable media ranges (as declared in the
500 Accept request header) will be matched in order to these Content-Type
501 values; the first such string is returned. That is, the return value
502 will always be one of the strings provided in the 'media' arg (or None
505 If no match is found, then HTTPError 406 (Not Acceptable) is raised.
506 Note that most web browsers send */* as a (low-quality) acceptable
507 media range, which should match any Content-Type. In addition, "...if
508 no Accept header field is present, then it is assumed that the client
509 accepts all media types."
511 Matching types are checked in order of client preference first,
512 and then in the order of the given 'media' values.
514 Note that this function does not honor accept-params (other than "q").
518 if isinstance(media
, basestring
):
520 request
= cherrypy
.serving
.request
522 # Parse the Accept request header, and try to match one
523 # of the requested media-ranges (in order of preference).
524 ranges
= request
.headers
.elements('Accept')
526 # Any media type is acceptable.
528 cherrypy
.log('No Accept header elements', 'TOOLS.ACCEPT')
531 # Note that 'ranges' is sorted in order of preference
532 for element
in ranges
:
533 if element
.qvalue
> 0:
534 if element
.value
== "*/*":
535 # Matches any type or subtype
537 cherrypy
.log('Match due to */*', 'TOOLS.ACCEPT')
539 elif element
.value
.endswith("/*"):
540 # Matches any subtype
541 mtype
= element
.value
[:-1] # Keep the slash
543 if m
.startswith(mtype
):
545 cherrypy
.log('Match due to %s' % element
.value
,
549 # Matches exact value
550 if element
.value
in media
:
552 cherrypy
.log('Match due to %s' % element
.value
,
556 # No suitable media-range found.
557 ah
= request
.headers
.get('Accept')
559 msg
= "Your client did not send an Accept header."
561 msg
= "Your client sent this Accept header: %s." % ah
562 msg
+= (" But this resource only emits these media types: %s." %
564 raise cherrypy
.HTTPError(406, msg
)
567 class MonitoredHeaderMap(_httputil
.HeaderMap
):
570 self
.accessed_headers
= set()
572 def __getitem__(self
, key
):
573 self
.accessed_headers
.add(key
)
574 return _httputil
.HeaderMap
.__getitem
__(self
, key
)
576 def __contains__(self
, key
):
577 self
.accessed_headers
.add(key
)
578 return _httputil
.HeaderMap
.__contains
__(self
, key
)
580 def get(self
, key
, default
=None):
581 self
.accessed_headers
.add(key
)
582 return _httputil
.HeaderMap
.get(self
, key
, default
=default
)
584 def has_key(self
, key
):
585 self
.accessed_headers
.add(key
)
586 return _httputil
.HeaderMap
.has_key(self
, key
)
589 def autovary(ignore
=None, debug
=False):
590 """Auto-populate the Vary response header based on request.header access."""
591 request
= cherrypy
.serving
.request
593 req_h
= request
.headers
594 request
.headers
= MonitoredHeaderMap()
595 request
.headers
.update(req_h
)
597 ignore
= set(['Content-Disposition', 'Content-Length', 'Content-Type'])
599 def set_response_header():
600 resp_h
= cherrypy
.serving
.response
.headers
601 v
= set([e
.value
for e
in resp_h
.elements('Vary')])
603 cherrypy
.log('Accessed headers: %s' % request
.headers
.accessed_headers
,
605 v
= v
.union(request
.headers
.accessed_headers
)
606 v
= v
.difference(ignore
)
609 resp_h
['Vary'] = ', '.join(v
)
610 request
.hooks
.attach('before_finalize', set_response_header
, 95)