diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..ce78039 --- /dev/null +++ b/bower.json @@ -0,0 +1,11 @@ +{ + "name": "jupyterhub-deps", + "version": "0.0.0", + "dependencies": { + "bootstrap": "components/bootstrap#~3.1", + "font-awesome": "components/font-awesome#~4.1", + "jquery": "components/jquery#~2.0", + "moment": "~2.7", + "requirejs": "~2.1" + } +} diff --git a/everware/__init__.py b/everware/__init__.py new file mode 100644 index 0000000..49f7ba1 --- /dev/null +++ b/everware/__init__.py @@ -0,0 +1,5 @@ + +from .spawner import * +from .authenticator import * +from .homehandler import * + diff --git a/everware/authenticator.py b/everware/authenticator.py new file mode 100644 index 0000000..72e505f --- /dev/null +++ b/everware/authenticator.py @@ -0,0 +1,299 @@ +""" +Custom Authenticator to use GitHub OAuth with JupyterHub + +Most of the code c/o Kyle Kelley (@rgbkrk) +""" + + +import json +import os +import urllib + +from tornado.auth import OAuth2Mixin +from tornado.escape import url_escape +from tornado import gen, web + +from tornado.httputil import url_concat +from tornado.httpclient import HTTPRequest, AsyncHTTPClient + +from jupyterhub.handlers import BaseHandler +from jupyterhub.auth import Authenticator, LocalAuthenticator +from jupyterhub.utils import url_path_join + +from traitlets import Unicode, Set + + +class GitHubMixin(OAuth2Mixin): + _OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" + _OAUTH_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + + +class BitbucketMixin(OAuth2Mixin): + _OAUTH_AUTHORIZE_URL = "https://bitbucket.org/site/oauth2/authorize" + _OAUTH_ACCESS_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token" + + +class WelcomeHandler(BaseHandler): + """Render the login page.""" + + def _render(self, login_error=None, username=None): + return self.render_template('login.html', + next=url_escape(self.get_argument('next', default='')), + username=username, + login_error=login_error, + ) + + def get(self): + next_url = self.get_argument('next', '') + if not next_url.startswith('/'): + # disallow non-absolute next URLs (e.g. full URLs) + next_url = '' + user = self.get_current_user() + if user: + if not next_url: + if user.running: + next_url = user.server.base_url + else: + next_url = self.hub.server.base_url + # set new login cookie + # because single-user cookie may have been cleared or incorrect + #self.set_login_cookie(self.get_current_user()) + self.redirect('/oauth_login', permanent=False) + else: + self.finish(self._render()) + +class OAuthLoginHandler(BaseHandler): + + def get(self): + guess_uri = '{proto}://{host}{path}'.format( + proto=self.request.protocol, + host=self.request.host, + path=url_path_join( + self.hub.server.base_url, + 'oauth_callback' + ) + ) + + redirect_uri = self.authenticator.oauth_callback_url or guess_uri + self.log.info('oauth redirect: %r', redirect_uri) + + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.authenticator.client_id, + scope=[], + response_type='code') + + +class GitHubLoginHandler(OAuthLoginHandler, GitHubMixin): + pass + + +class BitbucketLoginHandler(OAuthLoginHandler, BitbucketMixin): + pass + + +class GitHubOAuthHandler(BaseHandler): + @gen.coroutine + def get(self): + # TODO: Check if state argument needs to be checked + username = yield self.authenticator.authenticate(self) + if username: + user = self.user_from_username(username) + self.set_login_cookie(user) + self.redirect(self.hub.server.base_url) + else: + # todo: custom error page? + raise web.HTTPError(403) + + +class BitbucketOAuthHandler(GitHubOAuthHandler): + pass + + +class GitHubOAuthenticator(Authenticator): + + login_service = "GitHub" + oauth_callback_url = Unicode('', config=True) + client_id = Unicode(os.environ.get('GITHUB_CLIENT_ID', ''), + config=True) + client_secret = Unicode(os.environ.get('GITHUB_CLIENT_SECRET', ''), + config=True) + + def login_url(self, base_url): + return url_path_join(base_url, 'login') + + def get_handlers(self, app): + return [ + (r'/login', WelcomeHandler), + (r'/oauth_login', GitHubLoginHandler), + (r'/oauth_callback', GitHubOAuthHandler), + ] + + @gen.coroutine + def authenticate(self, handler): + code = handler.get_argument("code", False) + if not code: + raise web.HTTPError(400, "oauth callback made without a token") + # TODO: Configure the curl_httpclient for tornado + http_client = AsyncHTTPClient() + + # Exchange the OAuth code for a GitHub Access Token + # + # See: https://developer.github.com/v3/oauth/ + + # GitHub specifies a POST request yet requires URL parameters + params = dict( + client_id=self.client_id, + client_secret=self.client_secret, + code=code + ) + + url = url_concat("https://github.com/login/oauth/access_token", + params) + + req = HTTPRequest(url, + method="POST", + headers={"Accept": "application/json"}, + body='' # Body is required for a POST... + ) + + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + access_token = resp_json['access_token'] + + # Determine who the logged in user is + headers={"Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "token {}".format(access_token) + } + req = HTTPRequest("https://api.github.com/user", + method="GET", + headers=headers + ) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + username = resp_json["login"] + if self.whitelist and username not in self.whitelist: + username = None + raise gen.Return(username) + + +class BitbucketOAuthenticator(Authenticator): + + login_service = "Bitbucket" + oauth_callback_url = Unicode(os.environ.get('OAUTH_CALLBACK_URL', ''), + config=True) + client_id = Unicode(os.environ.get('BITBUCKET_CLIENT_ID', ''), + config=True) + client_secret = Unicode(os.environ.get('BITBUCKET_CLIENT_SECRET', ''), + config=True) + team_whitelist = Set( + config=True, + help="Automatically whitelist members of selected teams", + ) + + def login_url(self, base_url): + return url_path_join(base_url, 'oauth_login') + + def get_handlers(self, app): + return [ + (r'/oauth_login', BitbucketLoginHandler), + (r'/oauth_callback', BitbucketOAuthHandler), + ] + + @gen.coroutine + def authenticate(self, handler): + code = handler.get_argument("code", False) + if not code: + raise web.HTTPError(400, "oauth callback made without a token") + # TODO: Configure the curl_httpclient for tornado + http_client = AsyncHTTPClient() + + params = dict( + client_id=self.client_id, + client_secret=self.client_secret, + grant_type="authorization_code", + code=code, + redirect_uri=self.oauth_callback_url + ) + + url = url_concat( + "https://bitbucket.org/site/oauth2/access_token", params) + self.log.info(url) + + bb_header = {"Content-Type": + "application/x-www-form-urlencoded;charset=utf-8"} + req = HTTPRequest(url, + method="POST", + auth_username=self.client_id, + auth_password=self.client_secret, + body=urllib.parse.urlencode(params).encode('utf-8'), + headers=bb_header + ) + + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + access_token = resp_json['access_token'] + + # Determine who the logged in user is + headers = {"Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token) + } + req = HTTPRequest("https://api.bitbucket.org/2.0/user", + method="GET", + headers=headers + ) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + + username = resp_json["username"] + whitelisted = yield self.check_whitelist(username, headers) + if not whitelisted: + username = None + return username + + def check_whitelist(self, username, headers): + if self.team_whitelist: + return self._check_group_whitelist(username, headers) + else: + return self._check_user_whitelist(username) + + @gen.coroutine + def _check_user_whitelist(self, user): + return (not self.whitelist) or (user in self.whitelist) + + @gen.coroutine + def _check_group_whitelist(self, username, headers): + http_client = AsyncHTTPClient() + + # We verify the team membership by calling teams endpoint. + # Re-use the headers, change the request. + next_page = url_concat("https://api.bitbucket.org/2.0/teams", + {'role': 'member'}) + user_teams = set() + while next_page: + req = HTTPRequest(next_page, method="GET", headers=headers) + resp = yield http_client.fetch(req) + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + next_page = resp_json.get('next', None) + + user_teams |= \ + set([entry["username"] for entry in resp_json["values"]]) + return len(self.team_whitelist & user_teams) > 0 + + +class LocalGitHubOAuthenticator(LocalAuthenticator, GitHubOAuthenticator): + + """A version that mixes in local system user creation""" + pass + + +class LocalBitbucketOAuthenticator(LocalAuthenticator, + BitbucketOAuthenticator): + + """A version that mixes in local system user creation""" + pass diff --git a/everware/homehandler.py b/everware/homehandler.py new file mode 100644 index 0000000..c0f9672 --- /dev/null +++ b/everware/homehandler.py @@ -0,0 +1,37 @@ + +from tornado import gen +from jupyterhub.handlers.pages import web, BaseHandler + +class HomeHandler(BaseHandler): + """Render the user's home page.""" + + @web.authenticated + def get(self): + html = self.render_template('home.html', + user=self.get_current_user(), + ) + self.finish(html) + + @gen.coroutine + def post(self): + data = {} + for arg in self.request.arguments: + data[arg] = self.get_argument(arg) + + repo_url = data['repourl'] + user = self.get_current_user() + user.last_repo_url = repo_url + + already_running = False + if user.spawner: + status = yield user.spawner.poll() + already_running = (status == None) + if not already_running: + yield self.spawn_single_user(user) + + user.last_repo_url = repo_url + self.db.commit() + + self.redirect(self.hub.server.base_url) + + diff --git a/everware/spawner.py b/everware/spawner.py new file mode 100644 index 0000000..c9a48b1 --- /dev/null +++ b/everware/spawner.py @@ -0,0 +1,110 @@ +import pwd +from tempfile import mkdtemp +from datetime import timedelta + +from dockerspawner import DockerSpawner +from textwrap import dedent +from traitlets import ( + Integer, + Unicode, +) +from tornado import gen + +from escapism import escape + +import git + + +class CustomDockerSpawner(DockerSpawner): + def __init__(self, **kwargs): + self.user = kwargs['user'] + self.repo_url = self.user.last_repo_url + self.repo_sha = kwargs.get('last_commit', '') + super(CustomDockerSpawner, self).__init__(**kwargs) + + _git_executor = None + @property + def git_executor(self): + """single global git executor""" + cls = self.__class__ + if cls._git_executor is None: + cls._git_executor = ThreadPoolExecutor(1) + return cls._git_executor + + _git_client = None + @property + def git_client(self): + """single global git client instance""" + cls = self.__class__ + if cls._git_client is None: + cls._git_client = git.Git() + return cls._git_client + + def _git(self, method, *args, **kwargs): + """wrapper for calling git methods + + to be passed to ThreadPoolExecutor + """ + m = getattr(self.git_client, method) + return m(*args, **kwargs) + + def git(self, method, *args, **kwargs): + """Call a git method in a background thread + + returns a Future + """ + return self.executor.submit(self._git, method, *args, **kwargs) + + _escaped_repo_url = None + + @property + def escaped_repo_url(self): + if self._escaped_repo_url is None: + trans = str.maketrans(':/-.', "____") + self._escaped_repo_url = self.repo_url.translate(trans) + return self._escaped_repo_url + + @property + def container_name(self): + return "{}-{}-{}-{}".format(self.container_prefix, + self.escaped_name, + self.escaped_repo_url, + self.repo_sha) + + @gen.coroutine + def start(self, image=None): + """start the single-user server in a docker container""" + tmp_dir = mkdtemp(suffix='-everware') + yield self.git('clone', self.repo_url, tmp_dir) + # is this blocking? + # use the username, git repo URL and HEAD commit sha to derive + # the image name + repo = git.Repo(tmp_dir) + self.repo_sha = repo.rev_parse("HEAD") + + image_name = "everware/{}-{}-{}".format(self.user.name, + self.escaped_repo_url, + self.repo_sha) + + self.log.debug("Building image {}".format(image_name)) + build_log = yield gen.with_timeout(timedelta(30), + self.docker('build', + path=tmp_dir, + tag='bla', #image_name, + rm=True)) + self.log.debug("".join(str(line) for line in build_log)) + self.log.info("Built docker image {}".format(image_name)) + + images = yield self.docker('images', image_name) + self.log.debug(images) + + yield super(CustomDockerSpawner, self).start( + image='bla' #image_name + ) + + def _env_default(self): + env = super(CustomDockerSpawner, self)._env_default() + + env.update({'JPY_GITHUBURL': self.repo_url}) + + return env diff --git a/package.json b/package.json new file mode 100644 index 0000000..a6d7702 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "jupyterhub-deps", + "version": "0.0.0", + "description": "JupyterHub nodejs dependencies", + "author": "Jupyter Developers", + "license": "BSD", + "repository": { + "type": "git", + "url": "https://github.com/jupyter/jupyterhub.git" + }, + "devDependencies": { + "bower": "*", + "less": "~2", + "less-plugin-clean-css": "*" + } +} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4064dfe --- /dev/null +++ b/setup.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +# coding: utf-8 + +from __future__ import print_function + +import os +import sys +import shutil +from subprocess import check_call + +v = sys.version_info +if v[:2] < (3,3): + error = "ERROR: Jupyter Hub requires Python version 3.3 or above." + print(error, file=sys.stderr) + sys.exit(1) + +def mtime(path): + """shorthand for mtime""" + return os.stat(path).st_mtime + + +if os.name in ('nt', 'dos'): + error = "ERROR: Windows is not supported" + print(error, file=sys.stderr) + +# At least we're on the python version we need, move on. + +from distutils.core import setup + +pjoin = os.path.join +here = os.path.abspath(os.path.dirname(__file__)) + +from distutils.cmd import Command +from distutils.command.build_py import build_py +from distutils.command.sdist import sdist + +npm_path = ':'.join([ + pjoin(here, 'node_modules', '.bin'), + os.environ.get("PATH", os.defpath), +]) + +here = os.path.abspath(os.path.dirname(__file__)) +share = pjoin(here, 'share') +static = pjoin(share, 'static') + +class BaseCommand(Command): + """Dumb empty command because Command needs subclasses to override too much""" + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def get_inputs(self): + return [] + + def get_outputs(self): + return [] + +class Bower(BaseCommand): + description = "fetch static client-side components with bower" + + user_options = [] + bower_dir = pjoin(static, 'components') + node_modules = pjoin(here, 'node_modules') + + def should_run(self): + if not os.path.exists(self.bower_dir): + return True + return mtime(self.bower_dir) < mtime(pjoin(here, 'bower.json')) + + def should_run_npm(self): + if not shutil.which('npm'): + print("npm unavailable", file=sys.stderr) + return False + if not os.path.exists(self.node_modules): + return True + return mtime(self.node_modules) < mtime(pjoin(here, 'package.json')) + + def run(self): + if not self.should_run(): + print("bower dependencies up to date") + return + + if self.should_run_npm(): + print("installing build dependencies with npm") + check_call(['npm', 'install'], cwd=here) + os.utime(self.node_modules) + + env = os.environ.copy() + env['PATH'] = npm_path + + try: + check_call( + ['bower', 'install', '--allow-root', '--config.interactive=false'], + cwd=here, + env=env, + ) + except OSError as e: + print("Failed to run bower: %s" % e, file=sys.stderr) + print("You can install js dependencies with `npm install`", file=sys.stderr) + raise + os.utime(self.bower_dir) + # update data-files in case this created new files + self.distribution.data_files = get_data_files() + + +class CSS(BaseCommand): + description = "compile CSS from LESS" + + def should_run(self): + """Does less need to run?""" + # from IPython.html.tasks.py + + css_targets = [pjoin(static, 'css', 'style.min.css')] + css_maps = [t + '.map' for t in css_targets] + targets = css_targets + css_maps + if not all(os.path.exists(t) for t in targets): + # some generated files don't exist + return True + earliest_target = sorted(mtime(t) for t in targets)[0] + + # check if any .less files are newer than the generated targets + for (dirpath, dirnames, filenames) in os.walk(static): + for f in filenames: + if f.endswith('.less'): + path = pjoin(static, dirpath, f) + timestamp = mtime(path) + if timestamp > earliest_target: + return True + + return False + + def run(self): + if not self.should_run(): + print("CSS up-to-date") + return + + self.run_command('js') + + style_less = pjoin(static, 'less', 'style.less') + style_css = pjoin(static, 'css', 'style.min.css') + sourcemap = style_css + '.map' + + env = os.environ.copy() + env['PATH'] = npm_path + try: + check_call([ + 'lessc', '--clean-css', + '--source-map-basepath={}'.format(static), + '--source-map={}'.format(sourcemap), + '--source-map-rootpath=../', + style_less, style_css, + ], cwd=here, env=env) + except OSError as e: + print("Failed to run lessc: %s" % e, file=sys.stderr) + print("You can install js dependencies with `npm install`", file=sys.stderr) + raise + # update data-files in case this created new files + self.distribution.data_files = get_data_files() + +def get_data_files(): + """Get data files in share/jupyter""" + + data_files = [] + ntrim = len(here) + 1 + + for (d, dirs, filenames) in os.walk(static): + data_files.append(( + d[ntrim:], + [ pjoin(d, f) for f in filenames ] + )) + return data_files + + +setup_args = dict( + name = 'everware', + packages = ['everware'], + version = '0.0.0', + description = """Everware""", + long_description = "", + author = "", + author_email = "", + url = "", + license = "BSD", + platforms = "Linux, Mac OS X", + keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'], + classifiers = [ + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + ], +) + +setup_args['cmdclass'] = {'js': Bower, 'css': CSS} + +# setuptools requirements +if 'setuptools' in sys.modules: + setup_args['install_requires'] = install_requires = [] + with open('requirements.txt') as f: + for line in f.readlines(): + req = line.strip() + if not req or req.startswith(('-e', '#')): + continue + install_requires.append(req) + + +def main(): + setup(**setup_args) + +if __name__ == '__main__': + main() diff --git a/share/static/html/error.html b/share/static/html/error.html new file mode 100644 index 0000000..c8fef45 --- /dev/null +++ b/share/static/html/error.html @@ -0,0 +1,23 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} + +
+ {% block h1_error %} +

