Compare commits

...

70 Commits
2.2.1 ... 2.x

Author SHA1 Message Date
Yuvi Panda
220eb87bce Merge pull request #3984 from meeseeksmachine/auto-backport-of-pr-3936-on-2.x
Backport PR #3936 on branch 2.x (admin: Hub is responsible for username validation)
2022-07-29 10:43:28 -07:00
Erik Sundell
f9e9150abc Merge pull request #3993 from minrk/2.x
backport nbclassic fixes to 2.x
2022-07-29 15:32:13 +02:00
Min RK
8074469ad7 Backport PR #3977: unpin nbclassic
0.4.3 is out, see if it fixes things

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-07-29 15:11:00 +02:00
Min RK
46d2455aff Backport PR #3971: nbclassic extension name has been renamed
ref: https://github.com/jupyter/nbclassic/pull/96/files diff-baf53b9a1d8f038c7de824e4928d10356271b26bacf19ffccba98454e685438eL109-R110

our patches to the jinja env need updating to find the new env. Until then, nbclassic 0.4.x will not get the template patches (the 'control panel' link)

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-07-29 15:09:08 +02:00
YuviPanda
72e4119e1a Commit built js 2022-07-18 19:18:38 -05:00
Erik Sundell
faa1754645 Backport PR #3936: admin: Hub is responsible for username validation 2022-07-19 00:13:54 +00:00
Erik Sundell
318f739ba9 Bump to 2.3.2.dev 2022-06-06 16:26:41 +02:00
Erik Sundell
20b3229249 Bump to 2.3.1 2022-06-06 16:26:12 +02:00
Yuvi Panda
f0862f1d10 Merge pull request #3930 from consideRatio/pr/add-changelog-to-2x-branch
Add changelog for 2.3.1
2022-06-06 19:53:45 +05:30
Erik Sundell
3c5f9b255e Add changelog for 2.3.1 2022-06-06 16:15:36 +02:00
Erik Sundell
b6d9d5c120 Merge pull request #3926 from meeseeksmachine/auto-backport-of-pr-3910-on-2.x
Backport PR #3910 on branch 2.x (use equality to filter token prefixes)
2022-06-06 15:36:36 +02:00
Yuvi Panda
bccd0e2ff1 Merge pull request #3928 from yuvipanda/auto-backport-of-pr-3919-on-2.x
Auto backport of pr 3919 on 2.x
2022-06-06 18:40:16 +05:30
Yuvi Panda
a2d39c693d Merge pull request #3927 from meeseeksmachine/auto-backport-of-pr-3918-on-2.x
Backport PR #3918 on branch 2.x (set default_url via config)
2022-06-06 18:40:06 +05:30
Yuvi Panda
76e65da9ff Merge pull request #3925 from meeseeksmachine/auto-backport-of-pr-3906-on-2.x
Backport PR #3906 on branch 2.x (Force add existing certificates)
2022-06-06 18:39:24 +05:30
Yuvi Panda
eb9bb71655 Merge pull request #3924 from meeseeksmachine/auto-backport-of-pr-3889-on-2.x
Backport PR #3889 on branch 2.x (admin: make user-info table selectable)
2022-06-06 18:39:12 +05:30
Yuvi Panda
a39ef8f163 Merge pull request #3923 from meeseeksmachine/auto-backport-of-pr-3837-on-2.x
Backport PR #3837 on branch 2.x (ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable)
2022-06-06 18:38:57 +05:30
Yuvi Panda
f4727cba47 Backport PR #3919: ensure custom template is loaded with jupyter-server notebook extension 2022-06-03 21:15:44 +05:30
Yuvi Panda
14dfa65c75 Backport PR #3918: set default_url via config 2022-06-03 15:17:09 +00:00
Yuvi Panda
9f23bc2959 Backport PR #3910: use equality to filter token prefixes 2022-06-03 15:17:01 +00:00
Min RK
24e8362401 Backport PR #3906: Force add existing certificates 2022-06-03 15:16:46 +00:00
Min RK
c4c662843c Backport PR #3889: admin: make user-info table selectable 2022-06-03 15:16:29 +00:00
Erik Sundell
6d5b13962c Backport PR #3837: ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable 2022-06-03 15:16:20 +00:00
Min RK
fe64595d75 Bump to 2.3.1.dev 2022-05-06 16:06:06 +02:00
Min RK
a3c93088a8 Bump to 2.3.0 2022-05-06 16:05:34 +02:00
Min RK
834229622d Merge pull request #3887 from minrk/2.3-backports
2.3 backports
2022-05-06 16:05:10 +02:00
Min RK
44a1ea42de One more in the changelog 2022-05-06 15:56:13 +02:00
Simon Li
3879a96b67 Backport PR #3886: Cleanup everything on API shutdown
`app.stop` triggers full cleanup and stopping of the event loop

closes  3881

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-06 15:55:00 +02:00
Min RK
d40627d397 changelog for 2.3 2022-05-05 13:24:00 +02:00
Min RK
057cdbc9e9 pre-commit autoupdate 2022-05-05 13:23:52 +02:00
Min RK
75390d2e46 Backport PR #3882: Use log.exception when logging exceptions
This provides the stack trace in the log file, incredibly
useful when debugging

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:28 +02:00
Min RK
f5e4846cfa Backport PR #3874: Missing f prefix on f-strings fix
Fixes  3873

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:27 +02:00
Georgiana Elena
3dc115a829 Backport PR #3876: don't confuse :// in next_url query params for a redirect hostname
closes  3014

