1# coding: utf-8
2"""JupyterLab Server handlers"""
3
4# Copyright (c) Jupyter Development Team.
5# Distributed under the terms of the Modified BSD License.
6import os
7from urllib.parse import urlparse
8
9from tornado import template, web
10
11from jupyter_server.extension.handler import ExtensionHandlerMixin, ExtensionHandlerJinjaMixin
12
13from .config import LabConfig, get_page_config, recursive_update
14from .listings_handler import ListingsHandler, fetch_listings
15from .server import FileFindHandler, JupyterHandler
16from .server import url_path_join as ujoin
17from .settings_handler import SettingsHandler
18from .themes_handler import ThemesHandler
19from .translations_handler import TranslationsHandler
20from .workspaces_handler import WorkspacesHandler
21from .licenses_handler import LicensesHandler, LicensesManager
22
23# -----------------------------------------------------------------------------
24# Module globals
25# -----------------------------------------------------------------------------
26
27MASTER_URL_PATTERN = '/(?P<mode>{}|doc)(?P<workspace>/workspaces/[a-zA-Z0-9\-\_]+)?(?P<tree>/tree/.*)?'
28
29DEFAULT_TEMPLATE = template.Template("""
30<!DOCTYPE html>
31<html>
32<head>
33  <meta charset="utf-8">
34  <title>Error</title>
35</head>
36<body>
37<h2>Cannot find template: "{{name}}"</h2>
38<p>In "{{path}}"</p>
39</body>
40</html>
41""")
42
43
44def is_url(url):
45  """Test whether a string is a full url (e.g. https://nasa.gov)
46
47  https://stackoverflow.com/a/52455972
48  """
49  try:
50    result = urlparse(url)
51    return all([result.scheme, result.netloc])
52  except ValueError:
53    return False
54
55
56
57class LabHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler):
58    """Render the JupyterLab View."""
59
60    @web.authenticated
61    @web.removeslash
62    def get(self, mode = None, workspace = None, tree = None):
63        """Get the JupyterLab html page."""
64        workspace = 'default' if workspace is None else workspace.replace('/workspaces/','')
65        tree_path = '' if tree is None else tree.replace('/tree/','')
66
67        self.application.store_id = getattr(self.application, 'store_id', 0)
68        config = LabConfig()
69        app = self.extensionapp
70        settings_dir = app.app_settings_dir
71
72        # Handle page config data.
73        page_config = self.settings.setdefault('page_config_data', {})
74        terminals = self.settings.get('terminals_available', False)
75        server_root = self.settings.get('server_root_dir', '')
76        server_root = server_root.replace(os.sep, '/')
77        base_url = self.settings.get('base_url')
78
79        # Remove the trailing slash for compatibiity with html-webpack-plugin.
80        full_static_url = self.static_url_prefix.rstrip('/')
81        page_config.setdefault('fullStaticUrl', full_static_url)
82
83        page_config.setdefault('terminalsAvailable', terminals)
84        page_config.setdefault('ignorePlugins', [])
85        page_config.setdefault('serverRoot', server_root)
86        page_config['store_id'] = self.application.store_id
87
88        server_root = os.path.normpath(os.path.expanduser(server_root))
89        try:
90            page_config['preferredDir'] = self.serverapp.preferred_dir
91            page_config['preferredPath'] = self.serverapp.preferred_dir.replace(server_root, "")
92        except Exception:
93            page_config['preferredDir'] = server_root
94            page_config['preferredPath'] = '/'
95
96        self.application.store_id += 1
97
98        mathjax_config = self.settings.get('mathjax_config',
99                                           'TeX-AMS_HTML-full,Safe')
100        # TODO Remove CDN usage.
101        mathjax_url = self.mathjax_url
102        if not mathjax_url:
103            mathjax_url = 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js'
104
105        page_config.setdefault('mathjaxConfig', mathjax_config)
106        page_config.setdefault('fullMathjaxUrl', mathjax_url)
107
108        # Add parameters parsed from the URL
109        if mode == 'doc':
110            page_config['mode'] = 'single-document'
111        else:
112            page_config['mode'] = 'multiple-document'
113        page_config['workspace'] = workspace
114        page_config['treePath'] = tree_path
115
116        # Put all our config in page_config
117        for name in config.trait_names():
118            page_config[_camelCase(name)] = getattr(app, name)
119
120        # Add full versions of all the urls
121        for name in config.trait_names():
122            if not name.endswith('_url'):
123                continue
124            full_name = _camelCase('full_' + name)
125            full_url = getattr(app, name)
126            if not is_url(full_url):
127                # Relative URL will be prefixed with base_url
128                full_url = ujoin(base_url, full_url)
129            page_config[full_name] = full_url
130
131        # Update the page config with the data from disk
132        labextensions_path = app.extra_labextensions_path + app.labextensions_path
133        recursive_update(page_config, get_page_config(labextensions_path, settings_dir, logger=self.log))
134
135        # Write the template with the config.
136        tpl = self.render_template('index.html', page_config=page_config)
137        self.write(tpl)
138
139
140class NotFoundHandler(LabHandler):
141    def render_template(self, name, **ns):
142        if 'page_config' in ns:
143            ns['page_config'] = ns['page_config'].copy()
144            ns['page_config']['notFoundUrl'] = self.request.path
145        return super().render_template(name, **ns)
146
147
148def add_handlers(handlers, extension_app):
149    """Add the appropriate handlers to the web app.
150    """
151    # Normalize directories.
152    for name in LabConfig.class_trait_names():
153        if not name.endswith('_dir'):
154            continue
155        value = getattr(extension_app, name)
156        setattr(extension_app, name, value.replace(os.sep, '/'))
157
158    # Normalize urls
159    # Local urls should have a leading slash but no trailing slash
160    for name in LabConfig.class_trait_names():
161        if not name.endswith('_url'):
162            continue
163        value = getattr(extension_app, name)
164        if is_url(value):
165            continue
166        if not value.startswith('/'):
167            value = '/' + value
168        if value.endswith('/'):
169            value = value[:-1]
170        setattr(extension_app, name, value)
171
172    url_pattern = MASTER_URL_PATTERN.format(extension_app.app_url.replace('/', ''))
173    handlers.append((url_pattern, LabHandler))
174
175    # Cache all or none of the files depending on the `cache_files` setting.
176    no_cache_paths = [] if extension_app.cache_files else ['/']
177
178    # Handle federated lab extensions.
179    labextensions_path = extension_app.extra_labextensions_path + extension_app.labextensions_path
180    labextensions_url = ujoin(extension_app.labextensions_url, "(.*)")
181    handlers.append(
182        (labextensions_url, FileFindHandler, {
183            'path': labextensions_path,
184            'no_cache_paths': no_cache_paths
185        }))
186
187    # Handle local settings.
188    if extension_app.schemas_dir:
189        settings_config = {
190            'app_settings_dir': extension_app.app_settings_dir,
191            'schemas_dir': extension_app.schemas_dir,
192            'settings_dir': extension_app.user_settings_dir,
193            'labextensions_path': labextensions_path
194        }
195
196        # Handle requests for the list of settings. Make slash optional.
197        settings_path = ujoin(extension_app.settings_url, '?')
198        handlers.append((settings_path, SettingsHandler, settings_config))
199
200        # Handle requests for an individual set of settings.
201        setting_path = ujoin(extension_app.settings_url, '(?P<schema_name>.+)')
202        handlers.append((setting_path, SettingsHandler, settings_config))
203
204        # Handle translations.
205        ## Translations requires settings as the locale source of truth is stored in it
206        if extension_app.translations_api_url:
207            # Handle requests for the list of language packs available.
208            # Make slash optional.
209            translations_path = ujoin(extension_app.translations_api_url, '?')
210            handlers.append((translations_path, TranslationsHandler, settings_config))
211
212            # Handle requests for an individual language pack.
213            translations_lang_path = ujoin(
214                extension_app.translations_api_url, '(?P<locale>.*)')
215            handlers.append((translations_lang_path, TranslationsHandler, settings_config))
216
217    # Handle saved workspaces.
218    if extension_app.workspaces_dir:
219
220        workspaces_config = {
221            'path': extension_app.workspaces_dir
222        }
223
224        # Handle requests for the list of workspaces. Make slash optional.
225        workspaces_api_path = ujoin(extension_app.workspaces_api_url, '?')
226        handlers.append((
227            workspaces_api_path, WorkspacesHandler, workspaces_config))
228
229        # Handle requests for an individually named workspace.
230        workspace_api_path = ujoin(extension_app.workspaces_api_url, '(?P<space_name>.+)')
231        handlers.append((
232            workspace_api_path, WorkspacesHandler, workspaces_config))
233
234    # Handle local listings.
235
236    settings_config = extension_app.settings.get('config', {}).get('LabServerApp', {})
237    blocked_extensions_uris = settings_config.get('blocked_extensions_uris', '')
238    allowed_extensions_uris = settings_config.get('allowed_extensions_uris', '')
239
240    if (blocked_extensions_uris) and (allowed_extensions_uris):
241        print('Simultaneous blocked_extensions_uris and allowed_extensions_uris is not supported. Please define only one of those.')
242        import sys
243        sys.exit(-1)
244
245    ListingsHandler.listings_refresh_seconds = settings_config.get('listings_refresh_seconds', 60 * 60)
246    ListingsHandler.listings_request_opts = settings_config.get('listings_request_options', {})
247    listings_url = ujoin(extension_app.listings_url)
248    listings_path = ujoin(listings_url, '(.*)')
249
250    if blocked_extensions_uris:
251        ListingsHandler.blocked_extensions_uris = set(blocked_extensions_uris.split(','))
252    if allowed_extensions_uris:
253        ListingsHandler.allowed_extensions_uris = set(allowed_extensions_uris.split(','))
254
255    fetch_listings(None)
256
257    if len(ListingsHandler.blocked_extensions_uris) > 0 or len(ListingsHandler.allowed_extensions_uris) > 0:
258        from tornado import ioloop
259        ListingsHandler.pc = ioloop.PeriodicCallback(
260            lambda: fetch_listings(None),
261            callback_time=ListingsHandler.listings_refresh_seconds * 1000,
262            jitter=0.1
263            )
264        ListingsHandler.pc.start()
265
266    handlers.append((listings_path, ListingsHandler, {}))
267
268    # Handle local themes.
269    if extension_app.themes_dir:
270        themes_url = extension_app.themes_url
271        themes_path = ujoin(themes_url, '(.*)')
272        handlers.append((
273            themes_path,
274            ThemesHandler,
275            {
276                'themes_url': themes_url,
277                'path': extension_app.themes_dir,
278                'labextensions_path': labextensions_path,
279                'no_cache_paths': no_cache_paths
280            }
281        ))
282
283    # Handle licenses.
284    if extension_app.licenses_url:
285        licenses_url = extension_app.licenses_url
286        licenses_path = ujoin(licenses_url, '(.*)')
287        handlers.append((
288            licenses_path,
289            LicensesHandler,
290            {
291                'manager': LicensesManager(parent=extension_app)
292            }
293        ))
294
295    # Let the lab handler act as the fallthrough option instead of a 404.
296    fallthrough_url = ujoin(extension_app.app_url, r'.*')
297    handlers.append((fallthrough_url, NotFoundHandler))
298
299
300def _camelCase(base):
301    """Convert a string to camelCase.
302    https://stackoverflow.com/a/20744956
303    """
304    output = ''.join(x for x in base.title() if x.isalpha())
305    return output[0].lower() + output[1:]
306