Removed spurious static_path.
[smonitor.git] / monitor / cherrypy / lib / cptools.py
blob3eedf97a26468c1556d7ae685addaa08ff1f9865
1 """Functions for builtin CherryPy tools."""
3 import logging
4 import re
6 import cherrypy
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.
28 """
29 response = cherrypy.serving.response
31 # Guard against being run twice.
32 if hasattr(response, "ETag"):
33 return
35 status, reason, msg = _httputil.valid_status(response.status)
37 etag = response.headers.get('ETag')
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
56 response.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
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
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))
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))
89 def validate_since():
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
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)
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)
112 else:
113 raise cherrypy.HTTPError(412)
116 # Tool code #
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)):
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')
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')
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
158 NOT end in a slash.
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
168 if scheme:
169 s = request.headers.get(scheme, None)
170 if debug:
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
174 scheme = 'https'
175 else:
176 # This is for lighttpd/pound/Mongrel's 'X-Forwarded-Proto: https'
177 scheme = s
178 if not scheme:
179 scheme = request.base[:request.base.find("://")]
181 if local:
182 lbase = request.headers.get(local, None)
183 if debug:
184 cherrypy.log('Testing local %r:%r' % (local, lbase), 'TOOLS.PROXY')
185 if lbase is not None:
186 base = lbase.split(',')[0]
187 if not base:
188 port = request.local.port
189 if port == 80:
190 base = '127.0.0.1'
191 else:
192 base = '127.0.0.1:%s' % port
194 if base.find("://") == -1:
195 # add http:// or https:// if needed
196 base = scheme + "://" + base
198 request.base = base
200 if remote:
201 xff = request.headers.get(remote)
202 if debug:
203 cherrypy.log('Testing remote %r:%r' % (remote, xff), 'TOOLS.PROXY')
204 if xff:
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
219 for name in headers:
220 if name in request.headers:
221 if debug:
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."""
229 if debug:
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.
241 pattern
242 A regular expression pattern to test against the Referer.
244 accept
245 If True, the Referer must match the pattern; if False,
246 the Referer must NOT match the pattern.
248 accept_missing
249 If True, permit requests with no Referer header.
251 error
252 The HTTP error code to return to the client on failure.
254 message
255 A string to include in the response body on failure.
258 try:
259 ref = cherrypy.serving.request.headers['Referer']
260 match = bool(re.match(pattern, ref))
261 if debug:
262 cherrypy.log('Referer %r matches %r' % (ref, pattern),
263 'TOOLS.REFERER')
264 if accept == match:
265 return
266 except KeyError:
267 if debug:
268 cherrypy.log('No Referer header', 'TOOLS.REFERER')
269 if accept_missing:
270 return
272 raise cherrypy.HTTPError(error, message)
275 class SessionAuth(object):
276 """Assert that the user is logged in."""
278 session_key = "username"
279 debug = False
281 def check_username_and_password(self, username, password):
282 pass
284 def anonymous(self):
285 """Provide a temporary user name for anonymous users."""
286 pass
288 def on_login(self, username):
289 pass
291 def on_logout(self, username):
292 pass
294 def on_check(self, username):
295 pass
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" />
305 </form>
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)
313 if error_msg:
314 body = self.login_screen(from_page, username, error_msg)
315 response.body = body
316 if "Content-Length" in response.headers:
317 # Delete Content-Length header so finalize() recalcs it.
318 del response.headers["Content-Length"]
319 return True
320 else:
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
331 if username:
332 cherrypy.serving.request.login = None
333 self.on_logout(username)
334 raise cherrypy.HTTPRedirect(from_page)
336 def do_check(self):
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)
343 if not username:
344 sess[self.session_key] = username = self.anonymous()
345 if self.debug:
346 cherrypy.log('No session[username], trying anonymous', 'TOOLS.SESSAUTH')
347 if not username:
348 url = cherrypy.url(qs=request.query_string)
349 if self.debug:
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"]
356 return True
357 if self.debug:
358 cherrypy.log('Setting request.login to %r' % username, 'TOOLS.SESSAUTH')
359 request.login = username
360 self.on_check(username)
362 def run(self):
363 request = cherrypy.serving.request
364 response = cherrypy.serving.response
366 path = request.path_info
367 if path.endswith('login_screen'):
368 if self.debug:
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"
374 if self.debug:
375 cherrypy.log('do_login requires POST', 'TOOLS.SESSAUTH')
376 raise cherrypy.HTTPError(405)
377 if self.debug:
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)
384 if self.debug:
385 cherrypy.log('routing %r to do_logout' % path, 'TOOLS.SESSAUTH')
386 return self.do_logout(**request.params)
387 else:
388 if self.debug:
389 cherrypy.log('No special path, running do_check', 'TOOLS.SESSAUTH')
390 return self.do_check()
393 def session_auth(**kwargs):
394 sa = SessionAuth()
395 for k, v in kwargs.items():
396 setattr(sa, k, v)
397 return sa.run()
398 session_auth.__doc__ = """Session authentication hook.
400 Any attribute of the SessionAuth class may be overridden via a keyword arg
401 to this function:
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
420 msg = []
421 # Sort by the standard points if possible.
422 from cherrypy import _cprequest
423 points = _cprequest.hookpoints
424 for k in request.hooks.keys():
425 if k not in points:
426 points.append(k)
428 for k in points:
429 msg.append(" %s:" % k)
430 v = request.hooks.get(k, [])
431 v.sort()
432 for h in v:
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."""
439 if debug:
440 cherrypy.log('Redirecting %sto: %s' %
441 ({True: 'internal ', False: ''}[internal], url),
442 'TOOLS.REDIRECT')
443 if internal:
444 raise cherrypy.InternalRedirect(url)
445 else:
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
453 if debug:
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:
458 if missing:
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:
463 if extra:
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.
475 import types
476 def flattener(input):
477 numchunks = 0
478 for x in input:
479 if not isinstance(x, types.GeneratorType):
480 numchunks += 1
481 yield x
482 else:
483 for y in flattener(x):
484 numchunks += 1
485 yield y
486 if debug:
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
503 if 'media' is 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").
516 if not media:
517 return
518 if isinstance(media, basestring):
519 media = [media]
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')
525 if not ranges:
526 # Any media type is acceptable.
527 if debug:
528 cherrypy.log('No Accept header elements', 'TOOLS.ACCEPT')
529 return media[0]
530 else:
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
536 if debug:
537 cherrypy.log('Match due to */*', 'TOOLS.ACCEPT')
538 return media[0]
539 elif element.value.endswith("/*"):
540 # Matches any subtype
541 mtype = element.value[:-1] # Keep the slash
542 for m in media:
543 if m.startswith(mtype):
544 if debug:
545 cherrypy.log('Match due to %s' % element.value,
546 'TOOLS.ACCEPT')
547 return m
548 else:
549 # Matches exact value
550 if element.value in media:
551 if debug:
552 cherrypy.log('Match due to %s' % element.value,
553 'TOOLS.ACCEPT')
554 return element.value
556 # No suitable media-range found.
557 ah = request.headers.get('Accept')
558 if ah is None:
559 msg = "Your client did not send an Accept header."
560 else:
561 msg = "Your client sent this Accept header: %s." % ah
562 msg += (" But this resource only emits these media types: %s." %
563 ", ".join(media))
564 raise cherrypy.HTTPError(406, msg)
567 class MonitoredHeaderMap(_httputil.HeaderMap):
569 def __init__(self):
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)
596 if ignore is None:
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')])
602 if debug:
603 cherrypy.log('Accessed headers: %s' % request.headers.accessed_headers,
604 'TOOLS.AUTOVARY')
605 v = v.union(request.headers.accessed_headers)
606 v = v.difference(ignore)
607 v = list(v)
608 v.sort()
609 resp_h['Vary'] = ', '.join(v)
610 request.hooks.attach('before_finalize', set_response_header, 95)