handlers.py 14 KB

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