Skip to content

Commit 662ba90

Browse files
authored
Merge pull request #48 from shingo78/feature/multi-course-server
Add the multi-course server feature
2 parents 1587cf1 + 9cc3bbb commit 662ba90

9 files changed

Lines changed: 394 additions & 64 deletions

File tree

auth-proxy/resources/htdocs/php/lti/service.php

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
exit;
2222
}
2323

24-
$launch_data = $mail_address = $launch->get_launch_data();
24+
$launch_data = $launch->get_launch_data();
2525
$issuer = $launch_data['iss'];
2626
if (!isset($launch_data['email'])) {
2727
error_log("Could not receive email address: issuer=$issuer");
@@ -41,28 +41,57 @@
4141
$_SESSION['authtype'] = 'lti';
4242
$_SESSION['iss'] = $issuer;
4343

44-
$data = $custom = $launch->get_launch_data();
4544
$custom_key = 'https://purl.imsglobal.org/spec/lti/claim/custom';
4645
$notebook = null;
47-
if (isset($data[$custom_key])) {
48-
$custom = $data[$custom_key];
46+
$server_name = null;
47+
$login_params = array();
48+
if (isset($launch_data[$custom_key])) {
49+
$custom = $launch_data[$custom_key];
4950
if (isset($custom['notebook'])) {
5051
$notebook = $custom['notebook'];
5152
}
53+
if (isset($custom['course_server'])) {
54+
$login_params['course_server'] = $custom['course_server'];
55+
$server_name = $custom['course_server'];
56+
}
57+
if (isset($custom['course_image'])) {
58+
$login_params['course_image'] = $custom['course_image'];
59+
}
5260
if (isset($custom['logout-redirect-url'])) {
5361
$logout_redirect_url = $custom['logout-redirect-url'];
5462
$logout_redirect_url = filter_var($logout_redirect_url, FILTER_UNSAFE_RAW,
5563
FILTER_FLAG_ENCODE_HIGH | FILTER_FLAG_ENCODE_LOW);
5664
$_SESSION['logout-redirect-url'] = $logout_redirect_url;
5765
}
5866
}
59-
header("X-Accel-Redirect: /entrance/");
67+
$next = null;
6068
if ($notebook) {
61-
$notebook = rawurlencode($notebook);
62-
header("X-Reproxy-URL: ".HUB_URL.'/'.COURSE_NAME."/hub/login?next=/user-redirect/notebooks/".$notebook);
63-
} else {
64-
header("X-Reproxy-URL: ".HUB_URL.'/'.COURSE_NAME."/hub/login");
69+
if ($server_name) {
70+
$next = "/user/".$username."/".$server_name."/notebooks/".$notebook;
71+
} else {
72+
$next = "/user/".$username."/notebooks/".$notebook;
73+
}
74+
}
75+
if ($server_name) {
76+
$spawn_url = '/hub/spawn/'.$username.'/'.$server_name;
77+
if ($next) {
78+
$query = http_build_query(['next' => $next], '', null, PHP_QUERY_RFC3986);
79+
$next = $spawn_url.'?'.$query;
80+
} else {
81+
$next = $spawn_url;
82+
}
83+
}
84+
if ($next) {
85+
$login_params['next'] = $next;
86+
}
87+
88+
header("X-Accel-Redirect: /entrance/");
89+
$reproxy_url = HUB_URL.'/'.COURSE_NAME."/hub/login";
90+
$query = http_build_query($login_params, '', null, PHP_QUERY_RFC3986);
91+
if ($query) {
92+
$reproxy_url = $reproxy_url.'?'.$query;
6593
}
94+
header("X-Reproxy-URL: $reproxy_url");
6695
header("X-REMOTE-USER: $username");
6796
} else if ($launch->is_deep_link_launch()) {
6897
error_log('Deep linking launch type');

jupyterhub/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ RUN python3 -m pip wheel --wheel-dir wheelhouse --constraint requirements \
1919
git+https://github.com/cwaldbieser/jhub_remote_user_authenticator.git
2020

2121
# Spawner
22-
RUN mkdir /tmp/spawner
2322
ADD ./spawner /tmp/spawner/
23+
ADD ./cwh-authenticator /tmp/cwh-authenticator/
2424
ADD ./cwh-repo2docker /tmp/cwh-repo2docker/
2525
RUN python3 -m pip wheel --wheel-dir wheelhouse --constraint requirements \
2626
git+https://github.com/jupyterhub/dockerspawner.git \
2727
/tmp/spawner \
28+
/tmp/cwh-authenticator \
2829
/tmp/cwh-repo2docker
2930

3031
# DB
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from jupyterhub.handlers import LogoutHandler
2+
from jhub_remote_user_authenticator.remote_user_auth import RemoteUserLocalAuthenticator
3+
from jhub_remote_user_authenticator.remote_user_auth import RemoteUserLoginHandler
4+
5+
6+
class CoursewareHubLoginHandler(RemoteUserLoginHandler):
7+
8+
def get(self):
9+
course_server = self.get_query_argument('course_server', None)
10+
course_image = self.get_query_argument('course_image', None)
11+
12+
super().get()
13+
14+
user = self.current_user
15+
self.log.debug("course_server: %s, user=%s", course_server, user.name)
16+
self.log.debug("course_image: %s, user=%s", course_image, user.name)
17+
18+
if course_server:
19+
spawner = user.get_spawner(course_server, replace_failed=True)
20+
spawner.course_image = course_image
21+
22+
23+
class CoursewareHubLogoutHandler(LogoutHandler):
24+
25+
async def render_logout_page(self):
26+
self.redirect('/php/logout.php', permanent=False)
27+
28+
29+
class CoursewareHubRemoteUserLocalAuthenticator(RemoteUserLocalAuthenticator):
30+
31+
def get_handlers(self, app):
32+
return [
33+
(r'/login', CoursewareHubLoginHandler),
34+
(r'/logout', CoursewareHubLogoutHandler)
35+
]
36+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env python
2+
# coding: utf-8
3+
4+
from setuptools import setup, find_packages
5+
6+
setup_args = dict(
7+
name = 'cwh-authenticator',
8+
version = '0.1.0',
9+
platforms = "Linux",
10+
packages = find_packages(),
11+
include_package_data = False,
12+
install_requires = ['jhub_remote_user_authenticator']
13+
)
14+
15+
16+
def main():
17+
setup(**setup_args)
18+
19+
if __name__ == '__main__':
20+
main()
21+

jupyterhub/cwh-repo2docker/cwh_repo2docker/__init__.py

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import os
2+
import re
23
import sys
34

5+
from jupyterhub.spawner import Spawner
46
from coursewareuserspawner import CoursewareUserSpawner
57
from jinja2 import Environment, BaseLoader
6-
from traitlets import Unicode
8+
from traitlets import (
9+
List,
10+
Tuple,
11+
Unicode,
12+
)
713
from tornado import web
814

915
from .registry import get_registry, split_image_name
@@ -29,7 +35,7 @@ class Repo2DockerSpawner(CoursewareUserSpawner):
2935
{% for image in image_list %}
3036
<label for='image-item-{{ loop.index0 }}' class='form-control input-group'>
3137
<div class='col-md-1'>
32-
{% if image.default_course_image %}
38+
{% if image.selected %}
3339
<input type='radio' name='image' id='image-item-{{ loop.index0 }}' value='{{ registry_host }}/{{ image.image_name }}' checked/>
3440
{% else %}
3541
<input type='radio' name='image' id='image-item-{{ loop.index0 }}' value='{{ registry_host }}/{{ image.image_name }}' />
@@ -76,25 +82,87 @@ class Repo2DockerSpawner(CoursewareUserSpawner):
7682
""",
7783
)
7884

85+
notebook_dir = Unicode(
86+
'/home/{username}/{coursedir}',
87+
**Spawner.notebook_dir.metadata
88+
)
89+
90+
workdir = Unicode(
91+
'/home/{username}/{coursedir}',
92+
**CoursewareUserSpawner.workdir.metadata
93+
)
94+
95+
admin_home_mount_dirs = List(
96+
trait=Tuple(Unicode(), Unicode()),
97+
default_value=[
98+
('{coursedir}/admin_tools', 'admin_tools')
99+
],
100+
**CoursewareUserSpawner.admin_home_mount_dirs.metadata
101+
)
102+
103+
non_admin_home_mount_dirs = List(
104+
trait=Tuple(Unicode(), Unicode()),
105+
default_value=[
106+
('{coursedir}/tools', 'tools'),
107+
('{coursedir}/textbook', 'textbook/{coursedir}'),
108+
('{coursedir}/info', 'info/{coursedir}')
109+
],
110+
**CoursewareUserSpawner.non_admin_home_mount_dirs.metadata
111+
)
112+
79113
def __init__(self, *args, **kwargs):
114+
self._course_image = None
115+
80116
super().__init__(*args, **kwargs)
81117

82118
self._registry = get_registry(config=self.config)
83119

120+
@property
121+
def course_dir(self):
122+
course_dir = self.name
123+
course_dir = re.sub(r'[^\w\-_\.\(\)\+\[\]\{\}@]', '_', course_dir)
124+
return course_dir
125+
126+
@property
127+
def course_image(self):
128+
return self._course_image
129+
130+
@course_image.setter
131+
def course_image(self, value):
132+
self._course_image = value
133+
134+
def template_namespace(self):
135+
d = super().template_namespace()
136+
137+
d.update(dict(
138+
coursedir=self.course_dir
139+
))
140+
return d
141+
84142
async def get_options_form(self):
85143
"""
86144
Override the default form to handle the case when there is only one image.
87145
"""
88146
images = await self._registry.list_images()
147+
image_dict = {i['image_name']: i for i in images}
89148

90149
if not self.user.admin:
91-
self._use_default_course_image(images)
150+
if self.course_image and self.course_image in image_dict:
151+
self.image = self._registry.get_full_image_name(self.course_image)
152+
else:
153+
self._use_default_course_image(images)
92154
return ''
93155

94156
if len(images) <= 1:
95157
self._use_initial_course_image(images)
96158
return ''
97159

160+
for i in images:
161+
if self.course_image and self.course_image in image_dict:
162+
i['selected'] = (i['image_name'] == self.course_image)
163+
else:
164+
i['selected'] = i['default_course_image']
165+
98166
image_form_template = Environment(loader=BaseLoader).from_string(
99167
self.image_form_template
100168
)
@@ -145,6 +213,21 @@ async def _get_cmd_from_image(self):
145213
cmd = image_info["Config"]["Cmd"]
146214
return cmd
147215

216+
def get_args(self):
217+
args = super().get_args()
218+
username = self.user.name
219+
base_url = self.hub.base_url[:-4]
220+
xsrf_cookie_path = base_url + 'user/' + username + '/'
221+
# workaround when jupyterhub<=4.1.5 is used on single-user server
222+
# https://github.com/jupyterhub/jupyterhub/pull/4750
223+
# https://github.com/jupyterhub/jupyterhub/pull/4771
224+
args.append(
225+
'--ServerApp.tornado_settings='
226+
'{"xsrf_cookie_kwargs":{"path":"'
227+
+ xsrf_cookie_path + '"}}')
228+
229+
return args
230+
148231
async def get_command(self):
149232
image_cmd = await self._get_cmd_from_image()
150233
# override cmd for docker-stacks image
@@ -159,7 +242,84 @@ async def get_command(self):
159242

160243
return cmd + self.get_args()
161244

245+
def get_env(self):
246+
env = super().get_env()
247+
env.update(dict(
248+
CWH_COURSE_NAME=self.course_dir
249+
))
250+
return env
251+
252+
def _make_user_dirs(self):
253+
if self.course_dir:
254+
return
255+
256+
home_dir = os.path.join(self.users_dir, self.user.name)
257+
dirs = []
258+
if self.user.admin:
259+
dirs.extend([
260+
(os.path.join(home_dir, 'textbook'), 0o777),
261+
(os.path.join(home_dir, 'info'), 0o777)
262+
])
263+
264+
statinfo = os.stat(home_dir)
265+
for dirpath, mode in dirs:
266+
self._make_dir(dirpath, mode, statinfo.st_uid, statinfo.st_gid)
267+
268+
def _make_user_course_dirs(self):
269+
if not self.course_dir:
270+
return
271+
272+
content_dirs = [
273+
os.path.join(self.admin_data_dir, 'textbook', self.course_dir),
274+
os.path.join(self.admin_data_dir, 'info', self.course_dir)
275+
]
276+
277+
home_dir = os.path.join(self.users_dir, self.user.name)
278+
course_dirs = [
279+
(os.path.join(home_dir, self.course_dir), 0o755)
280+
]
281+
282+
if self.user.admin:
283+
course_dirs.extend([
284+
(os.path.join(home_dir, self.course_dir, 'textbook'), 0o777),
285+
(os.path.join(home_dir, self.course_dir, 'info'), 0o777)
286+
])
287+
288+
for dirpath in content_dirs:
289+
self._make_dir(dirpath, 0o777, 0, 0)
290+
else:
291+
if any([not os.path.exists(d) for d in content_dirs]):
292+
raise web.HTTPError(
293+
403,
294+
'You are not permitted to create a new course, "%s".',
295+
self.course_dir)
296+
297+
statinfo = os.stat(home_dir)
298+
for dirpath, mode in course_dirs:
299+
self._make_dir(dirpath, mode, statinfo.st_uid, statinfo.st_gid)
300+
301+
def _make_dir(self, dirpath, mode, uid, gid):
302+
try:
303+
os.mkdir(dirpath, mode)
304+
except FileExistsError:
305+
os.chmod(dirpath, mode)
306+
os.chown(dirpath, uid, gid)
307+
162308
async def create_object(self, *args, **kwargs):
309+
server_name = self.name
310+
course_dir = self.course_dir
311+
notebook_dir = self.format_string(self.notebook_dir)
312+
workdir = self.format_string(self.workdir)
313+
self.log.debug(
314+
f"create_object: server_name='{server_name}'"
315+
f" course_dir='{course_dir}'"
316+
f" notebook_dir={notebook_dir}"
317+
f" workdir={workdir}"
318+
f" image='{self.image}'")
319+
320+
self._make_user_dirs()
321+
self._make_user_course_dirs()
322+
163323
self.docker(
164324
'login',
165325
username=self._registry.username,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% extends "templates/error.html" %}
2+
3+
{% block error_detail %}
4+
{% if exception %}{% if exception.log_message %}
5+
{% if exception.log_message | format(*exception.args) != message %}
6+
<p>{{ exception.log_message | format(*exception.args) | safe }}</p>
7+
{% endif %}
8+
{% endif %}{% endif %}
9+
{{ super() }}
10+
{% endblock %}

jupyterhub/cwh-repo2docker/cwh_repo2docker/registry.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ def get_initial_course_image(self) -> str:
256256
name = self.initial_course_image
257257
return f'{host}/{name}'
258258

259+
def get_full_image_name(self, name: str) -> str:
260+
host = self.host
261+
return f'{host}/{name}'
262+
259263
async def list_images(self) -> List[Dict]:
260264
async with aiohttp.ClientSession(
261265
auth=self._get_auth(),

0 commit comments

Comments
 (0)