Merge pull request #436 from minrk/subdomains

allow running single-user servers on subdomains
This commit is contained in:
Kyle Kelley
2016-02-28 09:49:45 -06:00
14 changed files with 237 additions and 92 deletions

View File

@@ -15,3 +15,7 @@ script:
- py.test --cov jupyterhub jupyterhub/tests -v
after_success:
- codecov
matrix:
include:
- python: 3.5
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000

View File

@@ -86,7 +86,7 @@ class APIHandler(BaseHandler):
model = {
'name': user.name,
'admin': user.admin,
'server': user.server.base_url if user.running else None,
'server': user.url if user.running else None,
'pending': None,
'last_activity': user.last_activity.isoformat(),
}

View File

@@ -28,7 +28,7 @@ class ProxyAPIHandler(APIHandler):
@gen.coroutine
def post(self):
"""POST checks the proxy to ensure"""
yield self.proxy.check_routes()
yield self.proxy.check_routes(self.users)
@admin_only
@@ -59,7 +59,7 @@ class ProxyAPIHandler(APIHandler):
self.proxy.auth_token = model['auth_token']
self.db.commit()
self.log.info("Updated proxy at %s", server.bind_url)
yield self.proxy.check_routes()
yield self.proxy.check_routes(self.users)

View File

@@ -16,6 +16,7 @@ from datetime import datetime
from distutils.version import LooseVersion as V
from getpass import getuser
from subprocess import Popen
from urllib.parse import urlparse
if sys.version_info[:2] < (3,3):
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
@@ -230,8 +231,21 @@ class JupyterHub(Application):
"""
)
ip = Unicode('', config=True,
help="The public facing ip of the proxy"
help="The public facing ip of the whole application (the proxy)"
)
subdomain_host = Unicode('', config=True,
help="""The public-facing host (domain[:port]) on which the Hub will run.
Only used when subdomains are involved.
"""
)
def _subdomain_host_changed(self, name, old, new):
if new and '://' not in new:
# host should include '://'
# if not specified, assume https: You have to be really explicit about HTTP!
self.subdomain_host = 'https://' + new
port = Integer(8000, config=True,
help="The public facing port of the proxy"
)
@@ -248,6 +262,18 @@ class JupyterHub(Application):
help="Supply extra arguments that will be passed to Jinja environment."
)
use_subdomains = Bool(False, config=True,
help="""Run single-user servers on subdomains.
Provides additional cross-site protections for client-side js.
Requires <username>.hub.domain.tld to resolve to the same host as hub.domain.tld.
In general, this is most easily achieved with wildcard DNS.
When using SSL (i.e. always) this also requires a wildcard cert.
""")
proxy_cmd = Command('configurable-http-proxy', config=True,
help="""The command to start the http proxy.
@@ -288,7 +314,6 @@ class JupyterHub(Application):
hub_ip = Unicode('127.0.0.1', config=True,
help="The ip for this process"
)
hub_prefix = URLPrefix('/hub/', config=True,
help="The prefix for the hub server. Must not be '/'"
)
@@ -574,11 +599,15 @@ class JupyterHub(Application):
q = self.db.query(orm.Hub)
assert q.count() <= 1
self._local.hub = q.first()
if self.use_subdomains and self._local.hub:
self._local.hub.host = self.subdomain_host
return self._local.hub
@hub.setter
def hub(self, hub):
self._local.hub = hub
if hub and self.use_subdomains:
hub.host = self.subdomain_host
@property
def proxy(self):
@@ -631,6 +660,10 @@ class JupyterHub(Application):
server.ip = self.hub_ip
server.port = self.hub_port
server.base_url = self.hub_prefix
if self.use_subdomains:
if not self.subdomain_host:
raise ValueError("Must specify subdomain_host when using subdomains."
" This should be the public domain[:port] of the Hub.")
self.db.commit()
@@ -809,6 +842,8 @@ class JupyterHub(Application):
'--api-port', str(self.proxy.api_server.port),
'--default-target', self.hub.server.host,
]
if self.use_subdomains:
cmd.append('--host-routing')
if self.debug_proxy:
cmd.extend(['--log-level', 'debug'])
if self.ssl_key:
@@ -867,7 +902,7 @@ class JupyterHub(Application):
)
yield self.start_proxy()
self.log.info("Setting up routes on new proxy")
yield self.proxy.add_all_users()
yield self.proxy.add_all_users(self.users)
self.log.info("New proxy back up, and good to go")
def init_tornado_settings(self):
@@ -893,6 +928,8 @@ class JupyterHub(Application):
else:
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
subdomain_host = self.subdomain_host
domain = urlparse(subdomain_host).hostname
settings = dict(
log_function=log_request,
config=self.config,
@@ -915,6 +952,9 @@ class JupyterHub(Application):
template_path=self.template_paths,
jinja2_env=jinja_env,
version_hash=version_hash,
use_subdomains=self.use_subdomains,
subdomain_host=subdomain_host,
domain=domain,
)
# allow configured settings to have priority
settings.update(self.tornado_settings)
@@ -1051,7 +1091,7 @@ class JupyterHub(Application):
user.last_activity = max(user.last_activity, dt)
self.db.commit()
yield self.proxy.check_routes(routes)
yield self.proxy.check_routes(routes, self.users)
@gen.coroutine
def start(self):
@@ -1086,7 +1126,7 @@ class JupyterHub(Application):
self.exit(1)
return
loop.add_callback(self.proxy.add_all_users)
loop.add_callback(self.proxy.add_all_users, self.users)
if self.proxy_process:
# only check / restart the proxy if we started it in the first place.

