diff --git a/deepnote_core/resources/jupyter/jupyter_server_config.py b/deepnote_core/resources/jupyter/jupyter_server_config.py index 9e9641bd..fa92d431 100644 --- a/deepnote_core/resources/jupyter/jupyter_server_config.py +++ b/deepnote_core/resources/jupyter/jupyter_server_config.py @@ -459,11 +459,13 @@ # Default: {} # c.ServerApp.nbserver_extensions = {} -## Enable jupyter_server_terminals extension for terminal support -# and jupyter_resource_usage for resource usage tracking through API +## Enable jupyter_server_terminals extension for terminal support, +# jupyter_resource_usage for resource usage tracking through API +# and deepnote_core.widget_assets to serve widget files c.ServerApp.jpserver_extensions = { "jupyter_server_terminals": True, "jupyter_resource_usage": True, + "deepnote_core.widget_assets": True, } ## The directory to use for notebooks and kernels. diff --git a/deepnote_core/widget_assets.py b/deepnote_core/widget_assets.py new file mode 100644 index 00000000..75965847 --- /dev/null +++ b/deepnote_core/widget_assets.py @@ -0,0 +1,128 @@ +"""Jupyter Server extension that serves widget files. + +``GET /deepnote/widget-assets//`` returns a file installed in the +project's environment. Requests need the Jupyter token. + +Where each kind is looked up: + +- ``labextensions``: ``/`` in an installed JupyterLab extension. + Scoped modules take two segments, like ``@bokeh/jupyter_bokeh``. +- ``bokeh``: the ``server/static/js`` folder of the bokeh package. +- ``panel``: the ``dist`` folder of the panel package. + +Only ``.js``, ``.css`` and ``.json`` files are served. Packages are found +without importing them, so no user code runs in the server. +""" + +import importlib.util +import re +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +from jupyter_core.paths import jupyter_path +from jupyter_server.base.handlers import APIHandler +from jupyter_server.utils import url_path_join +from tornado import web + +if TYPE_CHECKING: + from jupyter_server.serverapp import ServerApp + +CONTENT_TYPES: Dict[str, str] = { + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", +} + +# One path segment: letters, digits and "_.@-", not starting with a dot +_PATH_SEGMENT = re.compile(r"[A-Za-z0-9_@-][A-Za-z0-9_.@-]*") + +_PACKAGE_STATIC_DIRS: Dict[str, List[str]] = { + "bokeh": ["server", "static", "js"], + "panel": ["dist"], +} + + +def find_widget_asset(kind: str, path: str) -> Optional[Path]: + """Find the file a widget asset request asks for. + + Args: + kind: ``labextensions``, ``bokeh`` or ``panel``. + path: The ``/``-separated path after the kind. + + Returns: + The file's real path, or None if the path isn't allowed or the file + is missing. + """ + segments = path.split("/") + if not all(_PATH_SEGMENT.fullmatch(segment) for segment in segments): + return None + + if kind == "labextensions": + return _find_labextension_file(segments) + + if kind not in _PACKAGE_STATIC_DIRS: + return None + package_dir = _find_package_dir(kind) + if package_dir is None: + return None + return _resolve_inside(package_dir.joinpath(*_PACKAGE_STATIC_DIRS[kind]), segments) + + +def _find_labextension_file(segments: List[str]) -> Optional[Path]: + """Find a file in the first installed copy of a JupyterLab extension.""" + module_length = 2 if segments[0].startswith("@") else 1 + module, file_segments = segments[:module_length], segments[module_length:] + if not file_segments: + return None + + roots = [Path(root) for root in jupyter_path("labextensions")] + roots.append(Path(sys.base_prefix, "share", "jupyter", "labextensions")) + for root in dict.fromkeys(roots): + module_dir = root.joinpath(*module) + if module_dir.joinpath("package.json").is_file(): + return _resolve_inside(module_dir, file_segments) + return None + + +def _find_package_dir(name: str) -> Optional[Path]: + """Return the folder of an installed package without importing it.""" + try: + spec = importlib.util.find_spec(name) + except (ImportError, ValueError): + return None + if spec is None or not spec.submodule_search_locations: + return None + return Path(list(spec.submodule_search_locations)[0]) + + +def _resolve_inside(root: Path, segments: List[str]) -> Optional[Path]: + """Return the real path of a servable file inside ``root``, or None.""" + try: + real_root = root.resolve(strict=True) + candidate = real_root.joinpath(*segments).resolve(strict=True) + except (OSError, RuntimeError): + return None + if not candidate.is_relative_to(real_root) or not candidate.is_file(): + return None + if candidate.suffix not in CONTENT_TYPES: + return None + return candidate + + +class WidgetAssetHandler(APIHandler): + """Serves widget files to requests that carry the Jupyter token.""" + + @web.authenticated + def get(self, kind: str, path: str) -> None: + """Send the requested file, or 404 when there isn't one.""" + asset = find_widget_asset(kind, path) + if asset is None: + raise web.HTTPError(404) + self.finish(asset.read_bytes(), set_content_type=CONTENT_TYPES[asset.suffix]) + + +def _load_jupyter_server_extension(serverapp: "ServerApp") -> None: + """Add the widget asset route when Jupyter Server starts.""" + route = url_path_join(serverapp.base_url, "deepnote/widget-assets/([a-z]+)/(.+)") + serverapp.web_app.add_handlers(".*$", [(route, WidgetAssetHandler)]) diff --git a/tests/unit/test_widget_assets.py b/tests/unit/test_widget_assets.py new file mode 100644 index 00000000..bbba3d5f --- /dev/null +++ b/tests/unit/test_widget_assets.py @@ -0,0 +1,310 @@ +"""Tests for the widget asset Jupyter Server extension.""" + +import os +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Dict + +import pytest + +pytest.importorskip("jupyter_server") + +import tornado.web # noqa: E402 +from jupyter_server.auth import IdentityProvider # noqa: E402 +from tornado.httpclient import HTTPResponse # noqa: E402 +from tornado.testing import AsyncHTTPTestCase # noqa: E402 +from traitlets.config.loader import PyFileConfigLoader # noqa: E402 + +from deepnote_core.widget_assets import ( # noqa: E402 + _load_jupyter_server_extension, + find_widget_asset, +) + + +def make_labextension(data_dir: Path, module: str, files: Dict[str, str]) -> Path: + """Create a labextension folder with package.json and the given files.""" + module_dir = data_dir.joinpath("labextensions", *module.split("/")) + module_dir.mkdir(parents=True) + (module_dir / "package.json").write_text("{}") + for name, content in files.items(): + file = module_dir.joinpath(*name.split("/")) + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(content) + return module_dir + + +@pytest.fixture +def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A temporary Jupyter data folder that is searched first.""" + path = tmp_path / "data" + path.mkdir() + monkeypatch.setenv("JUPYTER_PATH", str(path)) + return path + + +class TestLabextensions: + """Finding files in JupyterLab extensions.""" + + def test_finds_file_in_extension(self, data_dir: Path) -> None: + """A file in an installed extension is found.""" + module_dir = make_labextension( + data_dir, "fake-matplotlib", {"static/remoteEntry.x.js": "entry"} + ) + + asset = find_widget_asset( + "labextensions", "fake-matplotlib/static/remoteEntry.x.js" + ) + + assert asset == (module_dir / "static" / "remoteEntry.x.js").resolve() + + def test_finds_file_in_scoped_extension(self, data_dir: Path) -> None: + """A scoped extension like ``@scope/name`` is found.""" + module_dir = make_labextension(data_dir, "@fake-scope/fake_bokeh", {}) + + asset = find_widget_asset( + "labextensions", "@fake-scope/fake_bokeh/package.json" + ) + + assert asset == (module_dir / "package.json").resolve() + + def test_first_data_dir_with_extension_wins( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Only the first data folder that has the extension is used.""" + first, second = tmp_path / "a", tmp_path / "b" + first_dir = make_labextension(first, "fake-widgets", {"x.js": "a"}) + make_labextension(second, "fake-widgets", {"x.js": "b", "only-b.js": "b"}) + monkeypatch.setenv("JUPYTER_PATH", f"{first}{os.pathsep}{second}") + + assert ( + find_widget_asset("labextensions", "fake-widgets/x.js") + == (first_dir / "x.js").resolve() + ) + assert find_widget_asset("labextensions", "fake-widgets/only-b.js") is None + + def test_skips_extension_folder_without_package_json( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A folder without package.json isn't treated as an extension.""" + first, second = tmp_path / "a", tmp_path / "b" + (first / "labextensions" / "fake-widgets").mkdir(parents=True) + (first / "labextensions" / "fake-widgets" / "x.js").write_text("a") + second_dir = make_labextension(second, "fake-widgets", {"x.js": "b"}) + monkeypatch.setenv("JUPYTER_PATH", f"{first}{os.pathsep}{second}") + + asset = find_widget_asset("labextensions", "fake-widgets/x.js") + + assert asset == (second_dir / "x.js").resolve() + + def test_finds_extension_under_base_prefix( + self, data_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Extensions under ``sys.base_prefix`` are found.""" + base_prefix = tmp_path / "base" + module_dir = make_labextension( + base_prefix / "share" / "jupyter", "fake-base-widgets", {"x.js": "x"} + ) + monkeypatch.setattr(sys, "base_prefix", str(base_prefix)) + + asset = find_widget_asset("labextensions", "fake-base-widgets/x.js") + + assert asset == (module_dir / "x.js").resolve() + + def test_serves_symlinked_extension_folder( + self, data_dir: Path, tmp_path: Path + ) -> None: + """A symlinked extension folder is followed.""" + target = make_labextension(tmp_path / "elsewhere", "fake-widgets", {"x.js": ""}) + (data_dir / "labextensions").mkdir() + (data_dir / "labextensions" / "fake-widgets").symlink_to(target) + + asset = find_widget_asset("labextensions", "fake-widgets/x.js") + + assert asset == (target / "x.js").resolve() + + def test_rejects_symlink_to_file_outside_extension( + self, data_dir: Path, tmp_path: Path + ) -> None: + """A symlink pointing outside the extension is refused.""" + module_dir = make_labextension(data_dir, "fake-widgets", {}) + secret = tmp_path / "secret.js" + secret.write_text("secret") + (module_dir / "x.js").symlink_to(secret) + + assert find_widget_asset("labextensions", "fake-widgets/x.js") is None + + @pytest.mark.parametrize( + "path", + [ + "fake-widgets/../fake-widgets/x.js", + "fake-widgets/./x.js", + "fake-widgets//x.js", + "fake-widgets/.hidden.js", + "fake-widgets/a~b.js", + "fake-widgets/a\\b.js", + "fake-widgets/a\x00b.js", + "fake-widgets/a%2eb.js", + "fake-widgets/x.js\n", + "fake-widgets/x.py", + "fake-widgets/x.txt", + "fake-widgets/x.svg", + "fake-widgets/x.mjs", + "fake-widgets/x.js.map", + "fake-widgets/static", + "fake-widgets/missing.js", + "fake-widgets", + "@fake-scope/fake-widgets", + "missing-widgets/x.js", + ], + ) + def test_rejects_invalid_or_missing_file(self, data_dir: Path, path: str) -> None: + """Invalid paths, other file types and missing files return None.""" + names = [".hidden.js", "a~b.js", "a\\b.js", "a%2eb.js", "static/y.js", "x.js"] + names += ["x.py", "x.txt", "x.svg", "x.mjs", "x.js.map"] + make_labextension(data_dir, "fake-widgets", {name: "" for name in names}) + make_labextension(data_dir, "@fake-scope/fake-widgets", {}) + + assert find_widget_asset("labextensions", path) is None + + def test_rejects_unknown_kind(self, data_dir: Path) -> None: + """Kinds other than labextensions, bokeh and panel return None.""" + make_labextension(data_dir, "fake-widgets", {"x.js": ""}) + + assert find_widget_asset("nbextensions", "fake-widgets/x.js") is None + + +@pytest.fixture +def site_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Fake bokeh and panel packages that fail when imported.""" + site = tmp_path / "site" + for name, static_dir in (("bokeh", "server/static/js"), ("panel", "dist")): + monkeypatch.delitem(sys.modules, name, raising=False) + package = site / name + static = package.joinpath(*static_dir.split("/")) + (static / "lib").mkdir(parents=True) + (static / f"{name}.min.js").write_text(name) + (static / "lib" / "x.js").write_text("lib") + (package / "__init__.py").write_text("raise RuntimeError('imported')") + monkeypatch.syspath_prepend(str(site)) + return site + + +class TestPackageAssets: + """Finding static files of the bokeh and panel packages.""" + + def test_finds_bokeh_files_without_importing_it(self, site_dir: Path) -> None: + """Bokeh's static files are found without importing bokeh.""" + static = site_dir / "bokeh" / "server" / "static" / "js" + + assert ( + find_widget_asset("bokeh", "bokeh.min.js") + == (static / "bokeh.min.js").resolve() + ) + assert ( + find_widget_asset("bokeh", "lib/x.js") + == (static / "lib" / "x.js").resolve() + ) + assert "bokeh" not in sys.modules + + def test_finds_panel_files_without_importing_it(self, site_dir: Path) -> None: + """Panel's dist files are found without importing panel.""" + asset = find_widget_asset("panel", "panel.min.js") + + assert asset == (site_dir / "panel" / "dist" / "panel.min.js").resolve() + assert "panel" not in sys.modules + + def test_rejects_symlink_out_of_static_folder(self, site_dir: Path) -> None: + """A symlink out of the static folder is refused.""" + package = site_dir / "bokeh" + (package / "config.js").write_text("not a static file") + (package / "server" / "static" / "js" / "config.js").symlink_to( + package / "config.js" + ) + + assert find_widget_asset("bokeh", "config.js") is None + + @pytest.mark.parametrize("kind", ["bokeh", "panel"]) + def test_returns_none_when_package_is_missing( + self, kind: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A package that isn't installed returns None.""" + monkeypatch.delitem(sys.modules, kind, raising=False) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) + + assert find_widget_asset(kind, f"{kind}.min.js") is None + + def test_returns_none_when_module_has_no_spec( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A loaded module without a spec returns None.""" + module = ModuleType("bokeh") + module.__spec__ = None + monkeypatch.setitem(sys.modules, "bokeh", module) + + assert find_widget_asset("bokeh", "bokeh.min.js") is None + + +class TestWidgetAssetHandler(AsyncHTTPTestCase): + """The HTTP route, with token auth set up like in production.""" + + @pytest.fixture(autouse=True) + def _labextension(self, data_dir: Path) -> None: + """Install a fake extension for every test.""" + make_labextension(data_dir, "fake-widgets", {"x.js": "console.log(1)"}) + + def get_app(self) -> tornado.web.Application: + """Build an app with the route and token auth.""" + app = tornado.web.Application( + identity_provider=IdentityProvider(token="secret"), + cookie_secret=b"test", + base_url="/", + allow_unauthenticated_access=True, + ) + _load_jupyter_server_extension(SimpleNamespace(base_url="/", web_app=app)) + return app + + def fetch_asset(self, path: str, token: str = "") -> HTTPResponse: + """Request an asset, with a Bearer token if one is given.""" + headers = {"Authorization": f"Bearer {token}"} if token else {} + return self.fetch( + f"/deepnote/widget-assets/{path}", headers=headers, follow_redirects=False + ) + + def test_rejects_request_without_token(self) -> None: + """No token gets 403, not a redirect to login.""" + response = self.fetch_asset("labextensions/fake-widgets/x.js") + + assert response.code == 403 + + def test_rejects_wrong_token(self) -> None: + """A wrong token gets 403.""" + response = self.fetch_asset("labextensions/fake-widgets/x.js", token="wrong") + + assert response.code == 403 + + def test_serves_file_with_token(self) -> None: + """The right token gets the file with its content type.""" + response = self.fetch_asset("labextensions/fake-widgets/x.js", token="secret") + + assert response.code == 200 + assert response.body == b"console.log(1)" + assert response.headers["Content-Type"] == "text/javascript; charset=utf-8" + + def test_returns_404_for_encoded_parent_segments(self) -> None: + """Encoded ``..`` segments get 404.""" + response = self.fetch_asset( + "labextensions/fake-widgets/%2e%2e/fake-widgets/x.js", token="secret" + ) + + assert response.code == 404 + + +def test_jupyter_config_enables_extension() -> None: + """The Jupyter config turns the extension on.""" + config_dir = Path(__file__).parents[2] / "deepnote_core" / "resources" / "jupyter" + loader = PyFileConfigLoader("jupyter_server_config.py", path=str(config_dir)) + + extensions = loader.load_config().ServerApp.jpserver_extensions + + assert extensions[_load_jupyter_server_extension.__module__] is True