+ {{status_code}} : {{status_message}} +

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

+ {{message}} +

+ {% endif %} + {% endblock error_detail %} +
+ +{% endblock %} diff --git a/share/static/html/home.html b/share/static/html/home.html new file mode 100644 index 0000000..5d8d4f1 --- /dev/null +++ b/share/static/html/home.html @@ -0,0 +1,48 @@ +{% extends "page.html" %} + +{% block main %} + +
+
+

everware

+

Run your notebooks in the cloud

+
+ +
+
+
+ +
+ +
+
+ + + +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/share/static/html/login.html b/share/static/html/login.html new file mode 100644 index 0000000..162531a --- /dev/null +++ b/share/static/html/login.html @@ -0,0 +1,96 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} +{% block login %} +
+
+

everware

+

Run your notebooks in the cloud

+
+ +
+{% if custom_html %} +{{ custom_html }} +{% elif login_service %} + +{% else %} +
+
+ {% if login_error %} + + {% endif %} +
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+{% endif %} +
+
+{% endblock login %} + + + +{% endblock %} + +{% block script %} +{{super()}} + +{% endblock %} diff --git a/share/static/html/page.html b/share/static/html/page.html new file mode 100644 index 0000000..8a84c4a --- /dev/null +++ b/share/static/html/page.html @@ -0,0 +1,120 @@ +{% macro modal(title, btn_label=None, btn_class="btn-primary") %} +{% set key = title.replace(' ', '-').lower() %} +{% set btn_label = btn_label or title %} + +{% endmacro %} + + + + + + + + + {% block title %}Jupyter Hub{% endblock %} + + + + {% block stylesheet %} + + {% endblock %} + + + + + + {% block meta %} + {% endblock %} + + + + + + + + + +{% block main %} +{% endblock %} + +{% call modal('Error', btn_label='OK') %} +
+ The error +
+{% endcall %} + +{% block script %} +{% endblock %} + + + + diff --git a/share/static/html/spawn_pending.html b/share/static/html/spawn_pending.html new file mode 100644 index 0000000..a662be5 --- /dev/null +++ b/share/static/html/spawn_pending.html @@ -0,0 +1,28 @@ +{% extends "page.html" %} + +{% block main %} + +
+
+
+