View File

@@ -51,6 +51,14 @@ class BaseHandler(RequestHandler):
def version_hash(self):
return self.settings.get('version_hash', '')
@property
def use_subdomains(self):
return self.settings.get('use_subdomains', False)
@property
def domain(self):
return self.settings['domain']
@property
def db(self):
return self.settings['db']
@@ -199,42 +207,44 @@ class BaseHandler(RequestHandler):
user = self.get_current_user()
else:
user = self.find_user(name)
kwargs = {}
if self.use_subdomains:
kwargs['domain'] = self.domain
if user and user.server:
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs)
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs)
def _set_user_cookie(self, user, server):
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure': True}
else:
kwargs = {}
if self.use_subdomains:
kwargs['domain'] = self.domain
self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs)
self.set_secure_cookie(
server.cookie_name,
user.cookie_id,
path=server.base_url,
**kwargs
)
def set_server_cookie(self, user):
"""set the login cookie for the single-user server"""
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure':True}
else:
kwargs = {}
self.set_secure_cookie(
user.server.cookie_name,
user.cookie_id,
path=user.server.base_url,
**kwargs
)
self._set_user_cookie(user, user.server)
def set_hub_cookie(self, user):
"""set the login cookie for the Hub"""
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure':True}
else:
kwargs = {}
self.set_secure_cookie(
self.hub.server.cookie_name,
user.cookie_id,
path=self.hub.server.base_url,
**kwargs
)
self._set_user_cookie(user, self.hub.server)
def set_login_cookie(self, user):
"""Set login cookies for the Hub and single-user server."""
if self.use_subdomains and not self.request.host.startswith(self.domain):
self.log.warning(
"Possibly setting cookie on wrong domain: %s != %s",
self.request.host, self.domain)
# create and set a new cookie token for the single-user server
if user.server:
self.set_server_cookie(user)
@@ -472,6 +482,8 @@ class UserSpawnHandler(BaseHandler):
self.set_login_cookie(current_user)
without_prefix = self.request.uri[len(self.hub.server.base_url):]
target = url_path_join(self.base_url, without_prefix)
if self.use_subdomains:
target = current_user.host + target
self.redirect(target)
else:
# not logged in to the right user,

View File

@@ -25,7 +25,7 @@ class RootHandler(BaseHandler):
user = self.get_current_user()
if user:
if user.running:
url = user.server.base_url
url = user.url
self.log.debug("User is running: %s", url)
else:
url = url_path_join(self.hub.server.base_url, 'home')
@@ -67,7 +67,7 @@ class SpawnHandler(BaseHandler):
"""GET renders form for spawning with user-specified options"""
user = self.get_current_user()
if user.running:
url = user.server.base_url
url = user.url
self.log.debug("User is running: %s", url)
self.redirect(url)
return
@@ -84,7 +84,7 @@ class SpawnHandler(BaseHandler):
"""POST spawns with user-specified options"""
user = self.get_current_user()
if user.running:
url = user.server.base_url
url = user.url
self.log.warning("User is already running: %s", url)
self.redirect(url)
return
@@ -101,7 +101,7 @@ class SpawnHandler(BaseHandler):
self.finish(self._render_form(str(e)))
return
self.set_login_cookie(user)
url = user.server.base_url
url = user.url
self.redirect(url)
class AdminHandler(BaseHandler):

View File

