diff --git a/.gitignore b/.gitignore index 82ec99b6..317d8064 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ __pycache__ .#* .coverage .cache +.pytest_cache diff --git a/jupyterlab_launcher/handlers.py b/jupyterlab_launcher/handlers.py index 93d0193a..fc70b820 100644 --- a/jupyterlab_launcher/handlers.py +++ b/jupyterlab_launcher/handlers.py @@ -142,6 +142,9 @@ class LabConfig(HasTraits): 'schemas directory. If given, a handler will ' 'be added for settings.')) + workspaces_api_url = Unicode(default_workspaces_api_url, + help='The url path of the workspaces API.') + workspaces_dir = Unicode('', help=('The optional location of the saved ' 'workspaces directory. If given, a handler ' @@ -185,11 +188,11 @@ def add_handlers(web_app, config): # Set up the main page handler and tree handler. base_url = web_app.settings['base_url'] - lab_url = ujoin(base_url, config.page_url) - tree_url = ujoin(base_url, config.tree_url + r'.+') + lab_path = ujoin(base_url, config.page_url) + tree_path = ujoin(base_url, config.tree_url, r'.+') handlers = [ - (lab_url, LabHandler, {'lab_config': config}), - (tree_url, LabHandler, {'lab_config': config}) + (lab_path, LabHandler, {'lab_config': config}), + (tree_path, LabHandler, {'lab_config': config}) ] # Cache all or none of the files depending on the `cache_files` setting. @@ -197,16 +200,18 @@ def add_handlers(web_app, config): # Handle local static assets. if config.static_dir: - config.public_url = ujoin(base_url, default_public_url) - handlers.append((config.public_url + '(.*)', FileFindHandler, { + public_path = ujoin(base_url, config.public_url + '(.*)') + handlers.append((public_path, FileFindHandler, { 'path': config.static_dir, 'no_cache_paths': no_cache_paths })) # Handle local settings. if config.schemas_dir: - config.settings_url = ujoin(base_url, default_settings_url) - settings_path = config.settings_url + '(?P.+)' + settings_path = ujoin( + base_url, + config.settings_url + '(?P.+)' + ) handlers.append((settings_path, SettingsHandler, { 'app_settings_dir': config.app_settings_dir, 'schemas_dir': config.schemas_dir, @@ -216,22 +221,21 @@ def add_handlers(web_app, config): # Handle saved workspaces. if config.workspaces_dir: # Handle JupyterLab client URLs that include workspaces. - config.workspaces_url = ujoin(base_url, default_workspaces_url) - workspaces_path = ujoin(config.workspaces_url, r'/.+') + workspaces_path = ujoin(base_url, config.workspaces_url, r'/.+') handlers.append((workspaces_path, LabHandler, {'lab_config': config})) - # Handle API requests for workspaces. - config.workspaces_api_url = ujoin(base_url, default_workspaces_api_url) - # Handle requests for the list of workspaces. Make slash optional. - workspaces_api_path = config.workspaces_api_url + '?' + workspaces_api_path = ujoin(base_url, config.workspaces_api_url + '?') handlers.append((workspaces_api_path, WorkspacesHandler, { 'workspaces_url': config.workspaces_url, 'path': config.workspaces_dir })) # Handle requests for an individually named workspace. - workspace_api_path = config.workspaces_api_url + '(?P.+)' + workspace_api_path = ujoin( + base_url, + config.workspaces_api_url + '(?P.+)' + ) handlers.append((workspace_api_path, WorkspacesHandler, { 'workspaces_url': config.workspaces_url, 'path': config.workspaces_dir @@ -239,9 +243,9 @@ def add_handlers(web_app, config): # Handle local themes. if config.themes_dir: - config.themes_url = ujoin(base_url, default_themes_url) + themes_path = ujoin(base_url, config.themes_url, '(.*)') handlers.append(( - ujoin(config.themes_url, '(.*)'), + themes_path, ThemesHandler, { 'themes_url': config.themes_url, diff --git a/jupyterlab_launcher/tests/test_workspaces_api.py b/jupyterlab_launcher/tests/test_workspaces_api.py index bf1cf817..d3ffe282 100644 --- a/jupyterlab_launcher/tests/test_workspaces_api.py +++ b/jupyterlab_launcher/tests/test_workspaces_api.py @@ -4,6 +4,7 @@ import shutil from jupyterlab_launcher.tests.utils import LabTestBase, APITester +from notebook.tests.launchnotebook import assert_http_error class WorkspacesAPI(APITester): @@ -37,11 +38,10 @@ def setUp(self): self.workspaces_api = WorkspacesAPI(self.request) def test_delete(self): - orig = 'foo' + orig = 'f/o/o/' copy = 'baz' data = self.workspaces_api.get(orig).json() data['metadata']['id'] = copy - assert self.workspaces_api.put(copy, data).status_code == 204 assert self.workspaces_api.delete(copy).status_code == 204 @@ -51,7 +51,7 @@ def test_get(self): assert self.workspaces_api.get(id).json()['metadata']['id'] == id def test_listing(self): - listing = set(['foo', 'bar']) + listing = set(['foo', 'f/o/o/']) assert set(self.workspaces_api.get().json()['workspaces']) == listing @@ -60,3 +60,11 @@ def test_put(self): data = self.workspaces_api.get(id).json() assert self.workspaces_api.put(id, data).status_code == 204 + + def test_bad_put(self): + orig = 'foo' + copy = 'bar' + data = self.workspaces_api.get(orig).json() + + with assert_http_error(400): + self.workspaces_api.put(copy, data) diff --git a/jupyterlab_launcher/tests/workspaces/abfoo-19e2.jupyterlab-workspace b/jupyterlab_launcher/tests/workspaces/abfoo-19e2.jupyterlab-workspace new file mode 100644 index 00000000..e9d9e1f0 --- /dev/null +++ b/jupyterlab_launcher/tests/workspaces/abfoo-19e2.jupyterlab-workspace @@ -0,0 +1 @@ +{"data": {}, "metadata": {"id": "f/o/o/"}} diff --git a/jupyterlab_launcher/tests/workspaces/foo.jupyterlab-workspace b/jupyterlab_launcher/tests/workspaces/abfoo-2c26.jupyterlab-workspace similarity index 100% rename from jupyterlab_launcher/tests/workspaces/foo.jupyterlab-workspace rename to jupyterlab_launcher/tests/workspaces/abfoo-2c26.jupyterlab-workspace diff --git a/jupyterlab_launcher/tests/workspaces/bar.jupyterlab-workspace b/jupyterlab_launcher/tests/workspaces/bar.jupyterlab-workspace deleted file mode 100644 index 1252c5ac..00000000 --- a/jupyterlab_launcher/tests/workspaces/bar.jupyterlab-workspace +++ /dev/null @@ -1 +0,0 @@ -{"data": {}, "metadata": {"id": "bar"}} diff --git a/jupyterlab_launcher/workspaces_handler.py b/jupyterlab_launcher/workspaces_handler.py index 4ea0acf5..dfe5b03d 100644 --- a/jupyterlab_launcher/workspaces_handler.py +++ b/jupyterlab_launcher/workspaces_handler.py @@ -2,22 +2,96 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import hashlib import json import os -from tornado import web +import re +import sys +import unicodedata +import urllib from notebook.base.handlers import APIHandler, json_errors +from notebook.utils import url_path_join as ujoin +from tornado import web + + +PY2 = sys.version_info[0] < 3 + +# A cache of workspace names and their slug file name counterparts. +_cache = dict() +# The JupyterLab workspace file extension. _file_extension = '.jupyterlab-workspace' +def _list_workspaces(directory, prefix): + workspaces = [] + if not os.path.exists(directory): + return workspaces + + items = [item + for item in os.listdir(directory) + if item.startswith(prefix) and + item.endswith(_file_extension)] + items.sort() + + for slug in items: + if slug in _cache: + workspaces.append(_cache[slug]) + continue + workspace_path = os.path.join(directory, slug) + if os.path.exists(workspace_path): + with open(workspace_path) as fid: + try: # to load and parse the workspace file. + _cache[slug] = json.load(fid)['metadata']['id'] + workspaces.append(_cache[slug]) + except Exception as e: + raise web.HTTPError(500, str(e)) + return workspaces + + +def _slug(raw, base, sign=True, max_length=128 - len(_file_extension)): + """ + Use the common superset of raw and base values to build a slug shorter + than max_length. + Convert spaces to hyphens. Remove characters that aren't alphanumerics + underscores, or hyphens. Convert to lowercase. Strip leading and trailing + whitespace. + Add an optional short signature suffix to prevent collisions. + Modified from Django utils: + https://github.com/django/django/blob/master/django/utils/text.py + """ + signature = '' + if sign: + signature = '-' + hashlib.sha256(raw.encode('utf-8')).hexdigest()[:4] + base = (base if base.startswith('/') else '/' + base).lower() + raw = (raw if raw.startswith('/') else '/' + raw).lower() + common = 0 + limit = min(len(base), len(raw)) + while common < limit and base[common] == raw[common]: + common += 1 + value = ujoin(base[common:], raw) + value = urllib.unquote(value) if PY2 else urllib.parse.unquote(value) + value = (unicodedata + .normalize('NFKC', value) + .encode('ascii', 'ignore') + .decode('ascii')) + value = re.sub(r'[^\w\s-]', '', value).strip() + value = re.sub(r'[-\s]+', '-', value) + if signature: + return value[:max_length - len(signature)] + signature + else: + return value[:max_length] + + class WorkspacesHandler(APIHandler): - def initialize(self, path, default_filename=None, workspaces_url=None): + def initialize(self, path, workspaces_url=None): self.workspaces_dir = path def ensure_directory(self): + """Return the workspaces directory if set or raise error if not set.""" if not self.workspaces_dir: raise web.HTTPError(500, 'Workspaces directory is not set') @@ -26,14 +100,18 @@ def ensure_directory(self): @json_errors @web.authenticated def delete(self, space_name): + base_url = self.base_url directory = self.ensure_directory() if not space_name: raise web.HTTPError(400, 'Workspace name is required for DELETE') - workspace_path = os.path.join(directory, space_name + _file_extension) + slug = _slug(space_name, base_url) + workspace_path = os.path.join(directory, slug + _file_extension) + if not os.path.exists(workspace_path): - raise web.HTTPError(404, 'Workspace %r not found' % space_name) + raise web.HTTPError(404, 'Workspace %r (%r) not found' % + (space_name, slug)) try: # to delete the workspace file. os.remove(workspace_path) @@ -44,23 +122,17 @@ def delete(self, space_name): @json_errors @web.authenticated def get(self, space_name=''): + base_url = self.base_url directory = self.ensure_directory() if not space_name: - if not os.path.exists(directory): - return self.finish(json.dumps(dict(workspaces=[]))) + prefix = _slug('', base_url, sign=False) + workspaces = _list_workspaces(directory, prefix) + return self.finish(json.dumps(dict(workspaces=workspaces))) - try: # to read the contents of the workspaces directory. - items = [item[:-len(_file_extension)] - for item in os.listdir(directory) - if item.endswith(_file_extension)] - items.sort() + slug = _slug(space_name, base_url) + workspace_path = os.path.join(directory, slug + _file_extension) - return self.finish(json.dumps(dict(workspaces=items))) - except Exception as e: - raise web.HTTPError(500, str(e)) - - workspace_path = os.path.join(directory, space_name + _file_extension) if os.path.exists(workspace_path): with open(workspace_path) as fid: try: # to load and parse the workspace file. @@ -68,11 +140,13 @@ def get(self, space_name=''): except Exception as e: raise web.HTTPError(500, str(e)) else: - raise web.HTTPError(404, 'Workspace %r not found' % space_name) + raise web.HTTPError(404, 'Workspace %r (%r) not found' % + (space_name, slug)) @json_errors @web.authenticated def put(self, space_name): + base_url = self.base_url directory = self.ensure_directory() if not os.path.exists(directory): @@ -92,14 +166,19 @@ def put(self, space_name): raise web.HTTPError(400, str(e)) # Make sure metadata ID matches the workspace name. + # Transparently support an optional inital root `/`. metadata_id = workspace['metadata']['id'] - if metadata_id != space_name: + metadata_id = (metadata_id if metadata_id.startswith('/') + else '/' + metadata_id) + if metadata_id != '/' + space_name: message = ('Workspace metadata ID mismatch: expected %r got %r' % (space_name, metadata_id)) raise web.HTTPError(400, message) + slug = _slug(space_name, base_url) + workspace_path = os.path.join(directory, slug + _file_extension) + # Write the workspace data to a file. - workspace_path = os.path.join(directory, space_name + _file_extension) with open(workspace_path, 'w') as fid: fid.write(raw) diff --git a/setup.py b/setup.py index 7626b3da..e1494c29 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ } setup_args['install_requires'] = [ 'jsonschema>=2.6.0', - 'notebook>=4.2.0', + 'notebook>=4.2.0' ] if __name__ == '__main__':