From c56d0afef6643cd2d38a3129ed675a937cfadab5 2013-05-15 16:28:21 From: Brian E. Granger Date: 2013-05-15 16:28:21 Subject: [PATCH] Adding new files. --- diff --git a/IPython/frontend/html/notebook/handlers/base.py b/IPython/frontend/html/notebook/handlers/base.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/base.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + + diff --git a/IPython/frontend/html/notebook/handlers/clustersapi.py b/IPython/frontend/html/notebook/handlers/clustersapi.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/clustersapi.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + + diff --git a/IPython/frontend/html/notebook/handlers/kernelsapi.py b/IPython/frontend/html/notebook/handlers/kernelsapi.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/kernelsapi.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + + diff --git a/IPython/frontend/html/notebook/handlers/login.py b/IPython/frontend/html/notebook/handlers/login.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/login.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + + diff --git a/IPython/frontend/html/notebook/handlers/logout.py b/IPython/frontend/html/notebook/handlers/logout.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/logout.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + + diff --git a/IPython/frontend/html/notebook/handlers/notebooks.py b/IPython/frontend/html/notebook/handlers/notebooks.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/notebooks.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + + diff --git a/IPython/frontend/html/notebook/handlers/notebooksapi.py b/IPython/frontend/html/notebook/handlers/notebooksapi.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/notebooksapi.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + + diff --git a/IPython/frontend/html/notebook/handlers/tree.py b/IPython/frontend/html/notebook/handlers/tree.py new file mode 100644 index 0000000..32ec552 --- /dev/null +++ b/IPython/frontend/html/notebook/handlers/tree.py @@ -0,0 +1,931 @@ +"""Tornado handlers for the notebook. + +Authors: + +* Brian Granger +""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import Cookie +import datetime +import email.utils +import hashlib +import logging +import mimetypes +import os +import stat +import threading +import time +import uuid + +from tornado.escape import url_escape +from tornado import web +from tornado import websocket + +try: + from tornado.log import app_log +except ImportError: + app_log = logging.getLogger() + +from zmq.eventloop import ioloop +from zmq.utils import jsonapi + +from IPython.config import Application +from IPython.external.decorator import decorator +from IPython.kernel.zmq.session import Session +from IPython.lib.security import passwd_check +from IPython.utils.jsonutil import date_default +from IPython.utils.path import filefind +from IPython.utils.py3compat import PY3 + +try: + from docutils.core import publish_string +except ImportError: + publish_string = None + +#----------------------------------------------------------------------------- +# Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary! +#----------------------------------------------------------------------------- + +# Google Chrome, as of release 16, changed its websocket protocol number. The +# parts tornado cares about haven't really changed, so it's OK to continue +# accepting Chrome connections, but as of Tornado 2.1.1 (the currently released +# version as of Oct 30/2011) the version check fails, see the issue report: + +# https://github.com/facebook/tornado/issues/385 + +# This issue has been fixed in Tornado post 2.1.1: + +# https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710 + +# Here we manually apply the same patch as above so that users of IPython can +# continue to work with an officially released Tornado. We make the +# monkeypatch version check as narrow as possible to limit its effects; once +# Tornado 2.1.1 is no longer found in the wild we'll delete this code. + +import tornado + +if tornado.version_info <= (2,1,1): + + def _execute(self, transforms, *args, **kwargs): + from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76 + + self.open_args = args + self.open_kwargs = kwargs + + # 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 self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"): + self.ws_connection = WebSocketProtocol8(self) + self.ws_connection.accept_connection() + + elif self.request.headers.get("Sec-WebSocket-Version"): + self.stream.write(tornado.escape.utf8( + "HTTP/1.1 426 Upgrade Required\r\n" + "Sec-WebSocket-Version: 8\r\n\r\n")) + self.stream.close() + + else: + self.ws_connection = WebSocketProtocol76(self) + self.ws_connection.accept_connection() + + websocket.WebSocketHandler._execute = _execute + del _execute + +#----------------------------------------------------------------------------- +# Decorator for disabling read-only handlers +#----------------------------------------------------------------------------- + +@decorator +def not_if_readonly(f, self, *args, **kwargs): + if self.settings.get('read_only', False): + raise web.HTTPError(403, "Notebook server is read-only") + else: + return f(self, *args, **kwargs) + +@decorator +def authenticate_unless_readonly(f, self, *args, **kwargs): + """authenticate this page *unless* readonly view is active. + + In read-only mode, the notebook list and print view should + be accessible without authentication. + """ + + @web.authenticated + def auth_f(self, *args, **kwargs): + return f(self, *args, **kwargs) + + if self.settings.get('read_only', False): + return f(self, *args, **kwargs) + else: + return auth_f(self, *args, **kwargs) + +def urljoin(*pieces): + """Join components of url into a relative url + + Use to prevent double slash when joining subpath + """ + striped = [s.strip('/') for s in pieces] + return '/'.join(s for s in striped if s) + +#----------------------------------------------------------------------------- +# Top-level handlers +#----------------------------------------------------------------------------- + +class RequestHandler(web.RequestHandler): + """RequestHandler with default variable setting.""" + + def render(*args, **kwargs): + kwargs.setdefault('message', '') + return web.RequestHandler.render(*args, **kwargs) + +class AuthenticatedHandler(RequestHandler): + """A RequestHandler with an authenticated user.""" + + 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() + if not self.read_only and not self.login_available: + user_id = 'anonymous' + return user_id + + @property + def cookie_name(self): + return self.settings.get('cookie_name', '') + + @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', '')) + + @property + def read_only(self): + """Is the notebook read-only? + + """ + return self.settings.get('read_only', False) + + +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 + + @property + def use_less(self): + """Use less instead of css in templates""" + return self.settings.get('use_less', False) + + #--------------------------------------------------------------- + # URLs + #--------------------------------------------------------------- + + @property + def ws_url(self): + """websocket url matching the current request + + turns http[s]://host[:port] into + ws[s]://host[:port] + """ + proto = self.request.protocol.replace('http', 'ws') + host = self.settings.get('websocket_host', '') + # default to config value + if host == '': + host = self.request.host # get from request + return "%s://%s" % (proto, host) + + @property + def mathjax_url(self): + return self.settings.get('mathjax_url', '') + + @property + def base_project_url(self): + return self.settings.get('base_project_url', '/') + + @property + def base_kernel_url(self): + return self.settings.get('base_kernel_url', '/') + + #--------------------------------------------------------------- + # 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 + def project(self): + return self.notebook_manager.notebook_dir + + #--------------------------------------------------------------- + # 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( + base_project_url=self.base_project_url, + base_kernel_url=self.base_kernel_url, + read_only=self.read_only, + logged_in=self.logged_in, + login_available=self.login_available, + use_less=self.use_less, + ) + +class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler): + """static files should only be accessible when logged in""" + + @authenticate_unless_readonly + def get(self, path): + return web.StaticFileHandler.get(self, path) + + +class ProjectDashboardHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + self.write(self.render_template('projectdashboard.html', + project=self.project, + project_component=self.project.split('/'), + )) + + +class LoginHandler(IPythonHandler): + + def _render(self, message=None): + self.write(self.render_template('login.html', + next=url_escape(self.get_argument('next', default=self.base_project_url)), + message=message, + )) + + def get(self): + if self.current_user: + self.redirect(self.get_argument('next', default=self.base_project_url)) + else: + self._render() + + def post(self): + pwd = self.get_argument('password', default=u'') + if self.login_available: + if passwd_check(self.password, pwd): + self.set_secure_cookie(self.cookie_name, str(uuid.uuid4())) + else: + self._render(message={'error': 'Invalid password'}) + return + + self.redirect(self.get_argument('next', default=self.base_project_url)) + + +class LogoutHandler(IPythonHandler): + + def get(self): + self.clear_login_cookie() + if self.login_available: + message = {'info': 'Successfully logged out.'} + else: + message = {'warning': 'Cannot log out. Notebook authentication ' + 'is disabled.'} + self.write(self.render_template('logout.html', + message=message)) + + +class NewHandler(IPythonHandler): + + @web.authenticated + def get(self): + notebook_id = self.notebook_manager.new_notebook() + self.redirect('/' + urljoin(self.base_project_url, notebook_id)) + +class NamedNotebookHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + if not nbm.notebook_exists(notebook_id): + raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id) + self.write(self.render_template('notebook.html', + project=self.project, + notebook_id=notebook_id, + kill_kernel=False, + mathjax_url=self.mathjax_url, + ) + ) + + +#----------------------------------------------------------------------------- +# Kernel handlers +#----------------------------------------------------------------------------- + + +class MainKernelHandler(IPythonHandler): + + @web.authenticated + def get(self): + km = self.kernel_manager + self.finish(jsonapi.dumps(km.list_kernel_ids())) + + @web.authenticated + def post(self): + km = self.kernel_manager + nbm = self.notebook_manager + notebook_id = self.get_argument('notebook', default=None) + kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir) + data = {'ws_url':self.ws_url,'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.finish(jsonapi.dumps(data)) + + +class KernelHandler(IPythonHandler): + + SUPPORTED_METHODS = ('DELETE') + + @web.authenticated + def delete(self, kernel_id): + km = self.kernel_manager + km.shutdown_kernel(kernel_id) + self.set_status(204) + self.finish() + + +class KernelActionHandler(IPythonHandler): + + @web.authenticated + def post(self, kernel_id, action): + km = self.kernel_manager + if action == 'interrupt': + km.interrupt_kernel(kernel_id) + self.set_status(204) + if action == 'restart': + km.restart_kernel(kernel_id) + data = {'ws_url':self.ws_url, 'kernel_id':kernel_id} + self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id)) + self.write(jsonapi.dumps(data)) + self.finish() + + +class ZMQStreamHandler(websocket.WebSocketHandler): + + def clear_cookie(self, *args, **kwargs): + """meaningless for websockets""" + pass + + def _reserialize_reply(self, msg_list): + """Reserialize a reply message using JSON. + + This takes the msg list from the ZMQ socket, unserializes it using + self.session and then serializes the result using JSON. This method + should be used by self._on_zmq_reply to build messages that can + be sent back to the browser. + """ + idents, msg_list = self.session.feed_identities(msg_list) + msg = self.session.unserialize(msg_list) + try: + msg['header'].pop('date') + except KeyError: + pass + try: + msg['parent_header'].pop('date') + except KeyError: + pass + msg.pop('buffers') + return jsonapi.dumps(msg, default=date_default) + + def _on_zmq_reply(self, msg_list): + # Sometimes this gets triggered when the on_close method is scheduled in the + # eventloop but hasn't been called. + if self.stream.closed(): return + try: + msg = self._reserialize_reply(msg_list) + except Exception: + self.log.critical("Malformed message: %r" % msg_list, exc_info=True) + else: + self.write_message(msg) + + def allow_draft76(self): + """Allow draft 76, until browsers such as Safari update to RFC 6455. + + This has been disabled by default in tornado in release 2.2.0, and + support will be removed in later versions. + """ + return True + + +class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler): + + def open(self, kernel_id): + self.kernel_id = kernel_id.decode('ascii') + self.session = Session(config=self.config) + self.save_on_message = self.on_message + self.on_message = self.on_first_message + + def _inject_cookie_message(self, msg): + """Inject the first message, which is the document cookie, + for authentication.""" + if not PY3 and isinstance(msg, unicode): + # Cookie constructor doesn't accept unicode strings + # under Python 2.x for some reason + msg = msg.encode('utf8', 'replace') + try: + identity, msg = msg.split(':', 1) + self.session.session = identity.decode('ascii') + except Exception: + logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg) + + try: + self.request._cookies = Cookie.SimpleCookie(msg) + except: + self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True) + + def on_first_message(self, msg): + self._inject_cookie_message(msg) + if self.get_current_user() is None: + self.log.warn("Couldn't authenticate WebSocket connection") + raise web.HTTPError(403) + self.on_message = self.save_on_message + + +class ZMQChannelHandler(AuthenticatedZMQStreamHandler): + + @property + def max_msg_size(self): + return self.settings.get('max_msg_size', 65535) + + def create_stream(self): + km = self.kernel_manager + meth = getattr(km, 'connect_%s' % self.channel) + self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession) + + def initialize(self, *args, **kwargs): + self.zmq_stream = None + + def on_first_message(self, msg): + try: + super(ZMQChannelHandler, self).on_first_message(msg) + except web.HTTPError: + self.close() + return + try: + self.create_stream() + except web.HTTPError: + # WebSockets don't response to traditional error codes so we + # close the connection. + if not self.stream.closed(): + self.stream.close() + self.close() + else: + self.zmq_stream.on_recv(self._on_zmq_reply) + + def on_message(self, msg): + if len(msg) < self.max_msg_size: + msg = jsonapi.loads(msg) + self.session.send(self.zmq_stream, msg) + + def on_close(self): + # This method can be called twice, once by self.kernel_died and once + # from the WebSocket close event. If the WebSocket connection is + # closed before the ZMQ streams are setup, they could be None. + if self.zmq_stream is not None and not self.zmq_stream.closed(): + self.zmq_stream.on_recv(None) + self.zmq_stream.close() + + +class IOPubHandler(ZMQChannelHandler): + channel = 'iopub' + + def create_stream(self): + super(IOPubHandler, self).create_stream() + km = self.kernel_manager + km.add_restart_callback(self.kernel_id, self.on_kernel_restarted) + km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead') + + def on_close(self): + km = self.kernel_manager + if self.kernel_id in km: + km.remove_restart_callback( + self.kernel_id, self.on_kernel_restarted, + ) + km.remove_restart_callback( + self.kernel_id, self.on_restart_failed, 'dead', + ) + super(IOPubHandler, self).on_close() + + def _send_status_message(self, status): + msg = self.session.msg("status", + {'execution_state': status} + ) + self.write_message(jsonapi.dumps(msg, default=date_default)) + + def on_kernel_restarted(self): + logging.warn("kernel %s restarted", self.kernel_id) + self._send_status_message('restarting') + + def on_restart_failed(self): + logging.error("kernel %s restarted failed!", self.kernel_id) + self._send_status_message('dead') + + def on_message(self, msg): + """IOPub messages make no sense""" + pass + +class ShellHandler(ZMQChannelHandler): + channel = 'shell' + +class StdinHandler(ZMQChannelHandler): + channel = 'stdin' + + +#----------------------------------------------------------------------------- +# Notebook web service handlers +#----------------------------------------------------------------------------- + +class NotebookRedirectHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self, notebook_name): + # strip trailing .ipynb: + notebook_name = os.path.splitext(notebook_name)[0] + notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '') + if notebook_id: + url = self.settings.get('base_project_url', '/') + notebook_id + return self.redirect(url) + else: + raise HTTPError(404) + + +class NotebookRootHandler(IPythonHandler): + + @authenticate_unless_readonly + def get(self): + nbm = self.notebook_manager + km = self.kernel_manager + files = nbm.list_notebooks() + for f in files : + f['kernel_id'] = km.kernel_for_notebook(f['notebook_id']) + self.finish(jsonapi.dumps(files)) + + @web.authenticated + def post(self): + nbm = self.notebook_manager + body = self.request.body.strip() + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + if body: + notebook_id = nbm.save_new_notebook(body, name=name, format=format) + else: + notebook_id = nbm.new_notebook() + self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id)) + self.finish(jsonapi.dumps(notebook_id)) + + +class NotebookHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE') + + @authenticate_unless_readonly + def get(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + last_mod, name, data = nbm.get_notebook(notebook_id, format) + + if format == u'json': + self.set_header('Content-Type', 'application/json') + self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name) + elif format == u'py': + self.set_header('Content-Type', 'application/x-python') + self.set_header('Content-Disposition','attachment; filename="%s.py"' % name) + self.set_header('Last-Modified', last_mod) + self.finish(data) + + @web.authenticated + def put(self, notebook_id): + nbm = self.notebook_manager + format = self.get_argument('format', default='json') + name = self.get_argument('name', default=None) + nbm.save_notebook(notebook_id, self.request.body, name=name, format=format) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id): + self.notebook_manager.delete_notebook(notebook_id) + self.set_status(204) + self.finish() + + +class NotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('GET', 'POST') + + @web.authenticated + def get(self, notebook_id): + """get lists checkpoints for a notebook""" + nbm = self.notebook_manager + checkpoints = nbm.list_checkpoints(notebook_id) + data = jsonapi.dumps(checkpoints, default=date_default) + self.finish(data) + + @web.authenticated + def post(self, notebook_id): + """post creates a new checkpoint""" + nbm = self.notebook_manager + checkpoint = nbm.create_checkpoint(notebook_id) + data = jsonapi.dumps(checkpoint, default=date_default) + self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format( + self.base_project_url, notebook_id, checkpoint['checkpoint_id'] + )) + + self.finish(data) + + +class ModifyNotebookCheckpointsHandler(IPythonHandler): + + SUPPORTED_METHODS = ('POST', 'DELETE') + + @web.authenticated + def post(self, notebook_id, checkpoint_id): + """post restores a notebook from a checkpoint""" + nbm = self.notebook_manager + nbm.restore_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + @web.authenticated + def delete(self, notebook_id, checkpoint_id): + """delete clears a checkpoint for a given notebook""" + nbm = self.notebook_manager + nbm.delte_checkpoint(notebook_id, checkpoint_id) + self.set_status(204) + self.finish() + + +class NotebookCopyHandler(IPythonHandler): + + @web.authenticated + def get(self, notebook_id): + notebook_id = self.notebook_manager.copy_notebook(notebook_id) + self.redirect('/'+urljoin(self.base_project_url, notebook_id)) + + +#----------------------------------------------------------------------------- +# Cluster handlers +#----------------------------------------------------------------------------- + + +class MainClusterHandler(IPythonHandler): + + @web.authenticated + def get(self): + self.finish(jsonapi.dumps(self.cluster_manager.list_profiles())) + + +class ClusterProfileHandler(IPythonHandler): + + @web.authenticated + def get(self, profile): + self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile))) + + +class ClusterActionHandler(IPythonHandler): + + @web.authenticated + def post(self, profile, action): + cm = self.cluster_manager + if action == 'start': + n = self.get_argument('n',default=None) + if n is None: + data = cm.start_cluster(profile) + else: + data = cm.start_cluster(profile, int(n)) + if action == 'stop': + data = cm.stop_cluster(profile) + self.finish(jsonapi.dumps(data)) + + +#----------------------------------------------------------------------------- +# File handler +#----------------------------------------------------------------------------- + +# to minimize subclass changes: +HTTPError = web.HTTPError + +class FileFindHandler(web.StaticFileHandler): + """subclass of StaticFileHandler for serving files from a search path""" + + _static_paths = {} + # _lock is needed for tornado < 2.2.0 compat + _lock = threading.Lock() # protects _static_hashes + + def initialize(self, path, default_filename=None): + if isinstance(path, basestring): + path = [path] + self.roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path + ) + self.default_filename = default_filename + + @classmethod + def locate_file(cls, path, roots): + """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: + # empty string should always give exists=False + return '' + + # os.path.abspath strips a trailing / + # it needs to be temporarily added back for requests to root/ + if not (abspath + os.path.sep).startswith(roots): + raise HTTPError(403, "%s is not in root static directory", path) + + cls._static_paths[path] = abspath + return abspath + + def get(self, path, include_body=True): + path = self.parse_url_path(path) + + # begin subclass override + abspath = self.locate_file(path, self.roots) + # end subclass override + + if os.path.isdir(abspath) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): + self.redirect(self.request.path + "/") + return + abspath = os.path.join(abspath, self.default_filename) + if not os.path.exists(abspath): + raise HTTPError(404) + if not os.path.isfile(abspath): + raise HTTPError(403, "%s is not a file", path) + + stat_result = os.stat(abspath) + modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME]) + + self.set_header("Last-Modified", modified) + + mime_type, encoding = mimetypes.guess_type(abspath) + if mime_type: + self.set_header("Content-Type", mime_type) + + cache_time = self.get_cache_time(path, modified, mime_type) + + if cache_time > 0: + self.set_header("Expires", datetime.datetime.utcnow() + \ + datetime.timedelta(seconds=cache_time)) + self.set_header("Cache-Control", "max-age=" + str(cache_time)) + else: + self.set_header("Cache-Control", "public") + + self.set_extra_headers(path) + + # Check the If-Modified-Since, and don't send the result if the + # content has not been modified + ims_value = self.request.headers.get("If-Modified-Since") + if ims_value is not None: + date_tuple = email.utils.parsedate(ims_value) + if_since = datetime.datetime(*date_tuple[:6]) + if if_since >= modified: + self.set_status(304) + return + + with open(abspath, "rb") as file: + data = file.read() + hasher = hashlib.sha1() + hasher.update(data) + self.set_header("Etag", '"%s"' % hasher.hexdigest()) + if include_body: + self.write(data) + else: + assert self.request.method == "HEAD" + self.set_header("Content-Length", len(data)) + + @classmethod + def get_version(cls, settings, path): + """Generate the version string to be used in static URLs. + + This method may be overridden in subclasses (but note that it + is a class method rather than a static method). The default + implementation uses a hash of the file's contents. + + ``settings`` is the `Application.settings` dictionary and ``path`` + is the relative location of the requested asset on the filesystem. + The returned value should be a string, or ``None`` if no version + could be determined. + """ + # begin subclass override: + static_paths = settings['static_path'] + if isinstance(static_paths, basestring): + static_paths = [static_paths] + roots = tuple( + os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths + ) + + try: + abs_path = filefind(path, roots) + except IOError: + app_log.error("Could not find static file %r", path) + return None + + # end subclass override + + with cls._lock: + hashes = cls._static_hashes + if abs_path not in hashes: + try: + f = open(abs_path, "rb") + hashes[abs_path] = hashlib.md5(f.read()).hexdigest() + f.close() + except Exception: + app_log.error("Could not open static file %r", path) + hashes[abs_path] = None + hsh = hashes.get(abs_path) + if hsh: + return hsh[:5] + return None + + + def parse_url_path(self, url_path): + """Converts a static URL path into a filesystem path. + + ``url_path`` is the path component of the URL with + ``static_url_prefix`` removed. The return value should be + filesystem path relative to ``static_path``. + """ + if os.path.sep != "/": + url_path = url_path.replace("/", os.path.sep) + return url_path + +