Repository: jupyterhub/jupyterhub

Issue:
Jupyter 4.0.1 - Unable to access filesystem outside of home directory/notebook directory
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Bug description
I've also posted to Discord.  please forgive me as this is my first bug report.  LMK the preferred method github v discord. ty

I’ve been using jupyterhub for a few years now. I’ve recently tried updating to 4.0.1 but it no longer seems to honor my Spawner.default_url and Spawner.notebook_dir. My system uses the base local single user spawner along with NativeAuthenticator. As of 4.0.1, I’m always locked within the home dir of the user which is not desirable as I have group dirs for users to share behind their home dirs. When I downgrade back to jupyterhub 3.1.1, I can move around as needed

I’ve also even tried this article [Troubleshooting — JupyterHub documentation](https://jupyterhub.readthedocs.io/en/stable/faq/troubleshooting.html#i-want-access-to-the-whole-filesystem-and-still-default-users-to-their-home-directory). This doesn’t appear to work at all as the redirects dup the users homedir path in the url

#### Expected behaviour

c.Spawner.notebook_dir should start at path specified.  Currently only drops user into homedir as "/" and therefore locked within that dir.  

#### Actual behaviour

c.Spawner.notebook_dir = ''/some/path"  should start the user at some specified path instead of forced homedir

### How to reproduce
user homedir ex....   /my_dir/username/nbs

install 4.x jupyter hub
set config:
c.Spawner.notebook_dir = "/my_dir"
c.Spawner.default_url = '/lab'

forces user into home '/my_dir/username'.  I want users to be able to go a level back for shared folders

reverting to Jupyterhub 3.1.1 fixes issue

### Your personal set up
Docker container cuda 11.8 ubuntu 20.08
running jupyterhub in conda base
default local spawner
NativeAuthenticator for auth




Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:

I received an answer on discord thread.   c.Spawner.args=['--ContentsManager.root_dir=<dir_where_you_want_to_start_jlab>'].  fixed my issue  
on a side note where does this, ContentsManager, come from? A search on the documentation doesn’t yield anything… [Search - JupyterHub documentation](https://jupyterhub.readthedocs.io/en/stable/search.html?q=ContentsManager)
There is atleast one more [discussion](https://discourse.jupyter.org/t/c-spawner-notebook-dir-does-not-have-the-expected-effect/19913) regarding the same problem in JHub 4. I guess it is a side-effect of making singleuser-user app as a Jupyter server extension. 

I think it is worth documenting in the `notebook_dir` traitlet that users need to pass the same directory in `ContentsManager.root_dir` in `c.Spawner.args` for JHub 4.

Resolving patch:
diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py
--- a/jupyterhub/singleuser/extension.py
+++ b/jupyterhub/singleuser/extension.py
@@ -483,6 +483,11 @@ def load_config_file(self):
         cfg.answer_yes = True
         self.config.FileContentsManager.delete_to_trash = False
 
+        # load Spawner.notebook_dir configuration, if given
+        root_dir = os.getenv("JUPYTERHUB_ROOT_DIR", None)
+        if root_dir:
+            cfg.root_dir = os.path.expanduser(root_dir)
+
         # load http server config from environment
         url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
         if url.port:


Test patch:
diff --git a/jupyterhub/tests/extension/__init__.py b/jupyterhub/tests/extension/__init__.py
--- a/jupyterhub/tests/extension/__init__.py
+++ b/jupyterhub/tests/extension/__init__.py
@@ -30,6 +30,7 @@ def _class_str(obj):
         info = {
             "current_user": self.current_user,
             "config": self.app.config,
+            "root_dir": self.contents_manager.root_dir,
             "disable_user_config": getattr(self.app, "disable_user_config", None),
             "settings": self.settings,
             "config_file_paths": self.app.config_file_paths,
diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py
--- a/jupyterhub/tests/test_singleuser.py
+++ b/jupyterhub/tests/test_singleuser.py
@@ -2,6 +2,7 @@
 import os
 import sys
 from contextlib import nullcontext
+from pprint import pprint
 from subprocess import CalledProcessError, check_output
 from unittest import mock
 from urllib.parse import urlencode, urlparse
@@ -171,9 +172,7 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
     )
     r.raise_for_status()
     info = r.json()
-    import pprint
-
-    pprint.pprint(info)
+    pprint(info)
     assert info['disable_user_config']
     server_config = info['config']
     settings = info['settings']
@@ -198,6 +197,79 @@ def assert_not_in_home(path, name):
                 assert_not_in_home(path, key)
 
 
+@pytest.mark.parametrize("extension", [True, False])
+@pytest.mark.parametrize("notebook_dir", ["", "~", "~/sub", "ABS"])
+async def test_notebook_dir(
+    request, app, tmpdir, user, full_spawn, extension, notebook_dir
+):
+    if extension:
+        try:
+            import jupyter_server  # noqa
+        except ImportError:
+            pytest.skip("needs jupyter-server 2")
+        else:
+            if jupyter_server.version_info < (2,):
+                pytest.skip("needs jupyter-server 2")
+
+    token = user.new_api_token(scopes=["access:servers!user"])
+    headers = {"Authorization": f"Bearer {token}"}
+
+    spawner = user.spawner
+    if extension:
+        user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
+    else:
+        user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
+
+    home_dir = tmpdir.join("home").mkdir()
+    sub_dir = home_dir.join("sub").mkdir()
+    with sub_dir.join("subfile.txt").open("w") as f:
+        f.write("txt\n")
+    abs_dir = tmpdir.join("abs").mkdir()
+    with abs_dir.join("absfile.txt").open("w") as f:
+        f.write("absfile\n")
+
+    if notebook_dir:
+        expected_root_dir = notebook_dir.replace("ABS", str(abs_dir)).replace(
+            "~", str(home_dir)
+        )
+    else:
+        expected_root_dir = str(home_dir)
+
+    spawner.notebook_dir = notebook_dir.replace("ABS", str(abs_dir))
+
+    # home_dir is defined on SimpleSpawner
+    user.spawner.home_dir = home = str(home_dir)
+    spawner.environment["HOME"] = home
+    await user.spawn()
+    await app.proxy.add_user(user)
+    url = public_url(app, user)
+    r = await async_requests.get(
+        url_path_join(public_url(app, user), 'jupyterhub-test-info'), headers=headers
+    )
+    r.raise_for_status()
+    info = r.json()
+    pprint(info)
+
+    assert info["root_dir"] == expected_root_dir
+    # secondary check: make sure it has the intended effect on root_dir
+    r = await async_requests.get(
+        url_path_join(public_url(app, user), 'api/contents/'), headers=headers
+    )
+    r.raise_for_status()
+    root_contents = sorted(item['name'] for item in r.json()['content'])
+
+    # check contents
+    if not notebook_dir or notebook_dir == "~":
+        # use any to avoid counting possible automatically created files in $HOME
+        assert 'sub' in root_contents
+    elif notebook_dir == "ABS":
+        assert 'absfile.txt' in root_contents
+    elif notebook_dir == "~/sub":
+        assert 'subfile.txt' in root_contents
+    else:
+        raise ValueError(f"No contents check for {notebook_dir=}")
+
+
 def test_help_output():
     out = check_output(
         [sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']