@@ -176,10 +176,10 @@ class Proxy(Base):
def add_user(self, user, client=None):
"""Add a user's server to the proxy table."""
self.log.info("Adding user %s to proxy %s => %s",
user.name, user.server.base_url, user.server.host,
user.name, user.proxy_path, user.server.host,
)
yield self.api_request(user.server.base_url,
yield self.api_request(user.proxy_path,
method='POST',
body=dict(
target=user.server.host,
@@ -192,26 +192,11 @@ class Proxy(Base):
def delete_user(self, user, client=None):
"""Remove a user's server to the proxy table."""
self.log.info("Removing user %s from proxy", user.name)
yield self.api_request(user.server.base_url,
yield self.api_request(user.proxy_path,
method='DELETE',
client=client,
)
@gen.coroutine
def add_all_users(self):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = inspect(self).session
futures = []
for user in db.query(User):
if (user.server):
futures.append(self.add_user(user))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def get_routes(self, client=None):
"""Fetch the proxy's routes"""
@@ -219,15 +204,32 @@ class Proxy(Base):
return json.loads(resp.body.decode('utf8', 'replace'))
@gen.coroutine
def check_routes(self, routes=None):
"""Check that all users are properly"""
def add_all_users(self, user_dict):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = inspect(self).session
futures = []
for orm_user in db.query(User):
user = user_dict[orm_user]
if (user.server):
futures.append(self.add_user(user))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def check_routes(self, user_dict, routes=None):
"""Check that all users are properly routed on the proxy"""
if not routes:
routes = yield self.get_routes()
have_routes = { r['user'] for r in routes.values() if 'user' in r }
futures = []
db = inspect(self).session
for user in db.query(User).filter(User.server != None):
for orm_user in db.query(User).filter(User.server != None):
user = user_dict[orm_user]
if user.server is None:
# This should never be True, but seems to be on rare occasion.
# catch filter bug, either in sqlalchemy or my understanding of its behavior
@@ -253,6 +255,7 @@ class Hub(Base):
id = Column(Integer, primary_key=True)
_server_id = Column(Integer, ForeignKey('servers.id'))
server = relationship(Server, primaryjoin=_server_id == Server.id)
host = ''
@property
def api_url(self):

View File

@@ -199,6 +199,7 @@ class Spawner(LoggingConfigurable):
'--port=%i' % self.user.server.port,
'--cookie-name=%s' % self.user.server.cookie_name,
'--base-url=%s' % self.user.server.base_url,
'--hub-host=%s' % self.hub.host,
'--hub-prefix=%s' % self.hub.server.base_url,
'--hub-api-url=%s' % self.hub.api_url,
]

View File

@@ -1,7 +1,7 @@
"""mock utilities for testing"""
import os
import sys
from datetime import timedelta
from tempfile import NamedTemporaryFile
import threading
@@ -13,10 +13,11 @@ from tornado import gen
from tornado.concurrent import Future
from tornado.ioloop import IOLoop
from ..spawner import LocalProcessSpawner
from ..app import JupyterHub
from ..auth import PAMAuthenticator
from .. import orm
from ..spawner import LocalProcessSpawner
from ..utils import url_path_join
from pamela import PAMError
@@ -110,6 +111,12 @@ class MockHub(JupyterHub):
db_file = None
confirm_no_ssl = True
def _subdomain_host_default(self):
return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '')
def _use_subdomains_default(self):
return bool(self.subdomain_host)
def _ip_default(self):
return '127.0.0.1'
@@ -161,7 +168,8 @@ class MockHub(JupyterHub):
self.db_file.close()
def login_user(self, name):
r = requests.post(self.proxy.public_server.url + 'hub/login',
base_url = public_url(self)
r = requests.post(base_url + 'hub/login',
data={
'username': name,
'password': name,
@@ -171,3 +179,21 @@ class MockHub(JupyterHub):
assert r.cookies
return r.cookies
def public_host(app):
if app.use_subdomains:
return app.subdomain_host
else:
return app.proxy.public_server.host
def public_url(app):
return public_host(app) + app.proxy.public_server.base_url
def user_url(user, app):
if app.use_subdomains:
host = user.host
else:
host = public_host(app)
return host + user.server.base_url

View File

@@ -2,7 +2,6 @@
import json
import time
from datetime import timedelta
from queue import Queue
from urllib.parse import urlparse
@@ -14,6 +13,7 @@ from .. import orm
from ..user import User
from ..utils import url_path_join as ujoin
from . import mocking
from .mocking import public_url, user_url
def check_db_locks(func):
@@ -105,7 +105,7 @@ def test_auth_api(app):
def test_referer_check(app, io_loop):
url = app.hub.server.url
url = ujoin(public_url(app), app.hub.server.base_url)
host = urlparse(url).netloc
user = find_user(app.db, 'admin')
if user is None:
@@ -352,15 +352,19 @@ def test_spawn(app, io_loop):
assert status is None
assert user.server.base_url == '/user/%s' % name
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url))
url = user_url(user, app)
print(url)
r = requests.get(url)
assert r.status_code == 200
assert r.text == user.server.base_url
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args'))
r = requests.get(ujoin(url, 'args'))
assert r.status_code == 200
argv = r.json()
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
assert expected in argv
if app.use_subdomains:
assert '--hub-host=%s' % app.subdomain_host in argv
r = api_request(app, 'users', name, 'server', method='delete')
assert r.status_code == 204

View File

@@ -8,12 +8,11 @@ from ..utils import url_path_join as ujoin
from .. import orm
import mock
from .mocking import FormSpawner
from .mocking import FormSpawner, public_url, public_host, user_url
from .test_api import api_request
def get_page(path, app, **kw):
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
base_url = ujoin(public_url(app), app.hub.server.base_url)
print(base_url)
return requests.get(ujoin(base_url, path), **kw)
@@ -22,15 +21,17 @@ def test_root_no_auth(app, io_loop):
routes = io_loop.run_sync(app.proxy.get_routes)
print(routes)
print(app.hub.server)
r = requests.get(app.proxy.public_server.host)
url = public_url(app)
print(url)
r = requests.get(url)
r.raise_for_status()
assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url, 'login')
assert r.url == ujoin(url, app.hub.server.base_url, 'login')
def test_root_auth(app):
cookies = app.login_user('river')
r = requests.get(app.proxy.public_server.host, cookies=cookies)
r = requests.get(public_url(app), cookies=cookies)
r.raise_for_status()
assert r.url == ujoin(app.proxy.public_server.host, '/user/river')
assert r.url == user_url(app.users['river'], app)
def test_home_no_auth(app):
r = get_page('home', app, allow_redirects=False)
@@ -100,7 +101,7 @@ def test_spawn_page(app):
def test_spawn_form(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
base_url = ujoin(public_url(app), app.hub.server.base_url)
cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u]
@@ -121,7 +122,7 @@ def test_spawn_form(app, io_loop):
def test_spawn_form_with_file(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
base_url = ujoin(public_url(app), app.hub.server.base_url)
cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u]
@@ -149,7 +150,7 @@ def test_spawn_form_with_file(app, io_loop):
def test_static_files(app):
base_url = ujoin(app.proxy.public_server.url, app.hub.server.base_url)
base_url = ujoin(public_url(app), app.hub.server.base_url)
print(base_url)
r = requests.get(ujoin(base_url, 'logo'))
r.raise_for_status()

View File

@@ -4,6 +4,7 @@ import json
import os
from queue import Queue
from subprocess import Popen
from urllib.parse import urlparse
from .. import orm
from .mocking import MockHub
@@ -34,6 +35,8 @@ def test_external_proxy(request, io_loop):
'--api-port', str(proxy_port),
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
]
if app.use_subdomains:
cmd.append('--host-routing')
proxy = Popen(cmd, env=env)
def _cleanup_proxy():
if proxy.poll() is None:
@@ -60,7 +63,11 @@ def test_external_proxy(request, io_loop):
r.raise_for_status()
routes = io_loop.run_sync(app.proxy.get_routes)
assert sorted(routes.keys()) == ['/', '/user/river']
user_path = '/user/river'
if app.use_subdomains:
domain = urlparse(app.subdomain_host).hostname
user_path = '/%s.%s' % (name, domain) + user_path
assert sorted(routes.keys()) == ['/', user_path]
# teardown the proxy and start a new one in the same place
proxy.terminate()
@@ -76,7 +83,7 @@ def test_external_proxy(request, io_loop):
# check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes)
assert sorted(routes.keys()) == ['/', '/user/river']
assert sorted(routes.keys()) == ['/', user_path]
# teardown the proxy again, and start a new one with different auth and port
proxy.terminate()
@@ -90,7 +97,8 @@ def test_external_proxy(request, io_loop):
'--api-port', str(proxy_port),
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
]
if app.use_subdomains:
cmd.append('--host-routing')
proxy = Popen(cmd, env=env)
wait_for_proxy()
@@ -113,7 +121,7 @@ def test_external_proxy(request, io_loop):
# check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes)
assert sorted(routes.keys()) == ['/', '/user/river']
assert sorted(routes.keys()) == ['/', user_path]
def test_check_routes(app, io_loop):
proxy = app.proxy
@@ -123,13 +131,14 @@ def test_check_routes(app, io_loop):
r.raise_for_status()
zoe = orm.User.find(app.db, 'zoe')
assert zoe is not None
zoe = app.users[zoe]
before = sorted(io_loop.run_sync(app.proxy.get_routes))
assert '/user/zoe' in before
io_loop.run_sync(app.proxy.check_routes)
assert zoe.proxy_path in before
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
io_loop.run_sync(lambda : proxy.delete_user(zoe))
during = sorted(io_loop.run_sync(app.proxy.get_routes))
assert '/user/zoe' not in during
io_loop.run_sync(app.proxy.check_routes)
assert zoe.proxy_path not in during
io_loop.run_sync(lambda : app.proxy.check_routes(app.users))
after = sorted(io_loop.run_sync(app.proxy.get_routes))
assert '/user/zoe' in after
assert zoe.proxy_path in after
assert before == after

