handlers.py
507 lines
| 16.6 KiB
| text/x-python
|
PythonLexer
|
r17524 | """Base Tornado handlers for the notebook server.""" | ||
|
r10641 | |||
|
r17057 | # Copyright (c) IPython Development Team. | ||
# Distributed under the terms of the Modified BSD License. | ||||
|
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() | ||||
|
r18291 | import IPython | ||
|
r18359 | from IPython.utils.sysinfo import get_sys_info | ||
|
r18291 | |||
|
r10641 | from IPython.config import Application | ||
|
r10650 | from IPython.utils.path import filefind | ||
|
r13353 | from IPython.utils.py3compat import string_types | ||
|
r17533 | from IPython.html.utils import is_hidden, url_path_join, url_escape | ||
|
r13174 | |||
|
r19143 | from IPython.html.services.security import csp_report_uri | ||
|
r19141 | |||
|
r10641 | #----------------------------------------------------------------------------- | ||
# Top-level handlers | ||||
#----------------------------------------------------------------------------- | ||||
|
r13999 | non_alphanum = re.compile(r'[^A-Za-z0-9]') | ||
|
r10641 | |||
|
r18520 | sys_info = json.dumps(get_sys_info()) | ||
|
r13939 | class AuthenticatedHandler(web.RequestHandler): | ||
|
r10641 | """A RequestHandler with an authenticated user.""" | ||
|
r15584 | def set_default_headers(self): | ||
headers = self.settings.get('headers', {}) | ||||
|
r17235 | |||
|
r19137 | if "Content-Security-Policy" not in headers: | ||
|
r19151 | headers["Content-Security-Policy"] = ( | ||
"frame-ancestors 'self'; " | ||||
|
r19152 | # Make sure the report-uri is relative to the base_url | ||
"report-uri " + url_path_join(self.base_url, csp_report_uri) + ";" | ||||
|
r19151 | ) | ||
|
r19141 | |||
# Allow for overriding headers | ||||
|
r15584 | for header_name,value in headers.items() : | ||
try: | ||||
self.set_header(header_name, value) | ||||
|
r19147 | except Exception as e: | ||
|
r15584 | # tornado raise Exception (not a subclass) | ||
# if method is unsupported (websocket and Access-Control-Allow-Origin | ||||
# for example, so just ignore) | ||||
|
r19147 | self.log.debug(e) | ||
|
r15584 | |||
|
r10641 | def clear_login_cookie(self): | ||
self.clear_cookie(self.cookie_name) | ||||
def get_current_user(self): | ||||
|
r19325 | if self.login_handler is None: | ||
return 'anonymous' | ||||
return self.login_handler.get_user(self) | ||||
|
r10641 | |||
@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 logged_in(self): | ||||
|
r19323 | """Is a user currently logged in?""" | ||
|
r10641 | user = self.get_current_user() | ||
return (user and not user == 'anonymous') | ||||
@property | ||||
|
r19323 | def login_handler(self): | ||
|
r19333 | """Return the login handler for this application, if any.""" | ||
|
r19324 | return self.settings.get('login_handler_class', None) | ||
|
r19322 | |||
@property | ||||
|
r10641 | 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. | ||||
""" | ||||
|
r19324 | if self.login_handler is None: | ||
return False | ||||
|
r19323 | return bool(self.login_handler.login_available(self.settings)) | ||
|
r10641 | |||
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 | ||||
|
r19069 | def version_hash(self): | ||
"""The version hash to use for cache hints for static files""" | ||||
return self.settings.get('version_hash', '') | ||||
@property | ||||
|
r10641 | def mathjax_url(self): | ||
return self.settings.get('mathjax_url', '') | ||||
@property | ||||
|
r15238 | def base_url(self): | ||
return self.settings.get('base_url', '/') | ||||
|
r17303 | |||
@property | ||||
def ws_url(self): | ||||
return self.settings.get('websocket_url', '') | ||||
|
r18639 | |||
@property | ||||
|
r18643 | def contents_js_source(self): | ||
self.log.debug("Using contents: %s", self.settings.get('contents_js_source', | ||||
|
r18651 | 'services/contents')) | ||
|
r18659 | return self.settings.get('contents_js_source', 'services/contents') | ||
|
r10641 | |||
#--------------------------------------------------------------- | ||||
# Manager objects | ||||
#--------------------------------------------------------------- | ||||
@property | ||||
def kernel_manager(self): | ||||
return self.settings['kernel_manager'] | ||||
@property | ||||
|
r17524 | def contents_manager(self): | ||
return self.settings['contents_manager'] | ||||
|
r10641 | |||
@property | ||||
def cluster_manager(self): | ||||
return self.settings['cluster_manager'] | ||||
@property | ||||
|
r12980 | def session_manager(self): | ||
return self.settings['session_manager'] | ||||
@property | ||||
|
r18616 | def terminal_manager(self): | ||
return self.settings['terminal_manager'] | ||||
@property | ||||
|
r16684 | def kernel_spec_manager(self): | ||
return self.settings['kernel_spec_manager'] | ||||
|
r19083 | @property | ||
def config_manager(self): | ||||
return self.settings['config_manager'] | ||||
|
r10641 | #--------------------------------------------------------------- | ||
|
r17106 | # CORS | ||
#--------------------------------------------------------------- | ||||
@property | ||||
|
r17116 | def allow_origin(self): | ||
|
r17106 | """Normal Access-Control-Allow-Origin""" | ||
|
r17116 | return self.settings.get('allow_origin', '') | ||
|
r17106 | |||
@property | ||||
|
r17116 | def allow_origin_pat(self): | ||
"""Regular expression version of allow_origin""" | ||||
return self.settings.get('allow_origin_pat', None) | ||||
|
r17106 | |||
@property | ||||
|
r17116 | def allow_credentials(self): | ||
|
r17106 | """Whether to set Access-Control-Allow-Credentials""" | ||
|
r17116 | return self.settings.get('allow_credentials', False) | ||
|
r17106 | |||
def set_default_headers(self): | ||||
"""Add CORS headers, if defined""" | ||||
super(IPythonHandler, self).set_default_headers() | ||||
|
r17116 | if self.allow_origin: | ||
self.set_header("Access-Control-Allow-Origin", self.allow_origin) | ||||
elif self.allow_origin_pat: | ||||
|
r17106 | origin = self.get_origin() | ||
|
r17116 | if origin and self.allow_origin_pat.match(origin): | ||
|
r17106 | self.set_header("Access-Control-Allow-Origin", origin) | ||
|
r17116 | if self.allow_credentials: | ||
|
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 | ||||
#--------------------------------------------------------------- | ||||
|
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, | ||
|
r17303 | ws_url=self.ws_url, | ||
|
r10641 | logged_in=self.logged_in, | ||
login_available=self.login_available, | ||||
|
r13896 | static_url=self.static_url, | ||
|
r18639 | sys_info=sys_info, | ||
|
r18643 | contents_js_source=self.contents_js_source, | ||
|
r19069 | version_hash=self.version_hash, | ||
|
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 | |||
|
r17664 | def write_error(self, status_code, **kwargs): | ||
|
r13939 | """render custom error pages""" | ||
|
r17664 | exc_info = kwargs.get('exc_info') | ||
|
r13939 | message = '' | ||
|
r14045 | status_message = responses.get(status_code, 'Unknown HTTP Error') | ||
|
r17664 | if exc_info: | ||
exception = exc_info[1] | ||||
|
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, | ||||
) | ||||
|
r17664 | self.set_header('Content-Type', 'text/html') | ||
|
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) | ||||
|
r17664 | |||
self.write(html) | ||||
|
r13939 | |||
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': | ||
|
r18749 | name = path.rsplit('/', 1)[-1] | ||
|
r13114 | 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 | |||
|
r19068 | def set_headers(self): | ||
super(AuthenticatedFileHandler, self).set_headers() | ||||
|
r19070 | # disable browser caching, rely on 304 replies for savings | ||
|
r19068 | if "v" not in self.request.arguments: | ||
self.add_header("Cache-Control", "no-cache") | ||||
|
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 | ||||
|
r16444 | 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 = {} | ||
|
r19068 | def set_headers(self): | ||
super(FileFindHandler, self).set_headers() | ||||
|
r19070 | # disable browser caching, rely on 304 replies for savings | ||
if "v" not in self.request.arguments or \ | ||||
any(self.request.path.startswith(path) for path in self.no_cache_paths): | ||||
|
r19068 | self.add_header("Cache-Control", "no-cache") | ||
|
r19070 | def initialize(self, path, default_filename=None, no_cache_paths=None): | ||
self.no_cache_paths = no_cache_paths or [] | ||||
|
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 | |||
|
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__})) | ||||
|
r18749 | |||
|
r13077 | class TrailingSlashHandler(web.RequestHandler): | ||
"""Simple redirect handler that strips trailing slashes | ||||
This should be the first, highest priority handler. | ||||
""" | ||||
def get(self): | ||||
self.redirect(self.request.uri.rstrip('/')) | ||||
|
r18749 | |||
post = put = get | ||||
|
r13077 | |||
|
r17533 | |||
class FilesRedirectHandler(IPythonHandler): | ||||
"""Handler for redirecting relative URLs to the /files/ handler""" | ||||
def get(self, path=''): | ||||
cm = self.contents_manager | ||||
|
r18749 | if cm.dir_exists(path): | ||
|
r17533 | # 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('/') | ||||
|
r18749 | if not cm.file_exists(path=path) and 'files' in parts: | ||
|
r17533 | # 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') | ||||
|
r18749 | path = '/'.join(parts) | ||
|
r17533 | |||
|
r18749 | if not cm.file_exists(path=path): | ||
|
r17533 | raise web.HTTPError(404) | ||
|
r18749 | url = url_path_join(self.base_url, 'files', path) | ||
|
r17533 | url = url_escape(url) | ||
self.log.debug("Redirecting %s to %s", self.request.path, url) | ||||
self.redirect(url) | ||||
|
r10650 | #----------------------------------------------------------------------------- | ||
|
r13916 | # URL pattern fragments for re-use | ||
#----------------------------------------------------------------------------- | ||||
|
r18757 | # path matches any number of `/foo[/bar...]` or just `/` or '' | ||
path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))" | ||||
notebook_path_regex = r"(?P<path>(?:/[^/]+)+\.ipynb)" | ||||
|
r13916 | |||
#----------------------------------------------------------------------------- | ||||
|
r10647 | # URL to handler mappings | ||
#----------------------------------------------------------------------------- | ||||
|
r13077 | default_handlers = [ | ||
|
r18291 | (r".*/", TrailingSlashHandler), | ||
(r"api", ApiVersionHandler) | ||||
|
r13077 | ] | ||