handlers.py
432 lines
| 13.8 KiB
| text/x-python
|
PythonLexer
|
r10642 | """Base Tornado handlers for the notebook. | ||
|
r10641 | |||
Authors: | ||||
* Brian Granger | ||||
""" | ||||
#----------------------------------------------------------------------------- | ||||
|
r10642 | # Copyright (C) 2011 The IPython Development Team | ||
|
r10641 | # | ||
# Distributed under the terms of the BSD License. The full license is in | ||||
# the file COPYING, distributed as part of this software. | ||||
#----------------------------------------------------------------------------- | ||||
#----------------------------------------------------------------------------- | ||||
# Imports | ||||
#----------------------------------------------------------------------------- | ||||
|
r10650 | |||
|
r13047 | import functools | ||
import json | ||||
|
r10650 | import logging | ||
import os | ||||
|
r13999 | import re | ||
|
r13047 | import sys | ||
|
r13057 | import traceback | ||
|
r13939 | try: | ||
# py3 | ||||
from http.client import responses | ||||
except ImportError: | ||||
from httplib import responses | ||||
|
r10650 | |||
|
r13939 | from jinja2 import TemplateNotFound | ||
|
r10641 | from tornado import web | ||
try: | ||||
from tornado.log import app_log | ||||
except ImportError: | ||||
app_log = logging.getLogger() | ||||
from IPython.config import Application | ||||
|
r10650 | from IPython.utils.path import filefind | ||
|
r13353 | from IPython.utils.py3compat import string_types | ||
|
r15097 | from IPython.html.utils import is_hidden | ||
|
r13174 | |||
|
r10641 | #----------------------------------------------------------------------------- | ||
# Top-level handlers | ||||
#----------------------------------------------------------------------------- | ||||
|
r13999 | non_alphanum = re.compile(r'[^A-Za-z0-9]') | ||
|
r10641 | |||
|
r13939 | class AuthenticatedHandler(web.RequestHandler): | ||
|
r10641 | """A RequestHandler with an authenticated user.""" | ||
|
r15584 | def set_default_headers(self): | ||
headers = self.settings.get('headers', {}) | ||||
|
r17365 | |||
if "X-Frame-Options" not in headers: | ||||
headers["X-Frame-Options"] = "SAMEORIGIN" | ||||
|
r15584 | for header_name,value in headers.items() : | ||
try: | ||||
self.set_header(header_name, value) | ||||
except Exception: | ||||
# tornado raise Exception (not a subclass) | ||||
# if method is unsupported (websocket and Access-Control-Allow-Origin | ||||
# for example, so just ignore) | ||||
pass | ||||
|
r10641 | def clear_login_cookie(self): | ||
self.clear_cookie(self.cookie_name) | ||||
def get_current_user(self): | ||||
user_id = self.get_secure_cookie(self.cookie_name) | ||||
# For now the user_id should not return empty, but it could eventually | ||||
if user_id == '': | ||||
user_id = 'anonymous' | ||||
if user_id is None: | ||||
# prevent extra Invalid cookie sig warnings: | ||||
self.clear_login_cookie() | ||||
|
r11644 | if not self.login_available: | ||
|
r10641 | user_id = 'anonymous' | ||
return user_id | ||||
@property | ||||
def cookie_name(self): | ||||
|
r13999 | default_cookie_name = non_alphanum.sub('-', 'username-{}'.format( | ||
self.request.host | ||||
)) | ||||
|
r10783 | return self.settings.get('cookie_name', default_cookie_name) | ||
|
r10641 | |||
@property | ||||
def password(self): | ||||
"""our password""" | ||||
return self.settings.get('password', '') | ||||
@property | ||||
def logged_in(self): | ||||
"""Is a user currently logged in? | ||||
""" | ||||
user = self.get_current_user() | ||||
return (user and not user == 'anonymous') | ||||
@property | ||||
def login_available(self): | ||||
"""May a user proceed to log in? | ||||
This returns True if login capability is available, irrespective of | ||||
whether the user is already logged in or not. | ||||
""" | ||||
return bool(self.settings.get('password', '')) | ||||
class IPythonHandler(AuthenticatedHandler): | ||||
"""IPython-specific extensions to authenticated handling | ||||
Mostly property shortcuts to IPython-specific settings. | ||||
""" | ||||
@property | ||||
def config(self): | ||||
return self.settings.get('config', None) | ||||
@property | ||||
def log(self): | ||||
"""use the IPython log by default, falling back on tornado's logger""" | ||||
if Application.initialized(): | ||||
return Application.instance().log | ||||
else: | ||||
return app_log | ||||
#--------------------------------------------------------------- | ||||
# URLs | ||||
#--------------------------------------------------------------- | ||||
@property | ||||
def mathjax_url(self): | ||||
return self.settings.get('mathjax_url', '') | ||||
@property | ||||
|
r15238 | def base_url(self): | ||
return self.settings.get('base_url', '/') | ||||
|
r10641 | |||
#--------------------------------------------------------------- | ||||
# Manager objects | ||||
#--------------------------------------------------------------- | ||||
@property | ||||
def kernel_manager(self): | ||||
return self.settings['kernel_manager'] | ||||
@property | ||||
def notebook_manager(self): | ||||
return self.settings['notebook_manager'] | ||||
@property | ||||
def cluster_manager(self): | ||||
return self.settings['cluster_manager'] | ||||
@property | ||||
|
r12980 | def session_manager(self): | ||
return self.settings['session_manager'] | ||||
@property | ||||
|
r13055 | def project_dir(self): | ||
|
r10641 | return self.notebook_manager.notebook_dir | ||
#--------------------------------------------------------------- | ||||
|
r17153 | # CORS | ||
#--------------------------------------------------------------- | ||||
@property | ||||
def allow_origin(self): | ||||
"""Normal Access-Control-Allow-Origin""" | ||||
return self.settings.get('allow_origin', '') | ||||
@property | ||||
def allow_origin_pat(self): | ||||
"""Regular expression version of allow_origin""" | ||||
return self.settings.get('allow_origin_pat', None) | ||||
@property | ||||
def allow_credentials(self): | ||||
"""Whether to set Access-Control-Allow-Credentials""" | ||||
return self.settings.get('allow_credentials', False) | ||||
def set_default_headers(self): | ||||
"""Add CORS headers, if defined""" | ||||
super(IPythonHandler, self).set_default_headers() | ||||
if self.allow_origin: | ||||
self.set_header("Access-Control-Allow-Origin", self.allow_origin) | ||||
elif self.allow_origin_pat: | ||||
origin = self.get_origin() | ||||
if origin and self.allow_origin_pat.match(origin): | ||||
self.set_header("Access-Control-Allow-Origin", origin) | ||||
if self.allow_credentials: | ||||
self.set_header("Access-Control-Allow-Credentials", 'true') | ||||
def get_origin(self): | ||||
# Handle WebSocket Origin naming convention differences | ||||
# The difference between version 8 and 13 is that in 8 the | ||||
# client sends a "Sec-Websocket-Origin" header and in 13 it's | ||||
# simply "Origin". | ||||
if "Origin" in self.request.headers: | ||||
origin = self.request.headers.get("Origin") | ||||
else: | ||||
origin = self.request.headers.get("Sec-Websocket-Origin", None) | ||||
return origin | ||||
#--------------------------------------------------------------- | ||||
|
r10641 | # template rendering | ||
#--------------------------------------------------------------- | ||||
def get_template(self, name): | ||||
"""Return the jinja template object for a given name""" | ||||
return self.settings['jinja2_env'].get_template(name) | ||||
def render_template(self, name, **ns): | ||||
ns.update(self.template_namespace) | ||||
template = self.get_template(name) | ||||
return template.render(**ns) | ||||
@property | ||||
def template_namespace(self): | ||||
return dict( | ||||
|
r15238 | base_url=self.base_url, | ||
|
r10641 | logged_in=self.logged_in, | ||
login_available=self.login_available, | ||||
|
r13896 | static_url=self.static_url, | ||
|
r10641 | ) | ||
|
r13896 | |||
|
r13057 | def get_json_body(self): | ||
"""Return the body of the request as JSON data.""" | ||||
if not self.request.body: | ||||
return None | ||||
# Do we need to call body.decode('utf-8') here? | ||||
body = self.request.body.strip().decode(u'utf-8') | ||||
try: | ||||
model = json.loads(body) | ||||
|
r13071 | except Exception: | ||
self.log.debug("Bad JSON: %r", body) | ||||
self.log.error("Couldn't parse JSON", exc_info=True) | ||||
|
r13057 | raise web.HTTPError(400, u'Invalid JSON in body of request') | ||
return model | ||||
|
r13047 | |||
|
r13939 | def get_error_html(self, status_code, **kwargs): | ||
"""render custom error pages""" | ||||
exception = kwargs.get('exception') | ||||
message = '' | ||||
|
r14045 | status_message = responses.get(status_code, 'Unknown HTTP Error') | ||
|
r13939 | if exception: | ||
# get the custom message, if defined | ||||
try: | ||||
message = exception.log_message % exception.args | ||||
except Exception: | ||||
pass | ||||
# construct the custom reason, if defined | ||||
reason = getattr(exception, 'reason', '') | ||||
if reason: | ||||
status_message = reason | ||||
# build template namespace | ||||
ns = dict( | ||||
status_code=status_code, | ||||
status_message=status_message, | ||||
message=message, | ||||
exception=exception, | ||||
) | ||||
# render the template | ||||
try: | ||||
html = self.render_template('%s.html' % status_code, **ns) | ||||
except TemplateNotFound: | ||||
self.log.debug("No template for %d", status_code) | ||||
html = self.render_template('error.html', **ns) | ||||
return html | ||||
class Template404(IPythonHandler): | ||||
"""Render our 404 template""" | ||||
def prepare(self): | ||||
raise web.HTTPError(404) | ||||
|
r13174 | |||
|
r10641 | class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): | ||
"""static files should only be accessible when logged in""" | ||||
|
r11644 | @web.authenticated | ||
|
r10641 | def get(self, path): | ||
|
r13114 | if os.path.splitext(path)[1] == '.ipynb': | ||
name = os.path.basename(path) | ||||
self.set_header('Content-Type', 'application/json') | ||||
self.set_header('Content-Disposition','attachment; filename="%s"' % name) | ||||
|
r13174 | |||
|
r10641 | return web.StaticFileHandler.get(self, path) | ||
|
r13174 | |||
|
r13320 | def compute_etag(self): | ||
return None | ||||
|
r13174 | def validate_absolute_path(self, root, absolute_path): | ||
"""Validate and return the absolute path. | ||||
Requires tornado 3.1 | ||||
Adding to tornado's own handling, forbids the serving of hidden files. | ||||
""" | ||||
abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path) | ||||
abs_root = os.path.abspath(root) | ||||
|
r15108 | if is_hidden(abs_path, abs_root): | ||
|
r15610 | self.log.info("Refusing to serve hidden file, via 404 Error") | ||
|
r15102 | raise web.HTTPError(404) | ||
|
r15097 | return abs_path | ||
|
r10647 | |||
|
r13047 | def json_errors(method): | ||
"""Decorate methods with this to return GitHub style JSON errors. | ||||
|
r13065 | This should be used on any JSON API on any handler method that can raise HTTPErrors. | ||
|
r13047 | |||
This will grab the latest HTTPError exception using sys.exc_info | ||||
and then: | ||||
1. Set the HTTP status code based on the HTTPError | ||||
2. Create and return a JSON body with a message field describing | ||||
the error in a human readable form. | ||||
""" | ||||
@functools.wraps(method) | ||||
def wrapper(self, *args, **kwargs): | ||||
try: | ||||
result = method(self, *args, **kwargs) | ||||
|
r13065 | except web.HTTPError as e: | ||
status = e.status_code | ||||
message = e.log_message | ||||
|
r16628 | self.log.warn(message) | ||
|
r13065 | self.set_status(e.status_code) | ||
self.finish(json.dumps(dict(message=message))) | ||||
except Exception: | ||||
self.log.error("Unhandled error in API request", exc_info=True) | ||||
status = 500 | ||||
message = "Unknown server error" | ||||
|
r13047 | t, value, tb = sys.exc_info() | ||
self.set_status(status) | ||||
|
r13057 | tb_text = ''.join(traceback.format_exception(t, value, tb)) | ||
reply = dict(message=message, traceback=tb_text) | ||||
|
r13065 | self.finish(json.dumps(reply)) | ||
|
r13047 | else: | ||
return result | ||||
return wrapper | ||||
|
r10647 | #----------------------------------------------------------------------------- | ||
|
r10650 | # File handler | ||
#----------------------------------------------------------------------------- | ||||
# to minimize subclass changes: | ||||
HTTPError = web.HTTPError | ||||
class FileFindHandler(web.StaticFileHandler): | ||||
"""subclass of StaticFileHandler for serving files from a search path""" | ||||
|
r13313 | # cache search results, don't search for files more than once | ||
|
r10650 | _static_paths = {} | ||
def initialize(self, path, default_filename=None): | ||||
|
r13353 | if isinstance(path, string_types): | ||
|
r10650 | path = [path] | ||
|
r13313 | |||
self.root = tuple( | ||||
|
r13182 | os.path.abspath(os.path.expanduser(p)) + os.sep for p in path | ||
|
r10650 | ) | ||
self.default_filename = default_filename | ||||
|
r13320 | def compute_etag(self): | ||
return None | ||||
|
r10650 | @classmethod | ||
|
r13313 | def get_absolute_path(cls, roots, path): | ||
|
r10650 | """locate a file to serve on our static file search path""" | ||
with cls._lock: | ||||
if path in cls._static_paths: | ||||
return cls._static_paths[path] | ||||
try: | ||||
abspath = os.path.abspath(filefind(path, roots)) | ||||
except IOError: | ||||
|
r13318 | # IOError means not found | ||
|
r13897 | return '' | ||
|
r13313 | |||
|
r10650 | cls._static_paths[path] = abspath | ||
return abspath | ||||
|
r13313 | def validate_absolute_path(self, root, absolute_path): | ||
"""check if the file should be served (raises 404, 403, etc.)""" | ||||
|
r13897 | if absolute_path == '': | ||
raise web.HTTPError(404) | ||||
|
r13313 | for root in self.root: | ||
if (absolute_path + os.sep).startswith(root): | ||||
break | ||||
|
r13057 | |||
|
r13313 | return super(FileFindHandler, self).validate_absolute_path(root, absolute_path) | ||
|
r10650 | |||
|
r13077 | class TrailingSlashHandler(web.RequestHandler): | ||
"""Simple redirect handler that strips trailing slashes | ||||
This should be the first, highest priority handler. | ||||
""" | ||||
SUPPORTED_METHODS = ['GET'] | ||||
def get(self): | ||||
self.redirect(self.request.uri.rstrip('/')) | ||||
|
r10650 | #----------------------------------------------------------------------------- | ||
|
r13916 | # URL pattern fragments for re-use | ||
#----------------------------------------------------------------------------- | ||||
path_regex = r"(?P<path>(?:/.*)*)" | ||||
notebook_name_regex = r"(?P<name>[^/]+\.ipynb)" | ||||
notebook_path_regex = "%s/%s" % (path_regex, notebook_name_regex) | ||||
#----------------------------------------------------------------------------- | ||||
|
r10647 | # URL to handler mappings | ||
#----------------------------------------------------------------------------- | ||||
|
r13077 | default_handlers = [ | ||
(r".*/", TrailingSlashHandler) | ||||
] | ||||