View File

@@ -2,7 +2,7 @@
# Distributed under the terms of the Modified BSD License.
from datetime import datetime, timedelta
from urllib.parse import quote
from urllib.parse import quote, urlparse
from tornado import gen
from tornado.log import app_log
@@ -38,6 +38,12 @@ class UserDict(dict):
def __getitem__(self, key):
if isinstance(key, User):
key = key.id
elif isinstance(key, str):
orm_user = self.db.query(orm.User).filter(orm.User.name==key).first()
if orm_user is None:
raise KeyError("No such user: %s" % name)
else:
key = orm_user
if isinstance(key, orm.User):
# users[orm_user] returns User(orm_user)
orm_user = key
@@ -148,6 +154,41 @@ class User(HasTraits):
"""My name, escaped for use in URLs, cookies, etc."""
return quote(self.name, safe='@')
@property
def proxy_path(self):
if self.settings.get('use_subdomains'):
return url_path_join('/' + self.domain, self.server.base_url)
else:
return self.server.base_url
@property
def domain(self):
"""Get the domain for my server."""
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
return self.escaped_name + '.' + self.settings['domain']
@property
def host(self):
"""Get the *host* for my server (domain[:port])"""
# FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
parsed = urlparse(self.settings['subdomain_host'])
h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc)
return h
@property
def url(self):
"""My URL
Full name.domain/path if using subdomains, otherwise just my /base/url
"""
if self.settings.get('use_subdomains'):
return '{host}{path}'.format(
host=self.host,
path=self.server.base_url,
)
else:
return self.server.base_url
@gen.coroutine
def spawn(self, options=None):
"""Start the user's spawner"""

