diff --git a/.gitignore b/.gitignore index 70208bb9..77afef7a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ node_modules build dist share/jupyter/static/components +share/jupyter/static/css/style.min.css +share/jupyter/static/css/style.min.css.map *.egg-info MANIFEST diff --git a/README.md b/README.md index 73899606..1218e9ef 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,11 @@ Basic principles: # install the Python pargs (-e for editable/development install) pip install [-e] . - + +You will also need `bower` to fetch frontend-javascript and `less` to compile CSS: + + npm install -g bower less + Note on debian/ubuntu machines, you may need to install the `nodejs-legacy` package to get node executables to work: diff --git a/jupyterhub/app.py b/jupyterhub/app.py index d6200166..75548a97 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -9,10 +9,12 @@ import logging import os from subprocess import Popen +from jinja2 import Environment, FileSystemLoader + import tornado.httpserver import tornado.options from tornado.ioloop import IOLoop -from tornado.log import LogFormatter +from tornado.log import LogFormatter, app_log from tornado import gen, web from IPython.utils.traitlets import ( @@ -25,6 +27,7 @@ from IPython.utils.importstring import import_item here = os.path.dirname(__file__) from .handlers import ( + Template404, RootHandler, LoginHandler, LogoutHandler, @@ -36,6 +39,13 @@ from . import orm from ._data import DATA_FILES_PATH from .utils import url_path_join + +class RedirectHandler(web.RedirectHandler): + def get(self, *a, **kw): + self.set_header("mu-redirect", True) + app_log.warn("mu redirect: %s -> %s", self.request.path, self._url) + return super(RedirectHandler, self).get(*a, **kw) + class JupyterHubApp(Application): """An Application for starting a Multi-User Notebook server.""" data_files_path = Unicode(DATA_FILES_PATH, config=True, @@ -64,6 +74,10 @@ class JupyterHubApp(Application): help="The base URL of the entire application" ) + jinja_environment_options = Dict(config=True, + help="Supply extra arguments that will be passed to Jinja environment." + ) + proxy_cmd = Unicode('configurable-http-proxy', config=True, help="""The command to start the http proxy. @@ -157,7 +171,7 @@ class JupyterHubApp(Application): def _log_format_default(self): """override default log format to include time""" - return u"%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s" + return u"H %(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s" def init_logging(self): # This prevents double log messages because tornado use a root logger that @@ -191,8 +205,11 @@ class JupyterHubApp(Application): self.handlers = self.add_url_prefix(self.hub_prefix, handlers) self.handlers.extend([ (r"/user/([^/]+)/?.*", UserHandler), - (r"/", web.RedirectHandler, {"url" : self.hub_prefix}), + (r"/?", RedirectHandler, {"url" : self.hub_prefix}), ]) + self.handlers.append( + (r'(.*)', Template404) + ) def init_db(self): # TODO: load state from db for resume @@ -240,20 +257,28 @@ class JupyterHubApp(Application): '--api-ip', self.proxy.api_server.ip, '--api-port', str(self.proxy.api_server.port), '--default-target', self.hub.server.host, + '--log-level=debug', ] if self.ssl_key: cmd.extend(['--ssl-key', self.ssl_key]) if self.ssl_cert: cmd.extend(['--ssl-cert', self.ssl_cert]) + self.proxy = Popen(cmd, env=env) def init_tornado_settings(self): """Set up the tornado settings dict.""" base_url = self.base_url + template_path = os.path.join(self.data_files_path, 'templates'), + jinja_env = Environment( + loader=FileSystemLoader(template_path), + **self.jinja_environment_options + ) + settings = dict( config=self.config, - # log=self.log, + log=self.log, db=self.db, hub=self.hub, authenticator=import_item(self.authenticator)(config=self.config), @@ -261,8 +286,10 @@ class JupyterHubApp(Application): base_url=base_url, cookie_secret=self.cookie_secret, login_url=url_path_join(self.hub.server.base_url, 'login'), - template_path=os.path.join(self.data_files_path, 'templates'), - static_files_path=os.path.join(self.data_files_path, 'static'), + static_path=os.path.join(self.data_files_path, 'static'), + static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'), + template_path=template_path, + jinja2_env=jinja_env, ) # allow configured settings to have priority settings.update(self.tornado_settings) diff --git a/jupyterhub/handlers.py b/jupyterhub/handlers.py index 743f2d39..3fc53bb1 100644 --- a/jupyterhub/handlers.py +++ b/jupyterhub/handlers.py @@ -5,9 +5,16 @@ import json import re +try: + # py3 + from http.client import responses +except ImportError: + from httplib import responses import requests +from jinja2 import TemplateNotFound + from tornado.log import app_log from tornado.escape import url_escape from tornado.httputil import url_concat @@ -54,26 +61,97 @@ class BaseHandler(RequestHandler): return cookie_token.user.name else: # have cookie, but it's not valid. Clear it and start over. - self.clear_cookie(self.hub.server.cookie_name) + self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url) def clear_login_cookie(self): username = self.get_current_user() if username is not None: user = self.db.query(orm.User).filter(name=username).first() - if user is not None: + if user is not None and user.server is not None: self.clear_cookie(user.server.cookie_name, path=user.server.base_url) - self.clear_cookie(self.cookie_name, path=self.hub.base_url) + self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url) @property def spawner_class(self): return self.settings.get('spawner_class', LocalProcessSpawner) + + #--------------------------------------------------------------- + # 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 logged_in(self): + """Is a user currently logged in?""" + user = self.get_current_user() + return (user and not user == 'anonymous') + + @property + def template_namespace(self): + return dict( + base_url=self.base_url, + logged_in=self.logged_in, + login_url=self.settings['login_url'], + static_url=self.static_url, + ) + + def write_error(self, status_code, **kwargs): + """render custom error pages""" + exc_info = kwargs.get('exc_info') + message = '' + status_message = responses.get(status_code, 'Unknown HTTP Error') + if exc_info: + exception = exc_info[1] + # get the custom message, if defined + try: + message = exception.log_message % exception.args + except Exception: + pass + + # construct the custom reason, if defined + reason = getattr(exception, 'reason', '') + if reason: + status_message = reason + + # build template namespace + ns = dict( + status_code=status_code, + status_message=status_message, + message=message, + exception=exception, + ) + + self.set_header('Content-Type', 'text/html') + # render the template + try: + html = self.render_template('%s.html' % status_code, **ns) + except TemplateNotFound: + self.log.debug("No template for %d", status_code) + html = self.render_template('error.html', **ns) + + self.write(html) + + +class Template404(BaseHandler): + """Render our 404 template""" + def prepare(self): + raise web.HTTPError(404) class RootHandler(BaseHandler): """Redirect from / to /user/foo/ after logging in.""" @web.authenticated def get(self): - self.redirect("/user/%s/" % self.get_current_user()) + uri = "/user/%s/" % self.get_current_user() + self.redirect(uri, permanent=False) class UserHandler(BaseHandler): @@ -98,24 +176,25 @@ class LogoutHandler(BaseHandler): """Log a user out by clearing their login cookie.""" def get(self): self.clear_login_cookie() - self.write("logged out") + html = self.render_template('logout.html') + self.finish(html) class LoginHandler(BaseHandler): """Render the login page.""" def _render(self, message=None, username=None): - self.render('login.html', + return self.render_template('login.html', next=url_escape(self.get_argument('next', default='')), username=username, message=message, ) def get(self): - if False and self.get_current_user(): - self.redirect(self.get_argument('next', default='/')) + if self.get_argument('next', False) and self.get_current_user(): + self.redirect(self.get_argument('next')) else: username = self.get_argument('username', default='') - self._render(username=username) + self.finish(self._render(username=username)) @gen.coroutine def notify_proxy(self, user): @@ -211,13 +290,15 @@ class LoginHandler(BaseHandler): user = yield self.spawn_single_user(username) self.set_login_cookies(user) next_url = self.get_argument('next', default='') or '/user/%s/' % username + print('next', next_url) self.redirect(next_url) else: self.log.debug("Failed login for %s", username) - self._render( + html = self._render( message={'error': 'Invalid username or password'}, username=username, ) + self.finish(html) #------------------------------------------------------------------------------ diff --git a/setup.py b/setup.py index 19414f5d..924f94af 100755 --- a/setup.py +++ b/setup.py @@ -42,9 +42,12 @@ except NameError: locs = locs or globs exec(compile(open(fname).read(), fname, "exec"), globs, locs) -here = os.path.abspath(os.path.dirname(__file__)) pjoin = os.path.join +here = os.path.abspath(os.path.dirname(__file__)) +share_jupyter = pjoin(here, 'share', 'jupyter') +static = pjoin(share_jupyter, 'static') + #--------------------------------------------------------------------------- # Build basic package data, etc. #--------------------------------------------------------------------------- @@ -53,7 +56,6 @@ def get_data_files(): """Get data files in share/jupyter""" data_files = [] - share_jupyter = pjoin(here, 'share', 'jupyter') ntrim = len(here) + 1 for (d, dirs, filenames) in os.walk(share_jupyter): @@ -117,19 +119,41 @@ class Bower(Command): def run(self): check_call(['bower', 'install', '--allow-root']) + # update data-files in case this created new files self.distribution.data_files = get_data_files() + +class CSS(Command): + description = "compile CSS from LESS" - def get_outputs(self): - return [] + user_options = [] - def get_inputs(self): - return [] + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + style_less = pjoin(static, 'less', 'style.less') + style_css = pjoin(static, 'css', 'style.min.css') + sourcemap = style_css + '.map' + check_call([ + 'lessc', '-x', '--verbose', + '--source-map-basepath={}'.format(static), + '--source-map={}'.format(sourcemap), + '--source-map-rootpath=../', + style_less, style_css, + ]) + # update data-files in case this created new files + self.distribution.data_files = get_data_files() # ensure bower is run as part of install install.sub_commands.insert(0, ('bower', None)) +install.sub_commands.insert(1, ('css', None)) setup_args['cmdclass'] = { 'bower': Bower, + 'css': CSS, } # setuptools requirements diff --git a/share/jupyter/static/images/jupyterlogo.png b/share/jupyter/static/images/jupyterlogo.png new file mode 100644 index 00000000..21f404e0 Binary files /dev/null and b/share/jupyter/static/images/jupyterlogo.png differ diff --git a/share/jupyter/static/less/error.less b/share/jupyter/static/less/error.less new file mode 100644 index 00000000..0a1eadb0 --- /dev/null +++ b/share/jupyter/static/less/error.less @@ -0,0 +1,20 @@ +div.error { + margin: 2em; + text-align: center; +} + +div.error > h1 { + font-size: 500%; + line-height: normal; +} + +div.error > p { + font-size: 200%; + line-height: normal; +} + +div.traceback-wrapper { + text-align: left; + max-width: 800px; + margin: auto; +} diff --git a/share/jupyter/static/less/logout.less b/share/jupyter/static/less/logout.less new file mode 100644 index 00000000..2e579363 --- /dev/null +++ b/share/jupyter/static/less/logout.less @@ -0,0 +1,14 @@ +div.logout-main { + margin: 2em; + text-align: center; +} + +div.logout-main > h1 { + font-size: 400%; + line-height: normal; +} + +div.logout-main > p { + font-size: 200%; + line-height: normal; +} diff --git a/share/jupyter/static/less/page.less b/share/jupyter/static/less/page.less new file mode 100644 index 00000000..5366572c --- /dev/null +++ b/share/jupyter/static/less/page.less @@ -0,0 +1,3 @@ +.jpy-logo { + height: 48px; +} diff --git a/share/jupyter/static/less/style.less b/share/jupyter/static/less/style.less new file mode 100644 index 00000000..41a0522c --- /dev/null +++ b/share/jupyter/static/less/style.less @@ -0,0 +1,26 @@ +/*! +* +* Twitter Bootstrap +* +*/ +@import "../components/bootstrap/less/bootstrap.less"; +@import "../components/bootstrap/less/responsive-utilities.less"; + +/*! +* +* Font Awesome +* +*/ +@import "../components/font-awesome/less/font-awesome.less"; +@fa-font-path: "../components/font-awesome/fonts"; + +/*! +* +* Jupyter +* +*/ + +@import "./variables.less"; +@import "./page.less"; +@import "./error.less"; +@import "./logout.less"; diff --git a/share/jupyter/static/less/variables.less b/share/jupyter/static/less/variables.less new file mode 100644 index 00000000..e69de29b diff --git a/share/jupyter/templates/404.html b/share/jupyter/templates/404.html new file mode 100644 index 00000000..67c4ac09 --- /dev/null +++ b/share/jupyter/templates/404.html @@ -0,0 +1,6 @@ +{% extends "error.html" %} + +{% block error_detail %} +

