handlers.py
481 lines
| 15.5 KiB
| text/x-python
|
PythonLexer
MinRK
|
r17524 | """Base Tornado handlers for the notebook server.""" | ||
Brian E. Granger
|
r10641 | |||
MinRK
|
r17057 | # Copyright (c) IPython Development Team. | ||
# Distributed under the terms of the Modified BSD License. | ||||
Brian E. Granger
|
r10650 | |||
Brian E. Granger
|
r13047 | import functools | ||
import json | ||||
Brian E. Granger
|
r10650 | import logging | ||
import os | ||||
MinRK
|
r13999 | import re | ||
Brian E. Granger
|
r13047 | import sys | ||
Zachary Sailer
|
r13057 | import traceback | ||
MinRK
|
r13939 | try: | ||
# py3 | ||||
from http.client import responses | ||||
except ImportError: | ||||
from httplib import responses | ||||
Brian E. Granger
|
r10650 | |||
MinRK
|
r13939 | from jinja2 import TemplateNotFound | ||
Brian E. Granger
|
r10641 | from tornado import web | ||
try: | ||||
from tornado.log import app_log | ||||
except ImportError: | ||||
app_log = logging.getLogger() | ||||
Bussonnier Matthias
|
r18291 | import IPython | ||
Matthias Bussonnier
|
r18359 | from IPython.utils.sysinfo import get_sys_info | ||
Bussonnier Matthias
|
r18291 | |||
Brian E. Granger
|
r10641 | from IPython.config import Application | ||
Brian E. Granger
|
r10650 | from IPython.utils.path import filefind | ||
Thomas Kluyver
|
r13353 | from IPython.utils.py3compat import string_types | ||
MinRK
|
r17533 | from IPython.html.utils import is_hidden, url_path_join, url_escape | ||
MinRK
|
r13174 | |||
Brian E. Granger
|
r10641 | #----------------------------------------------------------------------------- | ||
# Top-level handlers | ||||
#----------------------------------------------------------------------------- | ||||
MinRK
|
r13999 | non_alphanum = re.compile(r'[^A-Za-z0-9]') | ||
Brian E. Granger
|
r10641 | |||
Bussonnier Matthias
|
r18520 | sys_info = json.dumps(get_sys_info()) | ||
MinRK
|
r13939 | class AuthenticatedHandler(web.RequestHandler): | ||
Brian E. Granger
|
r10641 | """A RequestHandler with an authenticated user.""" | ||
Matthias BUSSONNIER
|
r15584 | def set_default_headers(self): | ||
headers = self.settings.get('headers', {}) | ||||
rgbkrk
|
r17235 | |||
if "X-Frame-Options" not in headers: | ||||
headers["X-Frame-Options"] = "SAMEORIGIN" | ||||
Matthias BUSSONNIER
|
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 | ||||
Brian E. Granger
|
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() | ||||
MinRK
|
r11644 | if not self.login_available: | ||
Brian E. Granger
|
r10641 | user_id = 'anonymous' | ||
return user_id | ||||
@property | ||||
def cookie_name(self): | ||||
MinRK
|
r13999 | default_cookie_name = non_alphanum.sub('-', 'username-{}'.format( | ||
self.request.host | ||||
)) | ||||
MinRK
|
r10783 | return self.settings.get('cookie_name', default_cookie_name) | ||
Brian E. Granger
|
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 | ||||
MinRK
|
r15238 | def base_url(self): | ||
return self.settings.get('base_url', '/') | ||||
MinRK
|
r17303 | |||
@property | ||||
def ws_url(self): | ||||
return self.settings.get('websocket_url', '') | ||||
KesterTong
|
r18639 | |||
@property | ||||
Jeff Hemmelgarn
|
r18643 | def contents_js_source(self): | ||
self.log.debug("Using contents: %s", self.settings.get('contents_js_source', | ||||
Thomas Kluyver
|
r18651 | 'services/contents')) | ||
Thomas Kluyver
|
r18659 | return self.settings.get('contents_js_source', 'services/contents') | ||
Brian E. Granger
|
r10641 | |||
#--------------------------------------------------------------- | ||||
# Manager objects | ||||
#--------------------------------------------------------------- | ||||
@property | ||||
def kernel_manager(self): | ||||
return self.settings['kernel_manager'] | ||||
@property | ||||
MinRK
|
r17524 | def contents_manager(self): | ||
return self.settings['contents_manager'] | ||||
Brian E. Granger
|
r10641 | |||
@property | ||||
def cluster_manager(self): | ||||
return self.settings['cluster_manager'] | ||||
@property | ||||
Zachary Sailer
|
r12980 | def session_manager(self): | ||
return self.settings['session_manager'] | ||||
@property | ||||
Thomas Kluyver
|
r16684 | def kernel_spec_manager(self): | ||
return self.settings['kernel_spec_manager'] | ||||
Brian E. Granger
|
r10641 | #--------------------------------------------------------------- | ||
MinRK
|
r17106 | # CORS | ||
#--------------------------------------------------------------- | ||||
@property | ||||
MinRK
|
r17116 | def allow_origin(self): | ||
MinRK
|
r17106 | """Normal Access-Control-Allow-Origin""" | ||
MinRK
|
r17116 | return self.settings.get('allow_origin', '') | ||
MinRK
|
r17106 | |||
@property | ||||
MinRK
|
r17116 | def allow_origin_pat(self): | ||
"""Regular expression version of allow_origin""" | ||||
return self.settings.get('allow_origin_pat', None) | ||||
MinRK
|
r17106 | |||
@property | ||||
MinRK
|
r17116 | def allow_credentials(self): | ||
MinRK
|
r17106 | """Whether to set Access-Control-Allow-Credentials""" | ||
MinRK
|
r17116 | return self.settings.get('allow_credentials', False) | ||
MinRK
|
r17106 | |||
def set_default_headers(self): | ||||
"""Add CORS headers, if defined""" | ||||
super(IPythonHandler, self).set_default_headers() | ||||
MinRK
|
r17116 | if self.allow_origin: | ||
self.set_header("Access-Control-Allow-Origin", self.allow_origin) | ||||
elif self.allow_origin_pat: | ||||
MinRK
|
r17106 | origin = self.get_origin() | ||
MinRK
|
r17116 | if origin and self.allow_origin_pat.match(origin): | ||
MinRK
|
r17106 | self.set_header("Access-Control-Allow-Origin", origin) | ||
MinRK
|
r17116 | if self.allow_credentials: | ||
MinRK
|
r17106 | 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 | ||||
#--------------------------------------------------------------- | ||||
Brian E. Granger
|
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( | ||||
MinRK
|
r15238 | base_url=self.base_url, | ||
MinRK
|
r17303 | ws_url=self.ws_url, | ||
Brian E. Granger
|
r10641 | logged_in=self.logged_in, | ||
login_available=self.login_available, | ||||
MinRK
|
r13896 | static_url=self.static_url, | ||
KesterTong
|
r18639 | sys_info=sys_info, | ||
Jeff Hemmelgarn
|
r18643 | contents_js_source=self.contents_js_source, | ||
Brian E. Granger
|
r10641 | ) | ||
MinRK
|
r13896 | |||
Zachary Sailer
|
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) | ||||
MinRK
|
r13071 | except Exception: | ||
self.log.debug("Bad JSON: %r", body) | ||||
self.log.error("Couldn't parse JSON", exc_info=True) | ||||
Zachary Sailer
|
r13057 | raise web.HTTPError(400, u'Invalid JSON in body of request') | ||
return model | ||||
Brian E. Granger
|
r13047 | |||
MinRK
|
r17664 | def write_error(self, status_code, **kwargs): | ||
MinRK
|
r13939 | """render custom error pages""" | ||
MinRK
|
r17664 | exc_info = kwargs.get('exc_info') | ||
MinRK
|
r13939 | message = '' | ||
MinRK
|
r14045 | status_message = responses.get(status_code, 'Unknown HTTP Error') | ||
MinRK
|
r17664 | if exc_info: | ||
exception = exc_info[1] | ||||
MinRK
|
r13939 | # 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, | ||||
) | ||||
MinRK
|
r17664 | self.set_header('Content-Type', 'text/html') | ||
MinRK
|
r13939 | # 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) | ||||
MinRK
|
r17664 | |||
self.write(html) | ||||
MinRK
|
r13939 | |||
class Template404(IPythonHandler): | ||||
"""Render our 404 template""" | ||||
def prepare(self): | ||||
raise web.HTTPError(404) | ||||
MinRK
|
r13174 | |||
Brian E. Granger
|
r10641 | class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): | ||
"""static files should only be accessible when logged in""" | ||||
MinRK
|
r11644 | @web.authenticated | ||
Brian E. Granger
|
r10641 | def get(self, path): | ||
Brian E. Granger
|
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) | ||||
MinRK
|
r13174 | |||
Brian E. Granger
|
r10641 | return web.StaticFileHandler.get(self, path) | ||
MinRK
|
r13174 | |||
MinRK
|
r13320 | def compute_etag(self): | ||
return None | ||||
MinRK
|
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) | ||||
Brian E. Granger
|
r15108 | if is_hidden(abs_path, abs_root): | ||
Paul Ivanov
|
r15610 | self.log.info("Refusing to serve hidden file, via 404 Error") | ||
Brian E. Granger
|
r15102 | raise web.HTTPError(404) | ||
Brian E. Granger
|
r15097 | return abs_path | ||
Brian E. Granger
|
r10647 | |||
Brian E. Granger
|
r13047 | def json_errors(method): | ||
"""Decorate methods with this to return GitHub style JSON errors. | ||||
MinRK
|
r13065 | This should be used on any JSON API on any handler method that can raise HTTPErrors. | ||
Brian E. Granger
|
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) | ||||
MinRK
|
r13065 | except web.HTTPError as e: | ||
status = e.status_code | ||||
message = e.log_message | ||||
MinRK
|
r16444 | self.log.warn(message) | ||
MinRK
|
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" | ||||
Brian E. Granger
|
r13047 | t, value, tb = sys.exc_info() | ||
self.set_status(status) | ||||
Zachary Sailer
|
r13057 | tb_text = ''.join(traceback.format_exception(t, value, tb)) | ||
reply = dict(message=message, traceback=tb_text) | ||||
MinRK
|
r13065 | self.finish(json.dumps(reply)) | ||
Brian E. Granger
|
r13047 | else: | ||
return result | ||||
return wrapper | ||||
Brian E. Granger
|
r10647 | #----------------------------------------------------------------------------- | ||
Brian E. Granger
|
r10650 | # File handler | ||
#----------------------------------------------------------------------------- | ||||
# to minimize subclass changes: | ||||
HTTPError = web.HTTPError | ||||
class FileFindHandler(web.StaticFileHandler): | ||||
"""subclass of StaticFileHandler for serving files from a search path""" | ||||
MinRK
|
r13313 | # cache search results, don't search for files more than once | ||
Brian E. Granger
|
r10650 | _static_paths = {} | ||
def initialize(self, path, default_filename=None): | ||||
Thomas Kluyver
|
r13353 | if isinstance(path, string_types): | ||
Brian E. Granger
|
r10650 | path = [path] | ||
MinRK
|
r13313 | |||
self.root = tuple( | ||||
MinRK
|
r13182 | os.path.abspath(os.path.expanduser(p)) + os.sep for p in path | ||
Brian E. Granger
|
r10650 | ) | ||
self.default_filename = default_filename | ||||
MinRK
|
r13320 | def compute_etag(self): | ||
return None | ||||
Brian E. Granger
|
r10650 | @classmethod | ||
MinRK
|
r13313 | def get_absolute_path(cls, roots, path): | ||
Brian E. Granger
|
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: | ||||
MinRK
|
r13318 | # IOError means not found | ||
MinRK
|
r13897 | return '' | ||
MinRK
|
r13313 | |||
Brian E. Granger
|
r10650 | cls._static_paths[path] = abspath | ||
return abspath | ||||
MinRK
|
r13313 | def validate_absolute_path(self, root, absolute_path): | ||
"""check if the file should be served (raises 404, 403, etc.)""" | ||||
MinRK
|
r13897 | if absolute_path == '': | ||
raise web.HTTPError(404) | ||||
MinRK
|
r13313 | for root in self.root: | ||
if (absolute_path + os.sep).startswith(root): | ||||
break | ||||
Zachary Sailer
|
r13057 | |||
MinRK
|
r13313 | return super(FileFindHandler, self).validate_absolute_path(root, absolute_path) | ||
Brian E. Granger
|
r10650 | |||
Bussonnier Matthias
|
r18291 | class ApiVersionHandler(IPythonHandler): | ||
@json_errors | ||||
def get(self): | ||||
# not authenticated, so give as few info as possible | ||||
self.finish(json.dumps({"version":IPython.__version__})) | ||||
MinRK
|
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('/')) | ||||
MinRK
|
r17533 | |||
class FilesRedirectHandler(IPythonHandler): | ||||
"""Handler for redirecting relative URLs to the /files/ handler""" | ||||
def get(self, path=''): | ||||
cm = self.contents_manager | ||||
if cm.path_exists(path): | ||||
# it's a *directory*, redirect to /tree | ||||
url = url_path_join(self.base_url, 'tree', path) | ||||
else: | ||||
orig_path = path | ||||
# otherwise, redirect to /files | ||||
parts = path.split('/') | ||||
path = '/'.join(parts[:-1]) | ||||
name = parts[-1] | ||||
if not cm.file_exists(name=name, path=path) and 'files' in parts: | ||||
# redirect without files/ iff it would 404 | ||||
# this preserves pre-2.0-style 'files/' links | ||||
self.log.warn("Deprecated files/ URL: %s", orig_path) | ||||
parts.remove('files') | ||||
path = '/'.join(parts[:-1]) | ||||
if not cm.file_exists(name=name, path=path): | ||||
raise web.HTTPError(404) | ||||
url = url_path_join(self.base_url, 'files', path, name) | ||||
url = url_escape(url) | ||||
self.log.debug("Redirecting %s to %s", self.request.path, url) | ||||
self.redirect(url) | ||||
Brian E. Granger
|
r10650 | #----------------------------------------------------------------------------- | ||
Thomas Kluyver
|
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) | ||||
MinRK
|
r17525 | file_name_regex = r"(?P<name>[^/]+)" | ||
file_path_regex = "%s/%s" % (path_regex, file_name_regex) | ||||
Thomas Kluyver
|
r13916 | |||
#----------------------------------------------------------------------------- | ||||
Brian E. Granger
|
r10647 | # URL to handler mappings | ||
#----------------------------------------------------------------------------- | ||||
MinRK
|
r13077 | default_handlers = [ | ||
Bussonnier Matthias
|
r18291 | (r".*/", TrailingSlashHandler), | ||
(r"api", ApiVersionHandler) | ||||
MinRK
|
r13077 | ] | ||