View File

@@ -104,7 +104,9 @@ class JupyterHubLoginHandler(LoginHandler):
class JupyterHubLogoutHandler(LogoutHandler):
def get(self):
self.redirect(url_path_join(self.settings['hub_prefix'], 'logout'))
self.redirect(
self.settings['hub_host'] +
url_path_join(self.settings['hub_prefix'], 'logout'))
# register new hub related command-line aliases
@@ -113,6 +115,7 @@ aliases.update({
'user' : 'SingleUserNotebookApp.user',
'cookie-name': 'SingleUserNotebookApp.cookie_name',
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
'hub-host': 'SingleUserNotebookApp.hub_host',
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
'base-url': 'SingleUserNotebookApp.base_url',
})
@@ -141,6 +144,7 @@ class SingleUserNotebookApp(NotebookApp):
self.log.name = new
cookie_name = Unicode(config=True)
hub_prefix = Unicode(config=True)
hub_host = Unicode(config=True)
hub_api_url = Unicode(config=True)
aliases = aliases
open_browser = False
@@ -194,22 +198,22 @@ class SingleUserNotebookApp(NotebookApp):
s['user'] = self.user
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
s['hub_prefix'] = self.hub_prefix
s['hub_host'] = self.hub_host
s['cookie_name'] = self.cookie_name
s['login_url'] = self.hub_prefix
s['login_url'] = self.hub_host + self.hub_prefix
s['hub_api_url'] = self.hub_api_url
s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report')
s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
super(SingleUserNotebookApp, self).init_webapp()
self.patch_templates()
def patch_templates(self):
"""Patch page templates to add Hub-related buttons"""
self.jinja_template_vars['logo_url'] = url_path_join(self.hub_prefix, 'logo')
self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo')
env = self.web_app.settings['jinja2_env']
env.globals['hub_control_panel_url'] = \
url_path_join(self.hub_prefix, 'home')
self.hub_host + url_path_join(self.hub_prefix, 'home')
# patch jinja env loading to modify page template
def get_page(name):