Jupyter has lots of moons, but this is not one...

+{% endblock %} + diff --git a/share/jupyter/templates/error.html b/share/jupyter/templates/error.html new file mode 100644 index 00000000..b86ca639 --- /dev/null +++ b/share/jupyter/templates/error.html @@ -0,0 +1,22 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} + +
+ {% block h1_error %} +

{{status_code}} : {{status_message}}

+ {% endblock h1_error %} + {% block error_detail %} + {% if message %} +

The error was:

+
+
{{message}}
+
+ {% endif %} + {% endblock %} + + +{% endblock %} diff --git a/share/jupyter/templates/login.html b/share/jupyter/templates/login.html index 0efa7cee..b36170ba 100644 --- a/share/jupyter/templates/login.html +++ b/share/jupyter/templates/login.html @@ -1,18 +1,44 @@ - - - - - - {% if message %} -
{{message}}
- {% end if %} -
- - - -
+{% extends "page.html" %} - - - \ No newline at end of file + +{% block login_widget %} +{% endblock %} + +{% block site %} + +
+
+
+
+
+ Username: + +
+
+
+
+ Password: + + + + +
+
+
+
+ {% if message %} +
+
+ {{message}} +
+
+ {% endif %} +
+ +{% endblock %} + + +{% block script %} +{{super()}} + +{% endblock %} diff --git a/share/jupyter/templates/logout.html b/share/jupyter/templates/logout.html new file mode 100644 index 00000000..e394f2b8 --- /dev/null +++ b/share/jupyter/templates/logout.html @@ -0,0 +1,13 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} + +
+

You have been logged out

+

Log in again...

+
+ +{% endblock %} diff --git a/share/jupyter/templates/page.html b/share/jupyter/templates/page.html new file mode 100644 index 00000000..4af4fd0e --- /dev/null +++ b/share/jupyter/templates/page.html @@ -0,0 +1,76 @@ + + + + + + + {% block title %}Jupyter Hub{% endblock %} + + + + + {% block stylesheet %} + + {% endblock %} + + + + {% block meta %} + {% endblock %} + + + + + + + + + +{% block main %} +{% endblock %} + +{% block script %} +{% endblock %} + + + +