These query params should be url-encoded (https://github.com/jupyterhub/nbgitpuller/issues/118), but we still shouldn't be making the wrong assumptions about when a hostname is specified

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:25 +02:00
Min RK
af4ddbfc58 Backport PR #3867: ci: update black configuration
Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:24 +02:00
Min RK
50a4d1e34d Backport PR #3863: [Bug Fix] Search bar disabled on admin dashboard
I originally had `defaultValue` here and I changed it not realizing this would break/disable the input.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:23 +02:00
Erik Sundell
86a238334c Backport PR #3862: Fix typo in [rest api] link in README.md
Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:22 +02:00
Simon Li
dacb9d1668 Backport PR #3859: Do not store Spawner.ip/port on spawner.server during get_env
we shouldn't mutate db state when getting the environment.

IIRC, this was part of an attempt to get the url via `self.server.bind_url` that didn't end up getting used in  3381. So this doesn't really have any positive effects, but it _can_ have negative effects if `get_env` is called in unusual circumstances (jupyterhub/batchspawner 236)

closes jupyterhub/batchspawner 236

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:21 +02:00
Min RK
95cc170383 Backport PR #3853: Fix xsrf_cookie_kwargs ValueError
Fixes

`ValueError: too many values to unpack (expected 2)`

Related to code added as a fix for https://github.com/jupyterhub/jupyterhub/issues/3821

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:20 +02:00
Erik Sundell
437a9d150f Backport PR #3849: The word used is duplicated in upgrade.md
This PR is to update doc for that the word `used` is duplicated in this doc.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:19 +02:00
Erik Sundell
c9616d6f11 Backport PR #3843: Some typos in docs
- fix some references to old 'all' name which was renamed 'inherit'
- fix a heading level in changlog that sphinx warns about

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:18 +02:00
Min RK
61aed70c4d Backport PR #3841: adopt pytest-asyncio asyncio_mode='auto'
removes need for our own implementation of the same behavior in conftest

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:17 +02:00
Erik Sundell
9abb573d47 Backport PR #3839: Document version mismatch log message
Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:16 +02:00
Erik Sundell
b074304834 Backport PR #3835: remove lingering reference to distutils
traitlets, like most Jupyter projects (and Python itself), has a `.version_info` tuple to avoid needing to parse versions

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:15 +02:00
Min RK
201e7ca3d8 Backport PR #3834: Admin Dashboard - Collapsible Details View
I made this PR to see if this feature would be useful for other people. Right now, you can't see all of a user or server's details in the admin page so I added a collapsible view which will let you see the entire server and user objects. I'm open to ideas about how the information is displayed. Will add more tests if this feature is accepted.

![improved-collapse](https://user-images.githubusercontent.com/737367/158468531-1efc28e6-a229-4383-b5f9-b301898d929f.gif)

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:14 +02:00
Min RK
fa8cd90793 Merge pull request #3827 from NarekA/narek/admin-dashboard-search
[Admin Dash] Add search bar for user name
2022-03-15 11:35:57 +01:00
Narek Amirbekian
7dafae29fb Update compiled files 2022-03-15 02:40:27 -07:00
Narek Amirbekian
89a6c745b5 Add base_url to spawner 2022-03-15 02:33:17 -07:00
Erik Sundell
821d9e229d Merge pull request #3831 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-15 07:10:27 +01:00
Narek Amirbekian
db7619fa7a Fix server url 2022-03-14 21:02:18 -07:00
Narek Amirbekian
1ed9423530 Update compiled jsx 2022-03-14 18:06:10 -07:00
Narek Amirbekian
147a578f7a Fix index error on assertion 2022-03-14 18:03:56 -07:00
Narek Amirbekian
3a59a15164 Add front end tests for user search 2022-03-14 17:54:51 -07:00
pre-commit-ci[bot]
1b7aded7f9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-14 23:25:52 +00:00
pre-commit-ci[bot]
bc45d77365 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.31.0 → v2.31.1](https://github.com/asottile/pyupgrade/compare/v2.31.0...v2.31.1)
- [github.com/asottile/reorder_python_imports: v2.7.1 → v3.0.1](https://github.com/asottile/reorder_python_imports/compare/v2.7.1...v3.0.1)
2022-03-14 23:24:13 +00:00
Narek Amirbekian
1b3b005ca4 Add test for name_filter 2022-03-14 13:33:05 -07:00
pre-commit-ci[bot]
e0be811b2c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-14 19:17:28 +00:00
Narek Amirbekian
3627251246 Merge branch 'main' into narek/admin-dashboard-search 2022-03-14 12:16:50 -07:00
Erik Sundell
8d056170d7 Bump to 2.3.0.dev 2022-03-14 12:32:56 +01:00
Erik Sundell
3590d16e30 Bump to 2.2.2 2022-03-14 12:32:25 +01:00
Erik Sundell
572d258cd2 Merge pull request #3828 from consideRatio/pr/changelog-2.2.2
Add changelog for 2.2.2
2022-03-14 12:30:27 +01:00
Min RK
11d0954551 Merge pull request #3826 from consideRatio/pr/add-dedicated-jsx-tests 2022-03-14 12:27:59 +01:00
Erik Sundell
650d47d5c1 Add changelog for 2.2.2 2022-03-14 12:15:54 +01:00
Erik Sundell
945fc824d8 Remove autoformat added new line for generated file 2022-03-12 01:12:29 +01:00
Erik Sundell
a8aa737b00 Don't autoformat generated admin-react.js 2022-03-12 01:12:29 +01:00
Erik Sundell
cd689a1fab ci: test jsx in a dedicated workflow along with src->dist check 2022-03-12 01:12:29 +01:00
Narek Amirbekian
b3f04e7c66 Add search bar for user name 2022-03-11 15:12:53 -08:00
Erik Sundell
fbcf857991 Add inline comments to .pre-commit-config.yaml 2022-03-12 00:00:27 +01:00
Erik Sundell
6c5e5452bc Merge pull request #3825 from NarekA/narek/fix-admin-table-sorting-2
Update admin-react.js
2022-03-11 18:33:33 +01:00
pre-commit-ci[bot]
2f5ba7ba30 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-11 17:15:21 +00:00
Narek Amirbekian
a045eefa64 React file not updated 2022-03-11 09:13:14 -08:00
Min RK
6ea4f2af0d Bump to 2.3.0.dev 2022-03-11 17:00:28 +01:00
42 changed files with 3033 additions and 2830 deletions

108
.github/workflows/test-jsx.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
name: Test jsx (admin-react.js)
on:
pull_request:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
push:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
jobs:
# The ./jsx folder contains React based source code files that are to compile
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
# tests also has tests that this job is meant to run with `yarn test`
# according to the documentation in jsx/README.md.
test-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test
# The ./jsx folder contains React based source files that are to compile to
# share/jupyterhub/static/js/admin-react.js. This job makes sure that whatever
# we have in jsx/src matches the compiled asset that we package and
# distribute.
#
# This job's purpose is to make sure we don't forget to compile changes and to
# verify nobody sneaks in a change in the hard to review compiled asset.
#
# NOTE: In the future we may want to stop version controlling the compiled
# artifact and instead generate it whenever we package JupyterHub. If we
# do this, we are required to setup node and compile the source code
# more often, at the same time we could avoid having this check be made.
#
compile-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn build
run: |
cd jsx
yarn build
- name: yarn place
run: |
cd jsx
yarn place
- name: Verify compiled jsx/src matches version controlled artifact
run: |
if [[ `git status --porcelain=v1` ]]; then
echo "The source code in ./jsx compiles to something different than found in ./share/jupyterhub/static/js/admin-react.js!"
echo
echo "Please re-compile the source code in ./jsx with the following commands:"
echo
echo "yarn"
echo "yarn build"
echo "yarn place"
echo
echo "See ./jsx/README.md for more details."
exit 1
else
echo "Compilation of jsx/src to share/jupyterhub/static/js/admin-react.js didn't lead to changes."
fi

View File

@@ -31,33 +31,6 @@ env:
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
jstest:
# Run javascript tests
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install Node dependencies
run: |
npm install -g yarn
- name: Run yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test
# Run "pytest jupyterhub/tests" in various configurations
pytest:
runs-on: ubuntu-20.04

View File

@@ -1,30 +1,52 @@
# pre-commit is a tool to perform a predefined set of tasks manually and/or
# automatically before git commits are made.
#
# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level
#
# Common tasks
#
# - Run on all files: pre-commit run --all-files
# - Register git hooks: pre-commit install --install-hooks
#
repos:
# Autoformat: Python code, syntax patterns are modernized
- repo: https://github.com/asottile/pyupgrade
rev: v2.31.0
rev: v2.32.1
hooks:
- id: pyupgrade
args:
- --py36-plus
# Autoformat: Python code
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.7.1
rev: v3.1.0
hooks:
- id: reorder-python-imports
# Autoformat: Python code
- repo: https://github.com/psf/black
rev: 22.1.0
rev: 22.3.0
hooks:
- id: black
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.5.1
rev: v2.6.2
hooks:
- id: prettier
# Autoformat and linting, misc. details
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0
hooks:
- id: end-of-file-fixer
exclude: share/jupyterhub/static/js/admin-react.js
- id: requirements-txt-fixer
- id: check-case-conflict
- id: check-executables-have-shebangs
# Linting: Python code (see the file .flake8)
- repo: https://github.com/PyCQA/flake8
rev: "4.0.1"
hooks:
- id: flake8
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: end-of-file-fixer
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: requirements-txt-fixer

View File

@@ -59,7 +59,7 @@ JupyterHub also provides a
[REST API][]
for administration of the Hub and its users.
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
[rest api]: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
## Installation

View File

@@ -9,9 +9,14 @@ cryptography
html5lib # needed for beautifulsoup
jupyterlab >=3
mock
# nbclassic provides the '/tree/' handler, which we use in tests
# it is a transitive dependency via jupyterlab,
# but depend on it directly
nbclassic
pre-commit
pytest>=3.3
pytest-asyncio
pytest-asyncio; python_version < "3.7"
pytest-asyncio>=0.17; python_version >= "3.7"
pytest-cov
requests-mock
tbump

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub
license:
name: BSD-3-Clause
version: 2.2.1
version: 2.3.2.dev
servers:
- url: /hub/api
security:

View File

@@ -1,4 +1,4 @@
# Common log messages emitted by JupyterHub
# Interpreting common log messages
When debugging errors and outages, looking at the logs emitted by
JupyterHub is very helpful. This document tries to document some common
@@ -35,3 +35,38 @@ URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-
### Actions you can take
This log message is benign, and there is usually no action for you to take.
## JupyterHub Singleuser Version mismatch
### Example
```
jupyterhub version 1.5.0 != jupyterhub-singleuser version 1.3.0. This could cause failure to authenticate and result in redirect loops!
```
### Cause
JupyterHub requires the `jupyterhub` python package installed inside the image or
environment the user server starts in. This message indicates that the version of
the `jupyterhub` package installed inside the user image or environment is not
the same version as the JupyterHub server itself. This is not necessarily always a
problem - some version drift is mostly acceptable, and the only two known cases of
breakage are across the 0.7 and 2.0 version releases. In those cases, issues pop
up immediately after upgrading your version of JupyterHub, so **always check the JupyterHub
changelog before upgrading!**. The primary problems this _could_ cause are:
1. Infinite redirect loops after the user server starts
2. Missing expected environment variables in the user server once it starts
3. Failure for the started user server to authenticate with the JupyterHub server -
note that this is _not_ the same as _user authentication_ failing!
However, for the most part, unless you are seeing these specific issues, the log
message should be counted as a warning to get the `jupyterhub` package versions
aligned, rather than as an indicator of an existing problem.
### Actions you can take
Upgrade the version of the `jupyterhub` package in your user environment or image
so it matches the version of JupyterHub running your JupyterHub server! If you
are using the [zero-to-jupyterhub](https://z2jh.jupyter.org) helm chart, you can find the appropriate
version of the `jupyterhub` package to install in your user image [here](https://jupyterhub.github.io/helm-chart/)

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@ JupyterHub provides four roles that are available by default:
```{admonition} **Default roles**
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has.
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `inherit` that resolves to the same permissions as the owner of the token has.
- `server` role allows for posting activity of "itself" only.
**These roles cannot be deleted.**

View File

@@ -38,7 +38,7 @@ By adding a scope to an existing role, all role bearers will gain the associated
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
1. default user scope `self`, and
2. default token scope `all`.
2. default token scope `inherit`.
(default-user-scope-target)=
@@ -57,11 +57,11 @@ The `self` scope is only valid for user entities. In other cases (e.g., for serv
### Default token scope
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`.
The token metascope `inherit` causes the token to have the same permissions as the token's owner. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `inherit` scope resolves to the set of scopes `{read:groups, read:users}`.
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
If the token owner has default `user` role, the `inherit` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope.
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `inherit` scope.
(horizontal-filtering-target)=

View File

@@ -49,6 +49,6 @@ API tokens can also be issued to users via API ([_/hub/token_](../reference/urls
### With RBAC
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.

View File

@@ -5,12 +5,12 @@ object-assign
*/
/*!
Copyright (c) 2017 Jed Watson.
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/** @license React v0.20.1
/** @license React v0.20.2
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
@@ -28,7 +28,7 @@ object-assign
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.1
/** @license React v17.0.2
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
@@ -37,7 +37,16 @@ object-assign
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.1
/** @license React v17.0.2
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.

View File

@@ -40,17 +40,20 @@
"eslint-plugin-unused-imports": "^1.1.1",
"file-loader": "^6.2.0",
"history": "^5.0.0",
"lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-bootstrap": "^1.4.0",
"react-bootstrap": "^2.1.1",
"react-dom": "^17.0.1",
"react-icons": "^4.1.0",
"react-multi-select-component": "^3.0.7",
"react-object-table-viewer": "^1.0.7",
"react-redux": "^7.2.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"recompose": "^0.30.0",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.9",
"style-loader": "^2.0.0",
"webpack": "^5.6.0",
"webpack-cli": "^3.3.4",
@@ -65,6 +68,7 @@
"eslint-plugin-react": "^7.22.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"prettier": "^2.2.1"
"prettier": "^2.2.1",
"sinon": "^13.0.1"
}
}

View File

@@ -1,6 +1,7 @@
export const initialState = {
user_data: undefined,
user_page: 0,
name_filter: "",
groups_data: undefined,
groups_page: 0,
limit: window.api_page_limit,
@@ -13,6 +14,7 @@ export const reducers = (state = initialState, action) => {
return Object.assign({}, state, {
user_page: action.value.page,
user_data: action.value.data,
name_filter: action.value.name_filter || "",
});
// Updates the client group model data and stores the page

View File

@@ -60,7 +60,10 @@ const AddUser = (props) => {
placeholder="usernames separated by line"
data-testid="user-textarea"
onBlur={(e) => {
let split_users = e.target.value.split("\n");
let split_users = e.target.value
.split("\n")
.map((u) => u.trim())
.filter((u) => u.length > 0);
setUsers(split_users);
}}
></textarea>
@@ -88,17 +91,7 @@ const AddUser = (props) => {
data-testid="submit"
className="btn btn-primary"
onClick={() => {
let filtered_users = users.filter(
(e) =>
e.length > 2 &&
/[!@#$%^&*(),.?":{}|<>]/g.test(e) == false
);
if (filtered_users.length < users.length) {
setUsers(filtered_users);
failRegexEvent();
}
addUsers(filtered_users, admin)
addUsers(users, admin)
.then((data) =>
data.status < 300
? updateUsers(0, limit)

View File

@@ -70,12 +70,12 @@ test("Removes users when they fail Regex", async () => {
let textarea = screen.getByTestId("user-textarea");
let submit = screen.getByTestId("submit");
fireEvent.blur(textarea, { target: { value: "foo\nbar\n!!*&*" } });
fireEvent.blur(textarea, { target: { value: "foo \n bar\na@b.co\n \n\n" } });
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar", "a@b.co"], false);
});
test("Correctly submits admin", async () => {

View File

@@ -59,7 +59,7 @@ const CreateGroup = (props) => {
value={groupName}
placeholder="group name..."
onChange={(e) => {
setGroupName(e.target.value);
setGroupName(e.target.value.trim());
}}
></input>
</div>

View File

@@ -1,8 +1,19 @@
import React, { useState } from "react";
import regeneratorRuntime from "regenerator-runtime";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Button } from "react-bootstrap";
import {
Button,
Col,
Row,
FormControl,
Card,
CardGroup,
Collapse,
} from "react-bootstrap";
import ReactObjectTableViewer from "react-object-table-viewer";
import { Link } from "react-router-dom";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
@@ -10,8 +21,8 @@ import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const AccessServerButton = ({ userName, serverName }) => (
<a href={`/user/${userName}/${serverName || ""}`}>
const AccessServerButton = ({ url }) => (
<a href={url || ""}>
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
Access Server
</button>
@@ -19,6 +30,7 @@ const AccessServerButton = ({ userName, serverName }) => (
);
const ServerDashboard = (props) => {
let base_url = window.base_url;
// sort methods
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
@@ -38,14 +50,16 @@ const ServerDashboard = (props) => {
var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({});
const [collapseStates, setCollapseStates] = useState({});
var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page),
limit = useSelector((state) => state.limit),
name_filter = useSelector((state) => state.name_filter),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
var slice = [page * limit, limit, name_filter];
const dispatch = useDispatch();
@@ -59,12 +73,13 @@ const ServerDashboard = (props) => {
history,
} = props;
var dispatchPageUpdate = (data, page) => {
var dispatchPageUpdate = (data, page, name_filter) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
name_filter: name_filter,
},
});
};
@@ -74,9 +89,19 @@ const ServerDashboard = (props) => {
}
if (page != user_page) {
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
updateUsers(...slice).then((data) =>
dispatchPageUpdate(data, page, name_filter)
);
}
var debounce = require("lodash.debounce");
const handleSearch = debounce(async (event) => {
// setNameFilter(event.target.value);
updateUsers(page * limit, limit, event.target.value).then((data) =>
dispatchPageUpdate(data, page, name_filter)
);
}, 300);
if (sortMethod != null) {
user_data = sortMethod(user_data);
}
@@ -94,7 +119,7 @@ const ServerDashboard = (props) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(data, page, name_filter);
})
.catch(() => {
setIsDisabled(false);
@@ -130,7 +155,7 @@ const ServerDashboard = (props) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(data, page, name_filter);
})
.catch(() => {
setErrorAlert(`Failed to update users list.`);
@@ -175,6 +200,124 @@ const ServerDashboard = (props) => {
);
};
const ServerRowTable = ({ data }) => {
return (
<ReactObjectTableViewer
className="table-striped table-bordered"
style={{
padding: "3px 6px",
margin: "auto",
}}
keyStyle={{
padding: "4px",
}}
valueStyle={{
padding: "4px",
}}
data={data}
/>
);
};
const serverRow = (user, server) => {
const { servers, ...userNoServers } = user;
const serverNameDash = server.name ? `-${server.name}` : "";
const userServerName = user.name + serverNameDash;
const open = collapseStates[userServerName] || false;
return [
<tr key={`${userServerName}-row`} className="user-row">
<td data-testid="user-row-name">
<span>
<Button
onClick={() =>
setCollapseStates({
...collapseStates,
[userServerName]: !open,
})
}
aria-controls={`${userServerName}-collapse`}
aria-expanded={open}
data-testid={`${userServerName}-collapse-button`}
variant={open ? "secondary" : "primary"}
size="sm"
>
<span className="caret"></span>
</Button>{" "}
</span>
<span data-testid={`user-name-div-${userServerName}`}>
{user.name}
</span>
</td>
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
<td data-testid="user-row-server">
{server.name ? (
<p className="text-secondary">{server.name}</p>
) : (
<p style={{ color: "lightgrey" }}>[MAIN]</p>
)}
</td>
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton serverName={server.name} userName={user.name} />
<AccessServerButton url={server.url} />
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
style={{ marginRight: 20 }}
/>
<a
href={`${base_url}spawn/${user.name}${
server.name && "/" + server.name
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>,
<tr>
<td
colSpan={6}
style={{ padding: 0 }}
data-testid={`${userServerName}-td`}
>
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
<CardGroup
id={`${userServerName}-card-group`}
style={{ width: "100%", margin: "0 auto", float: "none" }}
>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>User</Card.Title>
<ServerRowTable data={userNoServers} />
</Card>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>Server</Card.Title>
<ServerRowTable data={server} />
</Card>
</CardGroup>
</Collapse>
</td>
</tr>,
];
};
let servers = user_data.flatMap((user) => {
let userServers = Object.values({
"": user.server || {},
@@ -203,11 +346,24 @@ const ServerDashboard = (props) => {
) : (
<></>
)}
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</div>
<div className="server-dashboard-container">
<table className="table table-striped table-bordered table-hover">
<Row>
<Col md={4}>
<FormControl
type="text"
name="user_search"
placeholder="Search users"
aria-label="user-search"
defaultValue={name_filter}
onChange={handleSearch}
/>
</Col>
<Col md="auto" style={{ float: "right", margin: 15 }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</Col>
</Row>
<table className="table table-bordered table-hover">
<thead className="admin-table-head">
<tr>
<th id="user-header">
@@ -286,7 +442,7 @@ const ServerDashboard = (props) => {
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(data, page, name_filter);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
@@ -322,7 +478,7 @@ const ServerDashboard = (props) => {
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
dispatchPageUpdate(data, page, name_filter);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
@@ -346,66 +502,7 @@ const ServerDashboard = (props) => {
</Button>
</td>
</tr>
{servers.map(([user, server], i) => {
server.name = server.name || "";
return (
<tr key={i + "row"} className="user-row">
<td data-testid="user-row-name">{user.name}</td>
<td data-testid="user-row-admin">
{user.admin ? "admin" : ""}
</td>
<td data-testid="user-row-server">
{server.name ? (
<p class="text-secondary">{server.name}</p>
) : (
<p style={{ color: "lightgrey" }}>[MAIN]</p>
)}
</td>
<td data-testid="user-row-last-activity">
{server.last_activity
? timeSince(server.last_activity)
: "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton
serverName={server.name}
userName={user.name}
/>
<AccessServerButton
serverName={server.name}
userName={user.name}
/>
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
/>
<a
href={`/spawn/${user.name}${
server.name && "/" + server.name
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>
);
})}
{servers.flatMap(([user, server]) => serverRow(user, server))}
</tbody>
</table>
<PaginationFooter

View File

@@ -1,6 +1,7 @@
import React from "react";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import userEvent from "@testing-library/user-event";
import { render, screen, fireEvent } from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux";
@@ -9,6 +10,9 @@ import { createStore } from "redux";
import regeneratorRuntime from "regenerator-runtime";
import ServerDashboard from "./ServerDashboard";
import * as sinon from "sinon";
let clock;
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -45,6 +49,7 @@ var mockAppState = () => ({
});
beforeEach(() => {
clock = sinon.useFakeTimers();
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
@@ -52,6 +57,7 @@ beforeEach(() => {
afterEach(() => {
useSelector.mockClear();
clock.restore();
});
test("Renders", async () => {
@@ -71,8 +77,8 @@ test("Renders users from props.user_data into table", async () => {
render(serverDashboardJsx(callbackSpy));
});
let foo = screen.getByText("foo");
let bar = screen.getByText("bar");
let foo = screen.getByTestId("user-name-div-foo");
let bar = screen.getByTestId("user-name-div-bar");
expect(foo).toBeVisible();
expect(bar).toBeVisible();
@@ -151,12 +157,12 @@ test("Sorts according to username", async () => {
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
expect(first.textContent).toContain("bar");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
expect(first.textContent).toContain("foo");
});
test("Sorts according to admin", async () => {
@@ -189,12 +195,12 @@ test("Sorts according to last activity", async () => {
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
expect(first.textContent).toContain("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
expect(first.textContent).toContain("bar");
});
test("Sorts according to server status (running/not running)", async () => {
@@ -208,12 +214,53 @@ test("Sorts according to server status (running/not running)", async () => {
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo");
expect(first.textContent).toContain("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar");
expect(first.textContent).toContain("bar");
});
test("Shows server details with button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let button = screen.getByTestId("foo-collapse-button");
let collapse = screen.getByTestId("foo-collapse");
let collapseBar = screen.getByTestId("bar-collapse");
// expect().toBeVisible does not work here with collapse.
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
});
test("Renders nothing if required data is not available", async () => {
@@ -435,3 +482,42 @@ test("Shows a UI error dialogue when stop user server returns an improper status
expect(errorDialog).toBeVisible();
});
test("Search for user calls updateUsers with name filter", async () => {
let spy = mockAsync();
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
return Promise.resolve([]);
});
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={mockUpdateUsers}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let search = screen.getByLabelText("user-search");
userEvent.type(search, "a");
expect(search.value).toEqual("a");
clock.tick(400);
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
userEvent.type(search, "b");
expect(search.value).toEqual("ab");
clock.tick(400);
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
});

View File

@@ -2,10 +2,11 @@ import { withProps } from "recompose";
import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps(() => ({
updateUsers: (offset, limit) =>
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
data.json()
),
updateUsers: (offset, limit, name_filter) =>
jhapiRequest(
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`,
"GET"
).then((data) => data.json()),
updateGroups: (offset, limit) =>
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
(data) => data.json()

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = (2, 2, 1, "", "")
version_info = (2, 3, 2, "", "dev")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

@@ -50,7 +50,7 @@ class GroupListAPIHandler(_GroupAPIHandler):
# the only valid filter is group=...
# don't expand invalid !server=x to all groups!
self.log.warning(
"Invalid filter on list:group for {self.current_user}: {sub_scope}"
f"Invalid filter on list:group for {self.current_user}: {sub_scope}"
)
raise web.HTTPError(403)
query = query.filter(orm.Group.name.in_(sub_scope['group']))

View File

@@ -47,9 +47,8 @@ class ShutdownAPIHandler(APIHandler):
self.set_status(202)
self.finish(json.dumps({"message": "Shutting down Hub"}))
# stop the eventloop, which will trigger cleanup
loop = IOLoop.current()
loop.add_callback(loop.stop)
# instruct the app to stop, which will trigger cleanup
app.stop()
class RootAPIHandler(APIHandler):

View File

@@ -84,6 +84,7 @@ class UserListAPIHandler(APIHandler):
@needs_scope('list:users')
def get(self):
state_filter = self.get_argument("state", None)
name_filter = self.get_argument("name_filter", None)
offset, limit = self.get_api_pagination()
# post_filter
@@ -130,7 +131,7 @@ class UserListAPIHandler(APIHandler):
if not set(sub_scope).issubset({'group', 'user'}):
# don't expand invalid !server=x filter to all users!
self.log.warning(
"Invalid filter on list:user for {self.current_user}: {sub_scope}"
f"Invalid filter on list:user for {self.current_user}: {sub_scope}"
)
raise web.HTTPError(403)
filters = []
@@ -148,6 +149,9 @@ class UserListAPIHandler(APIHandler):
else:
query = query.filter(or_(*filters))
if name_filter:
query = query.filter(orm.User.name.ilike(f'%{name_filter}%'))
full_query = query
query = query.order_by(orm.User.id.asc()).offset(offset).limit(limit)

View File

@@ -1689,7 +1689,9 @@ class JupyterHub(Application):
for authority, files in self.internal_ssl_authorities.items():
if files:
self.log.info("Adding CA for %s", authority)
certipy.store.add_record(authority, is_ca=True, files=files)
certipy.store.add_record(
authority, is_ca=True, files=files, overwrite=True
)
self.internal_trust_bundles = certipy.trust_from_graph(
self.internal_ssl_components_trust
@@ -3241,9 +3243,15 @@ class JupyterHub(Application):
loop.make_current()
loop.run_sync(self.cleanup)
async def shutdown_cancel_tasks(self, sig):
async def shutdown_cancel_tasks(self, sig=None):
"""Cancel all other tasks of the event loop and initiate cleanup"""
if sig is None:
self.log.critical("Initiating shutdown...")
else:
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
await self.cleanup()
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
if tasks:
@@ -3260,7 +3268,6 @@ class JupyterHub(Application):
tasks = [t for t in asyncio_all_tasks()]
for t in tasks:
self.log.debug("Task status: %s", t)
await self.cleanup()
asyncio.get_event_loop().stop()
def stop(self):
@@ -3268,7 +3275,7 @@ class JupyterHub(Application):
return
if self.http_server:
self.http_server.stop()
self.io_loop.add_callback(self.io_loop.stop)
self.io_loop.add_callback(self.shutdown_cancel_tasks)
async def start_show_config(self):
"""Async wrapper around base start_show_config method"""

View File

@@ -529,7 +529,7 @@ class BaseHandler(RequestHandler):
# clear_cookie only accepts a subset of set_cookie's kwargs
clear_xsrf_cookie_kwargs = {
key: value
for key, value in self.settings.get('xsrf_cookie_kwargs', {})
for key, value in self.settings.get('xsrf_cookie_kwargs', {}).items()
if key in {"path", "domain"}
}
@@ -642,29 +642,32 @@ class BaseHandler(RequestHandler):
next_url = next_url.replace('\\', '%5C')
proto = get_browser_protocol(self.request)
host = self.request.host
if next_url.startswith("///"):
# strip more than 2 leading // down to 2
# because urlparse treats that as empty netloc,
# whereas browsers treat more than two leading // the same as //,
# so netloc is the first non-/ bit
next_url = "//" + next_url.lstrip("/")
parsed_next_url = urlparse(next_url)
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
self.subdomain_host
and urlparse(next_url).netloc
and ("." + urlparse(next_url).netloc).endswith(
and parsed_next_url.netloc
and ("." + parsed_next_url.netloc).endswith(
"." + urlparse(self.subdomain_host).netloc
)
):
# treat absolute URLs for our host as absolute paths:
# below, redirects that aren't strictly paths
parsed = urlparse(next_url)
next_url = parsed.path
if parsed.query:
next_url = next_url + '?' + parsed.query
if parsed.fragment:
next_url = next_url + '#' + parsed.fragment
# below, redirects that aren't strictly paths are rejected
next_url = parsed_next_url.path
if parsed_next_url.query:
next_url = next_url + '?' + parsed_next_url.query
if parsed_next_url.fragment:
next_url = next_url + '#' + parsed_next_url.fragment
parsed_next_url = urlparse(next_url)
# if it still has host info, it didn't match our above check for *this* host
if next_url and (
'://' in next_url
or next_url.startswith('//')
or not next_url.startswith('/')
):
if next_url and (parsed_next_url.netloc or not next_url.startswith('/')):
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
next_url = ''

View File

@@ -498,7 +498,7 @@ class TokenPageHandler(BaseHandler):
continue
if not token.client_id:
# token should have been deleted when client was deleted
self.log.warning("Deleting stale oauth token {token}")
self.log.warning(f"Deleting stale oauth token {token}")
self.db.delete(token)
self.db.commit()
continue

View File

@@ -536,9 +536,7 @@ class Hashed(Expiring):
prefix = token[: cls.prefix_length]
# since we can't filter on hashed values, filter on prefix
# so we aren't comparing with all tokens
prefix_match = db.query(cls).filter(
bindparam('prefix', prefix).startswith(cls.prefix)
)
prefix_match = db.query(cls).filter_by(prefix=prefix)
prefix_match = prefix_match.filter(
or_(cls.expires_at == None, cls.expires_at >= cls.now())
)

View File

@@ -29,9 +29,9 @@ else:
try:
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
except ImportError as e:
continue
if _import_error is None:
_import_error = e
continue
else:
break
if App is None:

View File

@@ -182,6 +182,7 @@ page_template = """
<span>
<a href='{{hub_control_panel_url}}'
id='jupyterhub-control-panel-link'
class='btn btn-default btn-sm navbar-btn pull-right'
style='margin-right: 4px; margin-left: 2px;'>
Control Panel
@@ -633,8 +634,15 @@ class SingleUserNotebookAppMixin(Configurable):
# disable trash by default
# this can be re-enabled by config
self.config.FileContentsManager.delete_to_trash = False
# load default-url env at higher priority than `@default`,
# which may have their own _defaults_ which should not override explicit default_url config
# via e.g. c.Spawner.default_url. Seen in jupyterlab's SingleUserLabApp.
default_url = os.environ.get("JUPYTERHUB_DEFAULT_URL")
if default_url:
self.config[self.__class__.__name__].default_url = default_url
self._log_app_versions()
return super().initialize(argv)
super().initialize(argv)
self.patch_templates()
def start(self):
self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
@@ -705,7 +713,6 @@ class SingleUserNotebookAppMixin(Configurable):
# apply X-JupyterHub-Version to *all* request handlers (even redirects)
self.patch_default_headers()
self.patch_templates()
def page_config_hook(self, handler, page_config):
"""JupyterLab page config hook
@@ -738,19 +745,32 @@ class SingleUserNotebookAppMixin(Configurable):
)
self.jinja_template_vars['hub_host'] = self.hub_host
self.jinja_template_vars['hub_prefix'] = self.hub_prefix
env = self.web_app.settings['jinja2_env']
self.jinja_template_vars[
'hub_control_panel_url'
] = self.hub_host + url_path_join(self.hub_prefix, 'home')
env.globals['hub_control_panel_url'] = self.hub_host + url_path_join(
self.hub_prefix, 'home'
)
settings = self.web_app.settings
# patch classic notebook jinja env
jinja_envs = []
if 'jinja2_env' in settings:
# default jinja env (should we do this on jupyter-server, or only notebook?)
jinja_envs.append(settings['jinja2_env'])
for ext_name in ("notebook", "nbclassic"):
env_name = f"{ext_name}_jinja2_env"
if env_name in settings:
# when running with jupyter-server, classic notebook (nbclassic server extension or notebook v7)
# gets its own jinja env, which needs the same patch
jinja_envs.append(settings[env_name])
# patch jinja env loading to modify page template
# patch jinja env loading to get modified template, only for base page.html
def get_page(name):
if name == 'page.html':
return page_template
orig_loader = env.loader
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
for jinja_env in jinja_envs:
jinja_env.loader = ChoiceLoader(
[FunctionLoader(get_page), jinja_env.loader]
)
def load_server_extensions(self):
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect

View File

@@ -97,10 +97,15 @@ class Spawner(LoggingConfigurable):
Used in logging for consistency with named servers.
"""
if self.name:
return f'{self.user.name}:{self.name}'
if self.user:
user_name = self.user.name
else:
return self.user.name
# no user, only happens in mock tests
user_name = "(no user)"
if self.name:
return f"{user_name}:{self.name}"
else:
return user_name
@property
def _failed(self):
@@ -228,7 +233,7 @@ class Spawner(LoggingConfigurable):
self.orm_spawner.server = server.orm_server
elif server is not None:
self.log.warning(
"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
f"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
)
@property
@@ -871,9 +876,6 @@ class Spawner(LoggingConfigurable):
if self.server:
base_url = self.server.base_url
if self.ip or self.port:
self.server.ip = self.ip
self.server.port = self.port
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
else:
# this should only occur in mock/testing scenarios

View File

@@ -57,12 +57,14 @@ from .utils import add_user
_db = None
def pytest_collection_modifyitems(items):
def _pytest_collection_modifyitems(items):
"""This function is automatically run by pytest passing all collected test
functions.
We use it to add asyncio marker to all async tests and assert we don't use
test functions that are async generators which wouldn't make sense.
It is no longer required with pytest-asyncio >= 0.17
"""
for item in items:
if inspect.iscoroutinefunction(item.obj):
@@ -70,6 +72,13 @@ def pytest_collection_modifyitems(items):
assert not inspect.isasyncgenfunction(item.obj)
if sys.version_info < (3, 7):
# apply pytest-asyncio's 'auto' mode on Python 3.6.
# 'auto' mode is new in pytest-asyncio 0.17,
# which requires Python 3.7.
pytest_collection_modifyitems = _pytest_collection_modifyitems
@fixture(scope='module')
def ssl_tmpdir(tmpdir_factory):
return tmpdir_factory.mktemp('ssl')
@@ -182,6 +191,8 @@ def cleanup_after(request, io_loop):
if not MockHub.initialized():
return
app = MockHub.instance()
if app.db_file.closed:
return
for uid, user in list(app.users.items()):
for name, spawner in list(user.spawners.items()):
if spawner.active:

View File

@@ -333,26 +333,28 @@ class MockHub(JupyterHub):
roles.assign_default_roles(self.db, entity=user)
self.db.commit()
def stop(self):
super().stop()
_stop_called = False
def stop(self):
if self._stop_called:
return
self._stop_called = True
# run cleanup in a background thread
# to avoid multiple eventloops in the same thread errors from asyncio
def cleanup():
asyncio.set_event_loop(asyncio.new_event_loop())
loop = IOLoop.current()
loop.run_sync(self.cleanup)
loop = asyncio.new_event_loop()
loop.run_until_complete(self.cleanup())
loop.close()
pool = ThreadPoolExecutor(1)
with ThreadPoolExecutor(1) as pool:
f = pool.submit(cleanup)
# wait for cleanup to finish
f.result()
pool.shutdown()
# ignore the call that will fire in atexit
self.cleanup = lambda: None
# prevent redundant atexit from running
self._atexit_ran = True
super().stop()
self.db_file.close()
async def login_user(self, name):

View File

@@ -471,6 +471,42 @@ async def test_get_users_state_filter(app, state):
assert usernames == expected
@mark.user
async def test_get_users_name_filter(app):
db = app.db
add_user(db, app=app, name='q')
add_user(db, app=app, name='qr')
add_user(db, app=app, name='qrs')
add_user(db, app=app, name='qrst')
added_usernames = {'q', 'qr', 'qrs', 'qrst'}
r = await api_request(app, 'users')
assert r.status_code == 200
response_users = [u.get("name") for u in r.json()]
assert added_usernames.intersection(response_users) == added_usernames
r = await api_request(app, 'users?name_filter=q')
assert r.status_code == 200
response_users = [u.get("name") for u in r.json()]
assert response_users == ['q', 'qr', 'qrs', 'qrst']
r = await api_request(app, 'users?name_filter=qr')
assert r.status_code == 200
response_users = [u.get("name") for u in r.json()]
assert response_users == ['qr', 'qrs', 'qrst']
r = await api_request(app, 'users?name_filter=qrs')
assert r.status_code == 200
response_users = [u.get("name") for u in r.json()]
assert response_users == ['qrs', 'qrst']
r = await api_request(app, 'users?name_filter=qrst')
assert r.status_code == 200
response_users = [u.get("name") for u in r.json()]
assert response_users == ['qrst']
@mark.user
async def test_get_self(app):
db = app.db
@@ -2068,14 +2104,23 @@ def test_shutdown(app):
)
return r
real_stop = loop.stop
real_stop = loop.asyncio_loop.stop
def stop():
stop.called = True
loop.call_later(1, real_stop)
with mock.patch.object(loop, 'stop', stop):
real_cleanup = app.cleanup
def cleanup():
cleanup.called = True
return real_cleanup()
app.cleanup = cleanup
with mock.patch.object(loop.asyncio_loop, 'stop', stop):
r = loop.run_sync(shutdown, timeout=5)
r.raise_for_status()
reply = r.json()
assert cleanup.called
assert stop.called

View File

@@ -6,7 +6,6 @@ import os
import re
import sys
import time
from distutils.version import LooseVersion as V
from subprocess import check_output
from subprocess import PIPE
from subprocess import Popen
@@ -33,7 +32,7 @@ def test_help_all():
assert '--JupyterHub.ip' in out
@pytest.mark.skipif(V(traitlets.__version__) < V('5'), reason="requires traitlets 5")
@pytest.mark.skipif(traitlets.version_info < (5,), reason="requires traitlets 5")
def test_show_config(tmpdir):
tmpdir.chdir()
p = Popen(

View File

@@ -771,6 +771,10 @@ async def test_login_strip(app):
(False, '/user/other', '/hub/user/other', None),
(False, '/absolute', '/absolute', None),
(False, '/has?query#andhash', '/has?query#andhash', None),
# :// in query string or fragment
(False, '/has?repo=https/host.git', '/has?repo=https/host.git', None),
(False, '/has?repo=https://host.git', '/has?repo=https://host.git', None),
(False, '/has#repo=https://host.git', '/has#repo=https://host.git', None),
# next_url outside is not allowed
(False, 'relative/path', '', None),
(False, 'https://other.domain', '', None),
@@ -810,7 +814,9 @@ async def test_login_redirect(app, running, next_url, location, params):
if params:
url = url_concat(url, params)
if next_url:
if '//' not in next_url and next_url.startswith('/'):
if next_url.startswith('/') and not (
next_url.startswith("//") or urlparse(next_url).netloc
):
next_url = ujoin(app.base_url, next_url, '')
url = url_concat(url, dict(next=next_url))

View File

@@ -5,9 +5,11 @@ from contextlib import contextmanager
from subprocess import CalledProcessError
from subprocess import check_output
from unittest import mock
from urllib.parse import urlencode
from urllib.parse import urlparse
import pytest
from bs4 import BeautifulSoup
import jupyterhub
from .. import orm
@@ -16,6 +18,7 @@ from .mocking import public_url
from .mocking import StubSingleUserSpawner
from .utils import async_requests
from .utils import AsyncSession
from .utils import get_page
@contextmanager
@@ -196,10 +199,22 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
import jupyter_server # noqa
except ImportError:
have_server = False
expect_error = "jupyter_server" in JUPYTERHUB_SINGLEUSER_APP
else:
have_server = True
expect_error = False
try:
import notebook.notebookapp # noqa
except ImportError:
have_notebook = False
else:
have_notebook = True
if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."):
expect_error = not have_notebook
elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."):
expect_error = not have_server
else:
# not specified, will try both
expect_error = not (have_server or have_notebook)
if expect_error:
ctx = pytest.raises(CalledProcessError)
@@ -225,3 +240,22 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
else:
assert '--ServerApp.' in out
assert '--NotebookApp.' not in out
async def test_nbclassic_control_panel(app, user):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
# login, start the server
await user.spawn()
cookies = await app.login_user(user.name)
next_url = url_path_join(user.url, "tree/")
url = '/?' + urlencode({'next': next_url})
r = await get_page(url, app, cookies=cookies)
r.raise_for_status()
assert urlparse(r.url).path == urlparse(next_url).path
page = BeautifulSoup(r.text, "html.parser")
link = page.find("a", id="jupyterhub-control-panel-link")
assert link, f"Missing jupyterhub-control-panel-link in {page}"
assert link["href"] == url_path_join(app.base_url, "hub/home")

View File

@@ -282,9 +282,9 @@ class User:
new_groups = set(group_names).difference(current_groups)
removed_groups = current_groups.difference(group_names)
if new_groups:
self.log.info("Adding user {self.name} to group(s): {new_groups}")
self.log.info(f"Adding user {self.name} to group(s): {new_groups}")
if removed_groups:
self.log.info("Removing user {self.name} from group(s): {removed_groups}")
self.log.info(f"Removing user {self.name} from group(s): {removed_groups}")
if group_names:
groups = (
@@ -812,7 +812,7 @@ class User:
e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.timeout')
else:
self.log.error(
self.log.exception(
"Unhandled error starting {user}'s server: {error}".format(
user=self.name, error=e
)
@@ -822,7 +822,7 @@ class User:
try:
await self.stop(spawner.name)
except Exception:
self.log.error(
self.log.exception(
"Failed to cleanup {user}'s server that failed to start".format(
user=self.name
),
@@ -870,7 +870,7 @@ class User:
self.settings['statsd'].incr('spawner.failure.http_timeout')
else:
e.reason = 'error'
self.log.error(
self.log.exception(
"Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
user=self.name, url=server.url, error=e
)
@@ -879,7 +879,7 @@ class User:
try:
await self.stop(spawner.name)
except Exception:
self.log.error(
self.log.exception(
"Failed to cleanup {user}'s server that failed to start".format(
user=self.name
),

View File

@@ -1,9 +1,13 @@
[tool.black]
skip-string-normalization = true
# target-version should be all supported versions, see
# https://github.com/psf/black/issues/751#issuecomment-473066811
target_version = [
"py36",
"py37",
"py38",
"py39",
"py310",
]
[tool.tbump]
@@ -11,7 +15,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "2.2.1"
current = "2.3.2.dev"
# Example of a semver regexp.
# Make sure this matches current_version before

View File

@@ -3,6 +3,9 @@
# so we have to disable this until pytest 3.11
# minversion = 3.3
# automatically run coroutine tests with asyncio
asyncio_mode = auto
# jupyter_server plugin is incompatible with notebook imports
addopts = -p no:jupyter_server

File diff suppressed because one or more lines are too long