Your server is starting up.

+

You will be redirected automatically when it's ready for you.

+ refresh +
+
+
+ +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/share/static/images/jupyter.png b/share/static/images/jupyter.png new file mode 100644 index 0000000..54cc416 Binary files /dev/null and b/share/static/images/jupyter.png differ diff --git a/share/static/images/jupyterhub-80.png b/share/static/images/jupyterhub-80.png new file mode 100644 index 0000000..a83af52 Binary files /dev/null and b/share/static/images/jupyterhub-80.png differ diff --git a/share/static/js/admin.js b/share/static/js/admin.js new file mode 100644 index 0000000..30b22a3 --- /dev/null +++ b/share/static/js/admin.js @@ -0,0 +1,197 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, moment, JHAPI, utils) { + "use strict"; + + var base_url = window.jhdata.base_url; + var prefix = window.jhdata.prefix; + + var api = new JHAPI(base_url); + + function get_row (element) { + while (!element.hasClass("user-row")) { + element = element.parent(); + } + return element; + } + + function resort (col, order) { + var query = window.location.search.slice(1).split('&'); + // if col already present in args, remove it + var i = 0; + while (i < query.length) { + if (query[i] === 'sort=' + col) { + query.splice(i,1); + if (query[i] && query[i].substr(0, 6) === 'order=') { + query.splice(i,1); + } + } else { + i += 1; + } + } + // add new order to the front + if (order) { + query.unshift('order=' + order); + } + query.unshift('sort=' + col); + // reload page with new order + window.location = window.location.pathname + '?' + query.join('&'); + } + + $("th").map(function (i, th) { + th = $(th); + var col = th.data('sort'); + if (!col || col.length === 0) { + return; + } + var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc'; + th.find('a').click( + function () { + resort(col, order); + } + ); + }); + + $(".time-col").map(function (i, el) { + // convert ISO datestamps to nice momentjs ones + el = $(el); + el.text(moment(new Date(el.text())).fromNow()); + }); + + $(".stop-server").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + el.text("stopping..."); + api.stop_server(user, { + success: function () { + el.text('stop server').addClass('hidden'); + row.find('.access-server').addClass('hidden'); + row.find('.start-server').removeClass('hidden'); + } + }); + }); + + $(".access-server").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + var w = window.open(); + api.admin_access(user, { + async: false, + success: function () { + w.location = utils.url_path_join(prefix, 'user', user); + }, + error: function (xhr, err) { + w.close(); + console.error("Failed to gain access to server", err); + } + }); + }); + + $(".start-server").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + el.text("starting..."); + api.start_server(user, { + success: function () { + el.text('start server').addClass('hidden'); + row.find('.stop-server').removeClass('hidden'); + row.find('.access-server').removeClass('hidden'); + } + }); + }); + + $(".edit-user").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + var admin = row.data('admin'); + var dialog = $("#edit-user-dialog"); + dialog.data('user', user); + dialog.find(".username-input").val(user); + dialog.find(".admin-checkbox").attr("checked", admin==='True'); + dialog.modal(); + }); + + $("#edit-user-dialog").find(".save-button").click(function () { + var dialog = $("#edit-user-dialog"); + var user = dialog.data('user'); + var name = dialog.find(".username-input").val(); + var admin = dialog.find(".admin-checkbox").prop("checked"); + api.edit_user(user, { + admin: admin, + name: name + }, { + success: function () { + window.location.reload(); + } + }); + }); + + + $(".delete-user").click(function () { + var el = $(this); + var row = get_row(el); + var user = row.data('user'); + var dialog = $("#delete-user-dialog"); + dialog.find(".delete-username").text(user); + dialog.modal(); + }); + + $("#delete-user-dialog").find(".delete-button").click(function () { + var dialog = $("#delete-user-dialog"); + var username = dialog.find(".delete-username").text(); + console.log("deleting", username); + api.delete_user(username, { + success: function () { + window.location.reload(); + } + }); + }); + + $("#add-user").click(function () { + var dialog = $("#add-user-dialog"); + dialog.find(".username-input").val(''); + dialog.find(".admin-checkbox").prop("checked", false); + dialog.modal(); + }); + + $("#add-user-dialog").find(".save-button").click(function () { + var dialog = $("#add-user-dialog"); + var lines = dialog.find(".username-input").val().split('\n'); + var admin = dialog.find(".admin-checkbox").prop("checked"); + var usernames = []; + lines.map(function (line) { + var username = line.trim(); + if (username.length) { + usernames.push(username); + } + }); + + api.add_users(usernames, {admin: admin}, { + success: function () { + window.location.reload(); + } + }); + }); + + $("#shutdown-hub").click(function () { + var dialog = $("#shutdown-hub-dialog"); + dialog.find("input[type=checkbox]").prop("checked", true); + dialog.modal(); + }); + + $("#shutdown-hub-dialog").find(".shutdown-button").click(function () { + var dialog = $("#shutdown-hub-dialog"); + var servers = dialog.find(".shutdown-servers-checkbox").prop("checked"); + var proxy = dialog.find(".shutdown-proxy-checkbox").prop("checked"); + api.shutdown_hub({ + proxy: proxy, + servers: servers, + }); + }); + +}); diff --git a/share/static/js/home.js b/share/static/js/home.js new file mode 100644 index 0000000..cb9be3e --- /dev/null +++ b/share/static/js/home.js @@ -0,0 +1,19 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +require(["jquery", "jhapi"], function ($, JHAPI) { + "use strict"; + + var base_url = window.jhdata.base_url; + var user = window.jhdata.user; + var api = new JHAPI(base_url); + + $("#stop").click(function () { + api.stop_server(user, { + success: function () { + $("#stop").hide(); + } + }); + }); + +}); diff --git a/share/static/js/jhapi.js b/share/static/js/jhapi.js new file mode 100644 index 0000000..754460c --- /dev/null +++ b/share/static/js/jhapi.js @@ -0,0 +1,133 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +define(['jquery', 'utils'], function ($, utils) { + "use strict"; + + var JHAPI = function (base_url) { + this.base_url = base_url; + }; + + var default_options = { + type: 'GET', + contentType: "application/json", + cache: false, + dataType : "json", + processData: false, + success: null, + error: utils.ajax_error_dialog, + }; + + var update = function (d1, d2) { + $.map(d2, function (i, key) { + d1[key] = d2[key]; + }); + return d1; + }; + + var ajax_defaults = function (options) { + var d = {}; + update(d, default_options); + update(d, options); + return d; + }; + + JHAPI.prototype.api_request = function (path, options) { + options = options || {}; + options = ajax_defaults(options || {}); + var url = utils.url_path_join( + this.base_url, + 'api', + utils.encode_uri_components(path) + ); + $.ajax(url, options); + }; + + JHAPI.prototype.start_server = function (user, options) { + options = options || {}; + options = update(options, {type: 'POST', dataType: null}); + this.api_request( + utils.url_path_join('users', user, 'server'), + options + ); + }; + + JHAPI.prototype.stop_server = function (user, options) { + options = options || {}; + options = update(options, {type: 'DELETE', dataType: null}); + this.api_request( + utils.url_path_join('users', user, 'server'), + options + ); + }; + + JHAPI.prototype.list_users = function (options) { + this.api_request('users', options); + }; + + JHAPI.prototype.get_user = function (user, options) { + this.api_request( + utils.url_path_join('users', user), + options + ); + }; + + JHAPI.prototype.add_users = function (usernames, userinfo, options) { + options = options || {}; + var data = update(userinfo, {usernames: usernames}); + options = update(options, { + type: 'POST', + dataType: null, + data: JSON.stringify(data) + }); + + this.api_request('users', options); + }; + + JHAPI.prototype.edit_user = function (user, userinfo, options) { + options = options || {}; + options = update(options, { + type: 'PATCH', + dataType: null, + data: JSON.stringify(userinfo) + }); + + this.api_request( + utils.url_path_join('users', user), + options + ); + }; + + JHAPI.prototype.admin_access = function (user, options) { + options = options || {}; + options = update(options, { + type: 'POST', + dataType: null, + }); + + this.api_request( + utils.url_path_join('users', user, 'admin-access'), + options + ); + }; + + JHAPI.prototype.delete_user = function (user, options) { + options = options || {}; + options = update(options, {type: 'DELETE', dataType: null}); + this.api_request( + utils.url_path_join('users', user), + options + ); + }; + + JHAPI.prototype.shutdown_hub = function (data, options) { + options = options || {}; + options = update(options, {type: 'POST'}); + if (data) { + options.data = JSON.stringify(data); + } + this.api_request('shutdown', options); + }; + + return JHAPI; +}); \ No newline at end of file diff --git a/share/static/js/utils.js b/share/static/js/utils.js new file mode 100644 index 0000000..b20fb97 --- /dev/null +++ b/share/static/js/utils.js @@ -0,0 +1,137 @@ +// Based on IPython's base.js.utils +// Original Copyright (c) IPython Development Team. +// Distributed under the terms of the Modified BSD License. + +// Modifications Copyright (c) Juptyer Development Team. +// Distributed under the terms of the Modified BSD License. + +define(['jquery'], function($){ + "use strict"; + + var url_path_join = function () { + // join a sequence of url components with '/' + var url = ''; + for (var i = 0; i < arguments.length; i++) { + if (arguments[i] === '') { + continue; + } + if (url.length > 0 && url[url.length-1] != '/') { + url = url + '/' + arguments[i]; + } else { + url = url + arguments[i]; + } + } + url = url.replace(/\/\/+/, '/'); + return url; + }; + + var parse_url = function (url) { + // an `a` element with an href allows attr-access to the parsed segments of a URL + // a = parse_url("http://localhost:8888/path/name#hash") + // a.protocol = "http:" + // a.host = "localhost:8888" + // a.hostname = "localhost" + // a.port = 8888 + // a.pathname = "/path/name" + // a.hash = "#hash" + var a = document.createElement("a"); + a.href = url; + return a; + }; + + var encode_uri_components = function (uri) { + // encode just the components of a multi-segment uri, + // leaving '/' separators + return uri.split('/').map(encodeURIComponent).join('/'); + }; + + var url_join_encode = function () { + // join a sequence of url components with '/', + // encoding each component with encodeURIComponent + return encode_uri_components(url_path_join.apply(null, arguments)); + }; + + + var escape_html = function (text) { + // escape text to HTML + return $("
").text(text).html(); + }; + + var get_body_data = function(key) { + // get a url-encoded item from body.data and decode it + // we should never have any encoded URLs anywhere else in code + // until we are building an actual request + return decodeURIComponent($('body').data(key)); + }; + + + // http://stackoverflow.com/questions/2400935/browser-detection-in-javascript + var browser = (function() { + if (typeof navigator === 'undefined') { + // navigator undefined in node + return 'None'; + } + var N= navigator.appName, ua= navigator.userAgent, tem; + var M= ua.match(/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i); + if (M && (tem= ua.match(/version\/([\.\d]+)/i)) !== null) M[2]= tem[1]; + M= M? [M[1], M[2]]: [N, navigator.appVersion,'-?']; + return M; + })(); + + // http://stackoverflow.com/questions/11219582/how-to-detect-my-browser-version-and-operating-system-using-javascript + var platform = (function () { + if (typeof navigator === 'undefined') { + // navigator undefined in node + return 'None'; + } + var OSName="None"; + if (navigator.appVersion.indexOf("Win")!=-1) OSName="Windows"; + if (navigator.appVersion.indexOf("Mac")!=-1) OSName="MacOS"; + if (navigator.appVersion.indexOf("X11")!=-1) OSName="UNIX"; + if (navigator.appVersion.indexOf("Linux")!=-1) OSName="Linux"; + return OSName; + })(); + + var ajax_error_msg = function (jqXHR) { + // Return a JSON error message if there is one, + // otherwise the basic HTTP status text. + if (jqXHR.responseJSON && jqXHR.responseJSON.message) { + return jqXHR.responseJSON.message; + } else { + return jqXHR.statusText; + } + }; + + var log_ajax_error = function (jqXHR, status, error) { + // log ajax failures with informative messages + var msg = "API request failed (" + jqXHR.status + "): "; + console.log(jqXHR); + msg += ajax_error_msg(jqXHR); + console.log(msg); + return msg; + }; + + var ajax_error_dialog = function (jqXHR, status, error) { + console.log("ajax dialog", arguments); + var msg = log_ajax_error(jqXHR, status, error); + var dialog = $("#error-dialog"); + dialog.find(".ajax-error").text(msg); + dialog.modal(); + }; + + var utils = { + url_path_join : url_path_join, + url_join_encode : url_join_encode, + encode_uri_components : encode_uri_components, + escape_html : escape_html, + get_body_data : get_body_data, + parse_url : parse_url, + browser : browser, + platform: platform, + ajax_error_msg : ajax_error_msg, + log_ajax_error : log_ajax_error, + ajax_error_dialog : ajax_error_dialog, + }; + + return utils; +}); diff --git a/share/static/less/admin.less b/share/static/less/admin.less new file mode 100644 index 0000000..7283d91 --- /dev/null +++ b/share/static/less/admin.less @@ -0,0 +1,3 @@ +i.sort-icon { + margin-left: 4px; +} \ No newline at end of file diff --git a/share/static/less/error.less b/share/static/less/error.less new file mode 100644 index 0000000..ee9df73 --- /dev/null +++ b/share/static/less/error.less @@ -0,0 +1,21 @@ +div.error { + margin: 2em; + text-align: center; +} + +div.ajax-error { + padding: 1em; + text-align: center; + .alert-danger(); +} + +div.error > h1 { + font-size: 300%; + line-height: normal; +} + +div.error > p { + font-size: 200%; + line-height: normal; +} + diff --git a/share/static/less/login.less b/share/static/less/login.less new file mode 100644 index 0000000..85ae274 --- /dev/null +++ b/share/static/less/login.less @@ -0,0 +1,77 @@ +body { + font-family: "Helvetica Neue",Arial,sans-serif; + font-size: 14px; + color: #333; +} + + +.footer { + position: absolute; + bottom: 0; + width: 100%; + height: 60px; + background-color: #f5f5f5; + color: #777; +} + +.footer-inside { + margin: 20px 20px 20px 0; +} + +.footer .container { + margin: 20px 0; +} + +#login-main { + .service-login { + text-align: center; + display: block; + margin: 40px 0; + } + + + .masthead { + text-align: center; + margin: 0; + border-bottom: 1px solid #e5e5e5; + } + + .masthead h1 { + font-family: inherit; + font-size: 81px; + font-weight: 700; + letter-spacing: -1px; + color: @btn-primary-bg; + } + + .masthead p { + font-family: inherit; + font-size: 33px; + font-weight: 500; + } + + .btn-github { + color: #fff; + background-color: #474949; + boder-color: $474949; + } + + form { + display: block; + width: 350px; + padding: 30px 0; + margin-left: auto; + margin-right: auto; + } + + .input-group, input[type=text], button { + width: 100%; + } + + .login_error { + color: orangered; + font-weight: bold; + text-align: center; + } +} + diff --git a/share/static/less/page.less b/share/static/less/page.less new file mode 100644 index 0000000..e546acc --- /dev/null +++ b/share/static/less/page.less @@ -0,0 +1,26 @@ +.jpy-logo { + height: 28px; + margin-top: 6px; +} + +#header { + border-bottom: 1px solid #e7e7e7; + height: 40px; +} + +.hidden { + display: none; +} + +.dropdown.navbar-btn{ + padding:0 5px 0 0; +} + +#login_widget{ + + & .navbar-btn.btn-sm { + margin-top: 5px; + margin-bottom: 5px; + } + +} diff --git a/share/static/less/style.less b/share/static/less/style.less new file mode 100644 index 0000000..d74d7ef --- /dev/null +++ b/share/static/less/style.less @@ -0,0 +1,27 @@ +/*! +* +* 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 "./admin.less"; +@import "./error.less"; +@import "./login.less"; diff --git a/share/static/less/variables.less b/share/static/less/variables.less new file mode 100644 index 0000000..89b45e5 --- /dev/null +++ b/share/static/less/variables.less @@ -0,0 +1,11 @@ +@border-radius-small: 2px; +@border-radius-base: 2px; +@border-radius-large: 3px; +@navbar-height: 20px; + +@jupyter-orange: #F37524; +@jupyter-red: #E34F21; + +.btn-jupyter { + .button-variant(#fff; @jupyter-orange; @jupyter-red); +}