mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
54 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b8dc3befab | ||
![]() |
2f29848757 | ||
![]() |
4f3d6cdd0c | ||
![]() |
67733ef928 | ||
![]() |
e657754e7f | ||
![]() |
2d6087959c | ||
![]() |
08a913707f | ||
![]() |
9c8a4f287a | ||
![]() |
64d6f0222c | ||
![]() |
538abdf084 | ||
![]() |
6e5c307edb | ||
![]() |
67ebe0b0cf | ||
![]() |
dcf21d53fd | ||
![]() |
f5bb0a2622 | ||
![]() |
704712cc81 | ||
![]() |
f86d53a234 | ||
![]() |
5466224988 | ||
![]() |
f9fa21bfd7 | ||
![]() |
e4855c30f5 | ||
![]() |
f1c4fdd5a2 | ||
![]() |
e58cf06706 | ||
![]() |
91f4918cff | ||
![]() |
b15ccfa4ae | ||
![]() |
5102fde2f0 | ||
![]() |
f5dc005a70 | ||
![]() |
5fd8f0f596 | ||
![]() |
26ceafa8a3 | ||
![]() |
2e2ed8a4ff | ||
![]() |
6cc734f884 | ||
![]() |
4f7f07d3b7 | ||
![]() |
d436c97e3d | ||
![]() |
807c5b8ff9 | ||
![]() |
8da06d1259 | ||
![]() |
1c1be8a24b | ||
![]() |
897606b00c | ||
![]() |
615af5eb33 | ||
![]() |
85f94c12fc | ||
![]() |
ccfee4d235 | ||
![]() |
a2ba55756d | ||
![]() |
1b3e94db6c | ||
![]() |
614d9d89d0 | ||
![]() |
05a3f5aa9a | ||
![]() |
4f47153123 | ||
![]() |
a14d9ecaa1 | ||
![]() |
6815f30d36 | ||
![]() |
13172e6856 | ||
![]() |
ebc9fd7758 | ||
![]() |
0761a5db02 | ||
![]() |
46e7a231fe | ||
![]() |
ffa5a20e2f | ||
![]() |
2088a57ffe | ||
![]() |
345805781f | ||
![]() |
9eb52ea788 | ||
![]() |
fb1405ecd8 |
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -1,15 +1,32 @@
|
||||
# Build releases and (on tags) publish to PyPI
|
||||
# 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
|
||||
#
|
||||
# Test build release artifacts (PyPI package, Docker images) and publish them on
|
||||
# pushed git tags.
|
||||
#
|
||||
name: Release
|
||||
|
||||
# always build releases (to make sure wheel-building works)
|
||||
# but only publish to PyPI on tags
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "!dependabot/**"
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/release.yml"
|
||||
push:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/release.yml"
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
- "pre-commit-ci-update-config"
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
@@ -96,7 +113,6 @@ jobs:
|
||||
# Setup docker to build for multiple platforms, see:
|
||||
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
|
||||
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
|
||||
|
||||
- name: Set up QEMU (for docker buildx)
|
||||
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
|
||||
|
||||
@@ -120,6 +136,8 @@ jobs:
|
||||
run: |
|
||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
|
||||
# image: jupyterhub/jupyterhub
|
||||
#
|
||||
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
||||
# If this is a tagged build this will return additional parent tags.
|
||||
# E.g. 1.2.3 is expanded to Docker tags
|
||||
@@ -137,7 +155,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -146,8 +164,8 @@ jobs:
|
||||
# array into a comma separated list of tags
|
||||
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
|
||||
|
||||
# jupyterhub-onbuild
|
||||
|
||||
# image: jupyterhub/jupyterhub-onbuild
|
||||
#
|
||||
- name: Get list of jupyterhub-onbuild tags
|
||||
id: onbuildtags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
@@ -158,7 +176,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-onbuild
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||
@@ -167,8 +185,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
|
||||
|
||||
# jupyterhub-demo
|
||||
|
||||
# image: jupyterhub/jupyterhub-demo
|
||||
#
|
||||
- name: Get list of jupyterhub-demo tags
|
||||
id: demotags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
@@ -179,7 +197,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-demo
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||
@@ -191,7 +209,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
||||
|
||||
# jupyterhub/singleuser
|
||||
# image: jupyterhub/singleuser
|
||||
#
|
||||
- name: Get list of jupyterhub/singleuser tags
|
||||
id: singleusertags
|
||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||
@@ -202,7 +221,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub/singleuser
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||
with:
|
||||
build-args: |
|
||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||
|
64
.github/workflows/test-docs.yml
vendored
Normal file
64
.github/workflows/test-docs.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
#
|
||||
# This workflow validates the REST API definition and runs the pytest tests in
|
||||
# the docs/ folder. This workflow does not build the documentation. That is
|
||||
# instead tested via ReadTheDocs (https://readthedocs.org/projects/jupyterhub/).
|
||||
#
|
||||
name: Test docs
|
||||
|
||||
# The tests defined in docs/ are currently influenced by changes to _version.py
|
||||
# and scopes.py.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "jupyterhub/_version.py"
|
||||
- "jupyterhub/scopes.py"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test-docs.yml"
|
||||
push:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "jupyterhub/_version.py"
|
||||
- "jupyterhub/scopes.py"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test-docs.yml"
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
- "pre-commit-ci-update-config"
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||
LANG: C.UTF-8
|
||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||
|
||||
jobs:
|
||||
validate-rest-api-definition:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Validate REST API definition
|
||||
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
|
||||
with:
|
||||
definition-file: docs/source/_static/rest-api.yml
|
||||
|
||||
test-docs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.9"
|
||||
|
||||
- name: Install requirements
|
||||
run: |
|
||||
pip install -r docs/requirements.txt pytest -e .
|
||||
|
||||
- name: pytest docs/
|
||||
run: |
|
||||
pytest docs/
|
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -1,14 +1,28 @@
|
||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
|
||||
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||
#
|
||||
name: Test
|
||||
|
||||
# Trigger the workflow's on all PRs but only on pushed tags or commits to
|
||||
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
|
||||
# to trigger.
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test.yml"
|
||||
push:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "**.rst"
|
||||
- ".github/workflows/*"
|
||||
- "!.github/workflows/test.yml"
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
- "pre-commit-ci-update-config"
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -17,25 +31,6 @@ env:
|
||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||
|
||||
jobs:
|
||||
rest-api:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Validate REST API
|
||||
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
|
||||
with:
|
||||
definition-file: docs/source/_static/rest-api.yml
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.9"
|
||||
# in addition to the doc requirements
|
||||
# the docs *tests* require pre-commit and pytest
|
||||
- run: |
|
||||
pip install -r docs/requirements.txt pytest pre-commit -e .
|
||||
- run: |
|
||||
pytest docs/
|
||||
|
||||
jstest:
|
||||
# Run javascript tests
|
||||
runs-on: ubuntu-20.04
|
||||
|
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.29.1
|
||||
rev: v2.31.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args:
|
||||
@@ -22,7 +22,7 @@ repos:
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
rev: v4.1.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: check-case-conflict
|
||||
|
@@ -117,8 +117,7 @@ To start the Hub server, run the command:
|
||||
|
||||
jupyterhub
|
||||
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
||||
PAM credentials.
|
||||
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
|
||||
|
||||
_Note_: To allow multiple users to sign in to the server, you will need to
|
||||
run the `jupyterhub` command as a _privileged user_, such as root.
|
||||
|
@@ -3,8 +3,10 @@
|
||||
alabaster_jupyterhub
|
||||
autodoc-traits
|
||||
myst-parser
|
||||
pre-commit
|
||||
pydata-sphinx-theme
|
||||
pytablewriter>=0.56
|
||||
ruamel.yaml
|
||||
sphinx>=1.7
|
||||
sphinx-copybutton
|
||||
sphinx-jsonschema
|
||||
|
@@ -6,7 +6,7 @@ info:
|
||||
description: The REST API for JupyterHub
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
version: 2.0.1
|
||||
version: 2.1.0
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
@@ -1419,3 +1419,4 @@ components:
|
||||
Read information about the proxy’s routing table, sync the Hub
|
||||
with the proxy and notify the Hub about a new proxy.
|
||||
shutdown: Shutdown the hub.
|
||||
read:metrics: Read prometheus metrics.
|
||||
|
@@ -6,8 +6,83 @@ command line for details.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## 2.1
|
||||
|
||||
### 2.1.0 2022-01-21
|
||||
|
||||
2.1.0 is a small bugfix release, resolving regressions in 2.0 and further refinements.
|
||||
In particular, the authenticated prometheus metrics endpoint did not work in 2.0 because it lacked a scope.
|
||||
To access the authenticated metrics endpoint with a token,
|
||||
upgrade to 2.1 and make sure the token/owner has the `read:metrics` scope.
|
||||
|
||||
Custom error messages for failed spawns are now handled more consistently on the spawn-progress API and the spawn-failed HTML page.
|
||||
Previously, spawn-progress did not relay the custom message provided by `exception.jupyterhub_message`,
|
||||
and full HTML messages in `exception.jupyterhub_html_message` can now be displayed in both contexts.
|
||||
|
||||
The long-deprecated, inconsistent behavior when users visited a URL for another user's server,
|
||||
where they could sometimes be redirected back to their own server,
|
||||
has been removed in favor of consistent behavior based on the user's permissions.
|
||||
To share a URL that will take any user to their own server, use `https://my.hub/hub/user-redirect/path/...`.
|
||||
|
||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.2...2.1.0))
|
||||
|
||||
#### Enhancements made
|
||||
|
||||
- relay custom messages in exception.jupyterhub_message in progress API [#3764](https://github.com/jupyterhub/jupyterhub/pull/3764) ([@minrk](https://github.com/minrk))
|
||||
- Add the capability to inform a connection to Alembic Migration Script [#3762](https://github.com/jupyterhub/jupyterhub/pull/3762) ([@DougTrajano](https://github.com/DougTrajano))
|
||||
|
||||
#### Bugs fixed
|
||||
|
||||
- Fix loading Spawner.user_options from db [#3773](https://github.com/jupyterhub/jupyterhub/pull/3773) ([@IgorBerman](https://github.com/IgorBerman))
|
||||
- Add missing `read:metrics` scope for authenticated metrics endpoint [#3770](https://github.com/jupyterhub/jupyterhub/pull/3770) ([@minrk](https://github.com/minrk))
|
||||
- apply scope checks to some admin-or-self situations [#3763](https://github.com/jupyterhub/jupyterhub/pull/3763) ([@minrk](https://github.com/minrk))
|
||||
|
||||
#### Maintenance and upkeep improvements
|
||||
|
||||
- DOCS: Add github metadata for edit button [#3775](https://github.com/jupyterhub/jupyterhub/pull/3775) ([@minrk](https://github.com/minrk))
|
||||
|
||||
#### Documentation improvements
|
||||
|
||||
- Improve documentation about spawner exception handling [#3765](https://github.com/jupyterhub/jupyterhub/pull/3765) ([@twalcari](https://github.com/twalcari))
|
||||
|
||||
#### Contributors to this release
|
||||
|
||||
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-01-10&to=2022-01-21&type=c))
|
||||
|
||||
[@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-10..2022-01-21&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-10..2022-01-21&type=Issues) | [@DougTrajano](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ADougTrajano+updated%3A2022-01-10..2022-01-21&type=Issues) | [@IgorBerman](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AIgorBerman+updated%3A2022-01-10..2022-01-21&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-10..2022-01-21&type=Issues) | [@twalcari](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atwalcari+updated%3A2022-01-10..2022-01-21&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2022-01-10..2022-01-21&type=Issues)
|
||||
|
||||
## 2.0
|
||||
|
||||
### [2.0.2] 2022-01-10
|
||||
|
||||
2.0.2 fixes a regression in 2.0.1 causing false positives
|
||||
rejecting valid requests as cross-origin,
|
||||
mostly when JupyterHub is behind additional proxies.
|
||||
|
||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.1...2.0.2))
|
||||
|
||||
#### Bugs fixed
|
||||
|
||||
- use outermost proxied entry when looking up browser protocol [#3757](https://github.com/jupyterhub/jupyterhub/pull/3757) ([@minrk](https://github.com/minrk))
|
||||
|
||||
#### Maintenance and upkeep improvements
|
||||
|
||||
- remove unused macro with missing references [#3760](https://github.com/jupyterhub/jupyterhub/pull/3760) ([@minrk](https://github.com/minrk))
|
||||
- ci: refactor to avoid triggering all tests on changes to docs [#3750](https://github.com/jupyterhub/jupyterhub/pull/3750) ([@consideRatio](https://github.com/consideRatio))
|
||||
- Extra test_cors_check tests [#3746](https://github.com/jupyterhub/jupyterhub/pull/3746) ([@manics](https://github.com/manics))
|
||||
|
||||
#### Documentation improvements
|
||||
|
||||
- DOCS: Update theme configuration [#3754](https://github.com/jupyterhub/jupyterhub/pull/3754) ([@choldgraf](https://github.com/choldgraf))
|
||||
- DOC: Add note about allowed_users not being set [#3748](https://github.com/jupyterhub/jupyterhub/pull/3748) ([@choldgraf](https://github.com/choldgraf))
|
||||
- localhost URL is http, not https [#3747](https://github.com/jupyterhub/jupyterhub/pull/3747) ([@minrk](https://github.com/minrk))
|
||||
|
||||
#### Contributors to this release
|
||||
|
||||
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2021-12-22&to=2022-01-10&type=c))
|
||||
|
||||
[@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acholdgraf+updated%3A2021-12-22..2022-01-10&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2021-12-22..2022-01-10&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agithub-actions+updated%3A2021-12-22..2022-01-10&type=Issues) | [@jakob-keller](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajakob-keller+updated%3A2021-12-22..2022-01-10&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2021-12-22..2022-01-10&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ameeseeksmachine+updated%3A2021-12-22..2022-01-10&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2021-12-22..2022-01-10&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apre-commit-ci+updated%3A2021-12-22..2022-01-10&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2021-12-22..2022-01-10&type=Issues)
|
||||
|
||||
### [2.0.1]
|
||||
|
||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1))
|
||||
@@ -1359,7 +1434,9 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||
|
||||
First preview release
|
||||
|
||||
[unreleased]: https://github.com/jupyterhub/jupyterhub/compare/2.0.1...HEAD
|
||||
[unreleased]: https://github.com/jupyterhub/jupyterhub/compare/2.1.0...HEAD
|
||||
[2.1.0]: https://github.com/jupyterhub/jupyterhub/compare/2.0.2...2.1.0
|
||||
[2.0.2]: https://github.com/jupyterhub/jupyterhub/compare/2.0.1...2.0.2
|
||||
[2.0.1]: https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1
|
||||
[2.0.0]: https://github.com/jupyterhub/jupyterhub/compare/1.5.0...2.0.0
|
||||
[1.5.0]: https://github.com/jupyterhub/jupyterhub/compare/1.4.2...1.5.0
|
||||
|
@@ -130,6 +130,30 @@ html_static_path = ['_static']
|
||||
|
||||
htmlhelp_basename = 'JupyterHubdoc'
|
||||
|
||||
html_theme_options = {
|
||||
"icon_links": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/jupyterhub/jupyterhub",
|
||||
"icon": "fab fa-github-square",
|
||||
},
|
||||
{
|
||||
"name": "Discourse",
|
||||
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
|
||||
"icon": "fab fa-discourse",
|
||||
},
|
||||
],
|
||||
"use_edit_page_button": True,
|
||||
"navbar_align": "left",
|
||||
}
|
||||
|
||||
html_context = {
|
||||
"github_user": "jupyterhub",
|
||||
"github_repo": "jupyterhub",
|
||||
"github_version": "main",
|
||||
"doc_path": "docs",
|
||||
}
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
|
@@ -16,6 +16,10 @@ c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||
Users in the `allowed_users` set are added to the Hub database when the Hub is
|
||||
started.
|
||||
|
||||
```{warning}
|
||||
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
|
||||
```
|
||||
|
||||
## Configure admins (`admin_users`)
|
||||
|
||||
```{note}
|
||||
|
@@ -1,16 +1,33 @@
|
||||
"""
|
||||
This script updates two files with the RBAC scope descriptions found in
|
||||
`scopes.py`.
|
||||
|
||||
The files are:
|
||||
|
||||
1. scope-table.md
|
||||
|
||||
This file is git ignored and referenced by the documentation.
|
||||
|
||||
2. rest-api.yml
|
||||
|
||||
This file is JupyterHub's REST API schema. Both a version and the RBAC
|
||||
scopes descriptions are updated in it.
|
||||
"""
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from subprocess import run
|
||||
|
||||
from pytablewriter import MarkdownTableWriter
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
import jupyterhub
|
||||
from jupyterhub import __version__
|
||||
from jupyterhub.scopes import scope_definitions
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
DOCS = Path(HERE).parent.parent.absolute()
|
||||
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
||||
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
|
||||
|
||||
|
||||
class ScopeTableGenerator:
|
||||
@@ -82,8 +99,9 @@ class ScopeTableGenerator:
|
||||
return table_rows
|
||||
|
||||
def write_table(self):
|
||||
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
|
||||
filename = f"{HERE}/scope-table.md"
|
||||
"""Generates the RBAC scopes reference documentation as a markdown table
|
||||
and writes it to the .gitignored `scope-table.md`."""
|
||||
filename = SCOPE_TABLE_MD
|
||||
table_name = ""
|
||||
headers = ["Scope", "Grants permission to:"]
|
||||
values = self._parse_scopes()
|
||||
@@ -99,15 +117,20 @@ class ScopeTableGenerator:
|
||||
)
|
||||
|
||||
def write_api(self):
|
||||
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
||||
"""Loads `rest-api.yml` and writes it back with a dynamically set
|
||||
JupyterHub version field and list of RBAC scopes descriptions from
|
||||
`scopes.py`."""
|
||||
filename = REST_API_YAML
|
||||
yaml = YAML(typ='rt')
|
||||
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
yaml.indent(mapping=2, offset=2, sequence=4)
|
||||
|
||||
scope_dict = {}
|
||||
with open(filename) as f:
|
||||
content = yaml.load(f.read())
|
||||
|
||||
content["info"]["version"] = jupyterhub.__version__
|
||||
content["info"]["version"] = __version__
|
||||
for scope in self.scopes:
|
||||
description = self.scopes[scope]['description']
|
||||
doc_description = self.scopes[scope].get('doc_description', '')
|
||||
@@ -121,6 +144,12 @@ class ScopeTableGenerator:
|
||||
with open(filename, 'w') as f:
|
||||
yaml.dump(content, f)
|
||||
|
||||
run(
|
||||
['pre-commit', 'run', 'prettier', '--files', filename],
|
||||
cwd=HERE,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
table_generator = ScopeTableGenerator()
|
||||
|
@@ -108,6 +108,16 @@ class MySpawner(Spawner):
|
||||
return url
|
||||
```
|
||||
|
||||
#### Exception handling
|
||||
|
||||
When `Spawner.start` raises an Exception, a message can be passed on to the user via the exception via a `.jupyterhub_html_message` or `.jupyterhub_message` attribute.
|
||||
|
||||
When the Exception has a `.jupyterhub_html_message` attribute, it will be rendered as HTML to the user.
|
||||
|
||||
Alternatively `.jupyterhub_message` is rendered as unformatted text.
|
||||
|
||||
If both attributes are not present, the Exception will be shown to the user as unformatted text.
|
||||
|
||||
### Spawner.poll
|
||||
|
||||
`Spawner.poll` should check if the spawner is still running.
|
||||
|
@@ -10,7 +10,9 @@ here = Path(__file__).absolute().parent
|
||||
root = here.parent
|
||||
|
||||
|
||||
def test_rest_api_version():
|
||||
def test_rest_api_version_is_updated():
|
||||
"""Checks that the version in JupyterHub's REST API definition file
|
||||
(rest-api.yml) is matching the JupyterHub version."""
|
||||
version_py = root.joinpath("jupyterhub", "_version.py")
|
||||
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
|
||||
ns = {}
|
||||
@@ -25,18 +27,17 @@ def test_rest_api_version():
|
||||
assert jupyterhub_version == rest_api_version
|
||||
|
||||
|
||||
def test_restapi_scopes():
|
||||
def test_rest_api_rbac_scope_descriptions_are_updated():
|
||||
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
|
||||
definition file (rest-api.yml) as can be updated by generate-scope-table.py
|
||||
matches what is committed."""
|
||||
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
|
||||
run(
|
||||
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
|
||||
cwd=here,
|
||||
check=False,
|
||||
)
|
||||
run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--no-pager",
|
||||
"diff",
|
||||
"--color=always",
|
||||
"--exit-code",
|
||||
str(here.joinpath("source", "_static", "rest-api.yml")),
|
||||
],
|
||||
|
@@ -3664,9 +3664,9 @@ flatted@^3.1.0:
|
||||
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
|
||||
|
||||
follow-redirects@^1.0.0:
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
|
||||
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
|
||||
version "1.14.7"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
|
||||
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
|
||||
|
||||
for-in@^1.0.2:
|
||||
version "1.0.2"
|
||||
|
@@ -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, 0, 1, "", "")
|
||||
version_info = (2, 1, 0, "", "")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
|
@@ -55,8 +55,15 @@ def run_migrations_offline():
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
connectable = config.attributes.get('connection', None)
|
||||
|
||||
if connectable is None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
else:
|
||||
context.configure(
|
||||
connection=connectable, target_metadata=target_metadata, literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
@@ -69,11 +76,14 @@ def run_migrations_online():
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
connectable = config.attributes.get('connection', None)
|
||||
|
||||
if connectable is None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
@@ -16,6 +16,7 @@ from tornado import web
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from .. import scopes
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
from .base import BaseHandler
|
||||
@@ -115,7 +116,10 @@ class OAuthHandler:
|
||||
# make absolute local redirects full URLs
|
||||
# to satisfy oauthlib's absolute URI requirement
|
||||
redirect_uri = (
|
||||
self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
|
||||
get_browser_protocol(self.request)
|
||||
+ "://"
|
||||
+ self.request.host
|
||||
+ redirect_uri
|
||||
)
|
||||
parsed_url = urlparse(uri)
|
||||
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
|
||||
|
@@ -14,6 +14,7 @@ from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..handlers import BaseHandler
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import isoformat
|
||||
from ..utils import url_path_join
|
||||
|
||||
@@ -60,6 +61,8 @@ class APIHandler(BaseHandler):
|
||||
"""
|
||||
host_header = self.app.forwarded_host_header or "Host"
|
||||
host = self.request.headers.get(host_header)
|
||||
if host and "," in host:
|
||||
host = host.split(",", 1)[0].strip()
|
||||
referer = self.request.headers.get("Referer")
|
||||
|
||||
# If no header is provided, assume it comes from a script/curl.
|
||||
@@ -71,7 +74,8 @@ class APIHandler(BaseHandler):
|
||||
self.log.warning("Blocking API request with no referer")
|
||||
return False
|
||||
|
||||
proto = self.request.protocol
|
||||
proto = get_browser_protocol(self.request)
|
||||
|
||||
full_host = f"{proto}://{host}{self.hub.base_url}"
|
||||
host_url = urlparse(full_host)
|
||||
referer_url = urlparse(referer)
|
||||
|
@@ -714,7 +714,12 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
# check if spawner has just failed
|
||||
f = spawn_future
|
||||
if f and f.done() and f.exception():
|
||||
failed_event['message'] = "Spawn failed: %s" % f.exception()
|
||||
exc = f.exception()
|
||||
message = getattr(exc, "jupyterhub_message", str(exc))
|
||||
failed_event['message'] = f"Spawn failed: {message}"
|
||||
html_message = getattr(exc, "jupyterhub_html_message", "")
|
||||
if html_message:
|
||||
failed_event['html_message'] = html_message
|
||||
await self.send_event(failed_event)
|
||||
return
|
||||
else:
|
||||
@@ -747,7 +752,12 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
# what happened? Maybe spawn failed?
|
||||
f = spawn_future
|
||||
if f and f.done() and f.exception():
|
||||
failed_event['message'] = "Spawn failed: %s" % f.exception()
|
||||
exc = f.exception()
|
||||
message = getattr(exc, "jupyterhub_message", str(exc))
|
||||
failed_event['message'] = f"Spawn failed: {message}"
|
||||
html_message = getattr(exc, "jupyterhub_html_message", "")
|
||||
if html_message:
|
||||
failed_event['html_message'] = html_message
|
||||
else:
|
||||
self.log.warning(
|
||||
"Server %s didn't start for unknown reason", spawner._log_name
|
||||
|
@@ -45,10 +45,12 @@ from ..metrics import ServerSpawnStatus
|
||||
from ..metrics import ServerStopStatus
|
||||
from ..metrics import TOTAL_USERS
|
||||
from ..objects import Server
|
||||
from ..scopes import needs_scope
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..user import User
|
||||
from ..utils import AnyTimeoutError
|
||||
from ..utils import get_accepted_mimetype
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import maybe_future
|
||||
from ..utils import url_path_join
|
||||
|
||||
@@ -632,12 +634,10 @@ class BaseHandler(RequestHandler):
|
||||
next_url = self.get_argument('next', default='')
|
||||
# protect against some browsers' buggy handling of backslash as slash
|
||||
next_url = next_url.replace('\\', '%5C')
|
||||
if (next_url + '/').startswith(
|
||||
(
|
||||
f'{self.request.protocol}://{self.request.host}/',
|
||||
f'//{self.request.host}/',
|
||||
)
|
||||
) or (
|
||||
proto = get_browser_protocol(self.request)
|
||||
host = self.request.host
|
||||
|
||||
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
|
||||
self.subdomain_host
|
||||
and urlparse(next_url).netloc
|
||||
and ("." + urlparse(next_url).netloc).endswith(
|
||||
@@ -1449,54 +1449,24 @@ class UserUrlHandler(BaseHandler):
|
||||
delete = non_get
|
||||
|
||||
@web.authenticated
|
||||
@needs_scope("access:servers")
|
||||
async def get(self, user_name, user_path):
|
||||
if not user_path:
|
||||
user_path = '/'
|
||||
current_user = self.current_user
|
||||
|
||||
if (
|
||||
current_user
|
||||
and current_user.name != user_name
|
||||
and current_user.admin
|
||||
and self.settings.get('admin_access', False)
|
||||
):
|
||||
# allow admins to spawn on behalf of users
|
||||
if user_name != current_user.name:
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
# no such user
|
||||
raise web.HTTPError(404, "No such user %s" % user_name)
|
||||
raise web.HTTPError(404, f"No such user {user_name}")
|
||||
self.log.info(
|
||||
"Admin %s requesting spawn on behalf of %s",
|
||||
current_user.name,
|
||||
user.name,
|
||||
f"User {current_user.name} requesting spawn on behalf of {user.name}"
|
||||
)
|
||||
admin_spawn = True
|
||||
should_spawn = True
|
||||
redirect_to_self = False
|
||||
else:
|
||||
user = current_user
|
||||
admin_spawn = False
|
||||
# For non-admins, spawn if the user requested is the current user
|
||||
# otherwise redirect users to their own server
|
||||
should_spawn = current_user and current_user.name == user_name
|
||||
redirect_to_self = not should_spawn
|
||||
|
||||
if redirect_to_self:
|
||||
# logged in as a different non-admin user, redirect to user's own server
|
||||
# this is only a stop-gap for a common mistake,
|
||||
# because the same request will be a 403
|
||||
# if the requested server is running
|
||||
self.statsd.incr('redirects.user_to_user', 1)
|
||||
self.log.warning(
|
||||
"User %s requested server for %s, which they don't own",
|
||||
current_user.name,
|
||||
user_name,
|
||||
)
|
||||
target = url_path_join(current_user.url, user_path or '')
|
||||
if self.request.query:
|
||||
target = url_concat(target, parse_qsl(self.request.query))
|
||||
self.redirect(target)
|
||||
return
|
||||
|
||||
# If people visit /user/:user_name directly on the Hub,
|
||||
# the redirects will just loop, because the proxy is bypassed.
|
||||
|
@@ -12,6 +12,8 @@ class MetricsHandler(BaseHandler):
|
||||
Handler to serve Prometheus metrics
|
||||
"""
|
||||
|
||||
_accept_token_auth = True
|
||||
|
||||
@metrics_authentication
|
||||
async def get(self):
|
||||
self.set_header('Content-Type', CONTENT_TYPE_LATEST)
|
||||
|
@@ -106,22 +106,27 @@ class SpawnHandler(BaseHandler):
|
||||
)
|
||||
|
||||
@web.authenticated
|
||||
async def get(self, for_user=None, server_name=''):
|
||||
def get(self, user_name=None, server_name=''):
|
||||
"""GET renders form for spawning with user-specified options
|
||||
|
||||
or triggers spawn via redirect if there is no form.
|
||||
"""
|
||||
# two-stage to get the right signature for @require_scopes filter on user_name
|
||||
if user_name is None:
|
||||
user_name = self.current_user.name
|
||||
if server_name is None:
|
||||
server_name = ""
|
||||
return self._get(user_name=user_name, server_name=server_name)
|
||||
|
||||
@needs_scope("servers")
|
||||
async def _get(self, user_name, server_name):
|
||||
for_user = user_name
|
||||
|
||||
user = current_user = self.current_user
|
||||
if for_user is not None and for_user != user.name:
|
||||
if not user.admin:
|
||||
raise web.HTTPError(
|
||||
403, "Only admins can spawn on behalf of other users"
|
||||
)
|
||||
|
||||
if for_user != user.name:
|
||||
user = self.find_user(for_user)
|
||||
if user is None:
|
||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||
raise web.HTTPError(404, f"No such user: {for_user}")
|
||||
|
||||
if server_name:
|
||||
if not self.allow_named_servers:
|
||||
@@ -141,14 +146,11 @@ class SpawnHandler(BaseHandler):
|
||||
)
|
||||
|
||||
if not self.allow_named_servers and user.running:
|
||||
url = self.get_next_url(user, default=user.server_url(server_name))
|
||||
url = self.get_next_url(user, default=user.server_url(""))
|
||||
self.log.info("User is running: %s", user.name)
|
||||
self.redirect(url)
|
||||
return
|
||||
|
||||
if server_name is None:
|
||||
server_name = ''
|
||||
|
||||
spawner = user.spawners[server_name]
|
||||
|
||||
pending_url = self._get_pending_url(user, server_name)
|
||||
@@ -189,7 +191,6 @@ class SpawnHandler(BaseHandler):
|
||||
spawner._log_name,
|
||||
)
|
||||
options = await maybe_future(spawner.options_from_query(query_options))
|
||||
pending_url = self._get_pending_url(user, server_name)
|
||||
return await self._wrap_spawn_single_user(
|
||||
user, server_name, spawner, pending_url, options
|
||||
)
|
||||
@@ -219,14 +220,19 @@ class SpawnHandler(BaseHandler):
|
||||
)
|
||||
|
||||
@web.authenticated
|
||||
async def post(self, for_user=None, server_name=''):
|
||||
def post(self, user_name=None, server_name=''):
|
||||
"""POST spawns with user-specified options"""
|
||||
if user_name is None:
|
||||
user_name = self.current_user.name
|
||||
if server_name is None:
|
||||
server_name = ""
|
||||
return self._post(user_name=user_name, server_name=server_name)
|
||||
|
||||
@needs_scope("servers")
|
||||
async def _post(self, user_name, server_name):
|
||||
for_user = user_name
|
||||
user = current_user = self.current_user
|
||||
if for_user is not None and for_user != user.name:
|
||||
if not user.admin:
|
||||
raise web.HTTPError(
|
||||
403, "Only admins can spawn on behalf of other users"
|
||||
)
|
||||
if for_user != user.name:
|
||||
user = self.find_user(for_user)
|
||||
if user is None:
|
||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||
@@ -337,13 +343,11 @@ class SpawnPendingHandler(BaseHandler):
|
||||
"""
|
||||
|
||||
@web.authenticated
|
||||
async def get(self, for_user, server_name=''):
|
||||
@needs_scope("servers")
|
||||
async def get(self, user_name, server_name=''):
|
||||
for_user = user_name
|
||||
user = current_user = self.current_user
|
||||
if for_user is not None and for_user != current_user.name:
|
||||
if not current_user.admin:
|
||||
raise web.HTTPError(
|
||||
403, "Only admins can spawn on behalf of other users"
|
||||
)
|
||||
if for_user != current_user.name:
|
||||
user = self.find_user(for_user)
|
||||
if user is None:
|
||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||
@@ -387,6 +391,7 @@ class SpawnPendingHandler(BaseHandler):
|
||||
server_name=server_name,
|
||||
spawn_url=spawn_url,
|
||||
failed=True,
|
||||
failed_html_message=getattr(exc, 'jupyterhub_html_message', ''),
|
||||
failed_message=getattr(exc, 'jupyterhub_message', ''),
|
||||
exception=exc,
|
||||
)
|
||||
|
@@ -131,6 +131,9 @@ scope_definitions = {
|
||||
'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
|
||||
},
|
||||
'shutdown': {'description': 'Shutdown the hub.'},
|
||||
'read:metrics': {
|
||||
'description': "Read prometheus metrics.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
@@ -53,6 +53,7 @@ from traitlets import validate
|
||||
from traitlets.config import SingletonConfigurable
|
||||
|
||||
from ..scopes import _intersect_expanded_scopes
|
||||
from ..utils import get_browser_protocol
|
||||
from ..utils import url_path_join
|
||||
|
||||
|
||||
@@ -772,7 +773,7 @@ class HubOAuth(HubAuth):
|
||||
# OAuth that doesn't complete shouldn't linger too long.
|
||||
'max_age': 600,
|
||||
}
|
||||
if handler.request.protocol == 'https':
|
||||
if get_browser_protocol(handler.request) == 'https':
|
||||
kwargs['secure'] = True
|
||||
# load user cookie overrides
|
||||
kwargs.update(self.cookie_options)
|
||||
@@ -812,7 +813,7 @@ class HubOAuth(HubAuth):
|
||||
def set_cookie(self, handler, access_token):
|
||||
"""Set a cookie recording OAuth result"""
|
||||
kwargs = {'path': self.base_url, 'httponly': True}
|
||||
if handler.request.protocol == 'https':
|
||||
if get_browser_protocol(handler.request) == 'https':
|
||||
kwargs['secure'] = True
|
||||
# load user cookie overrides
|
||||
kwargs.update(self.cookie_options)
|
||||
|
@@ -98,27 +98,62 @@ async def test_post_content_type(app, content_type, status):
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"host, referer, status",
|
||||
"host, referer, extraheaders, status",
|
||||
[
|
||||
('$host', '$url', 200),
|
||||
(None, None, 200),
|
||||
(None, 'null', 403),
|
||||
(None, 'http://attack.com/csrf/vulnerability', 403),
|
||||
('$host', {"path": "/user/someuser"}, 403),
|
||||
('$host', {"path": "{path}/foo/bar/subpath"}, 200),
|
||||
('$host', '$url', {}, 200),
|
||||
(None, None, {}, 200),
|
||||
(None, 'null', {}, 403),
|
||||
(None, 'http://attack.com/csrf/vulnerability', {}, 403),
|
||||
('$host', {"path": "/user/someuser"}, {}, 403),
|
||||
('$host', {"path": "{path}/foo/bar/subpath"}, {}, 200),
|
||||
# mismatch host
|
||||
("mismatch.com", "$url", 403),
|
||||
("mismatch.com", "$url", {}, 403),
|
||||
# explicit host, matches
|
||||
("fake.example", {"netloc": "fake.example"}, 200),
|
||||
("fake.example", {"netloc": "fake.example"}, {}, 200),
|
||||
# explicit port, matches implicit port
|
||||
("fake.example:80", {"netloc": "fake.example"}, 200),
|
||||
("fake.example:80", {"netloc": "fake.example"}, {}, 200),
|
||||
# explicit port, mismatch
|
||||
("fake.example:81", {"netloc": "fake.example"}, 403),
|
||||
("fake.example:81", {"netloc": "fake.example"}, {}, 403),
|
||||
# implicit ports, mismatch proto
|
||||
("fake.example", {"netloc": "fake.example", "scheme": "https"}, 403),
|
||||
("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
|
||||
# explicit ports, match
|
||||
("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
|
||||
# Test proxy protocol defined headers taken into account by utils.get_browser_protocol
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{'X-Scheme': 'https'},
|
||||
200,
|
||||
),
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{'X-Forwarded-Proto': 'https'},
|
||||
200,
|
||||
),
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{
|
||||
'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
|
||||
'X-Scheme': 'http',
|
||||
},
|
||||
200,
|
||||
),
|
||||
(
|
||||
"fake.example",
|
||||
{"netloc": "fake.example", "scheme": "https"},
|
||||
{
|
||||
'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
|
||||
'X-Scheme': 'https',
|
||||
},
|
||||
403,
|
||||
),
|
||||
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
|
||||
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
|
||||
],
|
||||
)
|
||||
async def test_cors_check(request, app, host, referer, status):
|
||||
async def test_cors_check(request, app, host, referer, extraheaders, status):
|
||||
url = ujoin(public_host(app), app.hub.base_url)
|
||||
real_host = urlparse(url).netloc
|
||||
if host == "$host":
|
||||
@@ -140,6 +175,7 @@ async def test_cors_check(request, app, host, referer, status):
|
||||
headers['X-Forwarded-Host'] = host
|
||||
if referer is not None:
|
||||
headers['Referer'] = referer
|
||||
headers.update(extraheaders)
|
||||
|
||||
# add admin user
|
||||
user = find_user(app.db, 'admin')
|
||||
|
@@ -1,9 +1,13 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
from .utils import get_page
|
||||
from jupyterhub import metrics
|
||||
from jupyterhub import orm
|
||||
from jupyterhub import roles
|
||||
|
||||
|
||||
async def test_total_users(app):
|
||||
@@ -32,3 +36,42 @@ async def test_total_users(app):
|
||||
|
||||
sample = metrics.TOTAL_USERS.collect()[0].samples[0]
|
||||
assert sample.value == num_users
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"authenticate_prometheus, authenticated, authorized, success",
|
||||
[
|
||||
(True, True, True, True),
|
||||
(True, True, False, False),
|
||||
(True, False, False, False),
|
||||
(False, True, True, True),
|
||||
(False, False, False, True),
|
||||
],
|
||||
)
|
||||
async def test_metrics_auth(
|
||||
app,
|
||||
authenticate_prometheus,
|
||||
authenticated,
|
||||
authorized,
|
||||
success,
|
||||
create_temp_role,
|
||||
user,
|
||||
):
|
||||
if authorized:
|
||||
role = create_temp_role(["read:metrics"])
|
||||
roles.grant_role(app.db, user, role)
|
||||
|
||||
headers = {}
|
||||
if authenticated:
|
||||
token = user.new_api_token()
|
||||
headers["Authorization"] = f"token {token}"
|
||||
|
||||
with mock.patch.dict(
|
||||
app.tornado_settings, {"authenticate_prometheus": authenticate_prometheus}
|
||||
):
|
||||
r = await get_page("metrics", app, headers=headers)
|
||||
if success:
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
assert 'read:metrics' in r.text
|
||||
|
@@ -12,6 +12,7 @@ from tornado.escape import url_escape
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from .. import scopes
|
||||
from ..auth import Authenticator
|
||||
from ..handlers import BaseHandler
|
||||
@@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin
|
||||
from .mocking import FalsyCallableFormSpawner
|
||||
from .mocking import FormSpawner
|
||||
from .test_api import next_event
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
from .utils import async_requests
|
||||
from .utils import AsyncSession
|
||||
@@ -48,16 +48,16 @@ async def test_root_auth(app):
|
||||
# if spawning was quick, there will be one more entry that's public_url(user)
|
||||
|
||||
|
||||
async def test_root_redirect(app):
|
||||
async def test_root_redirect(app, user):
|
||||
name = 'wash'
|
||||
cookies = await app.login_user(name)
|
||||
next_url = ujoin(app.base_url, 'user/other/test.ipynb')
|
||||
next_url = ujoin(app.base_url, f'user/{user.name}/test.ipynb')
|
||||
url = '/?' + urlencode({'next': next_url})
|
||||
r = await get_page(url, app, cookies=cookies)
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||
# serve "server not running" page, which has status 424
|
||||
assert r.status_code == 424
|
||||
assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb')
|
||||
# preserves choice to requested user, which 404s as unavailable without access
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_root_default_url_noauth(app):
|
||||
@@ -203,13 +203,34 @@ async def test_spawn_handler_access(app):
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
async def test_spawn_admin_access(app, admin_access):
|
||||
"""GET /user/:name as admin with admin-access spawns user's server"""
|
||||
cookies = await app.login_user('admin')
|
||||
name = 'mariel'
|
||||
user = add_user(app.db, app=app, name=name)
|
||||
app.db.commit()
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_spawn_other_user(
|
||||
app, user, username, group, create_temp_role, has_access
|
||||
):
|
||||
"""GET /user/:name as another user with access to spawns user's server"""
|
||||
cookies = await app.login_user(username)
|
||||
requester = app.users[username]
|
||||
name = user.name
|
||||
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
f"servers!group={group.name}",
|
||||
]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers", "servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
|
||||
r = await get_page('spawn/' + name, app, cookies=cookies)
|
||||
if not has_access:
|
||||
assert r.status_code == 404
|
||||
return
|
||||
r.raise_for_status()
|
||||
|
||||
while '/spawn-pending/' in r.url:
|
||||
@@ -248,14 +269,36 @@ async def test_spawn_page_falsy_callable(app):
|
||||
assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik")
|
||||
|
||||
|
||||
async def test_spawn_page_admin(app, admin_access):
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_spawn_page_access(
|
||||
app, has_access, group, username, user, create_temp_role
|
||||
):
|
||||
cookies = await app.login_user(username)
|
||||
requester = app.users[username]
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
f"servers!group={group.name}",
|
||||
]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers", "servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
|
||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||
cookies = await app.login_user('admin')
|
||||
u = add_user(app.db, app=app, name='melanie')
|
||||
r = await get_page('spawn/' + u.name, app, cookies=cookies)
|
||||
assert r.url.endswith('/spawn/' + u.name)
|
||||
r = await get_page('spawn/' + user.name, app, cookies=cookies)
|
||||
if not has_access:
|
||||
assert r.status_code == 404
|
||||
return
|
||||
assert r.status_code == 200
|
||||
assert r.url.endswith('/spawn/' + user.name)
|
||||
assert FormSpawner.options_form in r.text
|
||||
assert f"Spawning server for {u.name}" in r.text
|
||||
assert f"Spawning server for {user.name}" in r.text
|
||||
|
||||
|
||||
async def test_spawn_with_query_arguments(app):
|
||||
@@ -322,18 +365,39 @@ async def test_spawn_form(app):
|
||||
}
|
||||
|
||||
|
||||
async def test_spawn_form_admin_access(app, admin_access):
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_spawn_form_other_user(
|
||||
app, username, user, group, create_temp_role, has_access
|
||||
):
|
||||
cookies = await app.login_user(username)
|
||||
requester = app.users[username]
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(user)
|
||||
app.db.commit()
|
||||
scopes = [
|
||||
f"access:servers!group={group.name}",
|
||||
f"servers!group={group.name}",
|
||||
]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers", "servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
|
||||
with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
|
||||
base_url = ujoin(public_host(app), app.hub.base_url)
|
||||
cookies = await app.login_user('admin')
|
||||
u = add_user(app.db, app=app, name='martha')
|
||||
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
|
||||
next_url = ujoin(app.base_url, 'user', user.name, 'tree')
|
||||
|
||||
r = await async_requests.post(
|
||||
url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}),
|
||||
url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}),
|
||||
cookies=cookies,
|
||||
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
|
||||
)
|
||||
if not has_access:
|
||||
assert r.status_code == 404
|
||||
return
|
||||
r.raise_for_status()
|
||||
|
||||
while '/spawn-pending/' in r.url:
|
||||
@@ -342,8 +406,8 @@ async def test_spawn_form_admin_access(app, admin_access):
|
||||
r.raise_for_status()
|
||||
|
||||
assert r.history
|
||||
assert r.url.startswith(public_url(app, u))
|
||||
assert u.spawner.user_options == {
|
||||
assert r.url.startswith(public_url(app, user))
|
||||
assert user.spawner.user_options == {
|
||||
'energy': '938MeV',
|
||||
'bounds': [-3, 3],
|
||||
'notspecified': 5,
|
||||
@@ -498,31 +562,54 @@ async def test_user_redirect_hook(app, username):
|
||||
assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
|
||||
|
||||
|
||||
async def test_user_redirect_deprecated(app, username):
|
||||
"""redirecting from /user/someonelse/ URLs (deprecated)"""
|
||||
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||
async def test_other_user_url(app, username, user, group, create_temp_role, has_access):
|
||||
"""Test accessing /user/someonelse/ URLs when the server is not running
|
||||
|
||||
Used to redirect to your own server,
|
||||
which produced inconsistent behavior depending on whether the server was running.
|
||||
"""
|
||||
name = username
|
||||
cookies = await app.login_user(name)
|
||||
other_user = user
|
||||
requester = app.users[name]
|
||||
other_user_url = f"/user/{other_user.name}"
|
||||
if has_access:
|
||||
if has_access == "group":
|
||||
group.users.append(other_user)
|
||||
app.db.commit()
|
||||
scopes = [f"access:servers!group={group.name}"]
|
||||
elif has_access == "all":
|
||||
scopes = ["access:servers"]
|
||||
elif has_access == "user":
|
||||
scopes = [f"access:servers!user={other_user.name}"]
|
||||
role = create_temp_role(scopes)
|
||||
roles.grant_role(app.db, requester, role)
|
||||
status = 424
|
||||
else:
|
||||
# 404 - access denied without revealing if the user exists
|
||||
status = 404
|
||||
|
||||
r = await get_page('/user/baduser', app, cookies=cookies, hub=False)
|
||||
r = await get_page(other_user_url, app, cookies=cookies, hub=False)
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
||||
assert r.status_code == 424
|
||||
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/')
|
||||
assert r.status_code == status
|
||||
|
||||
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
||||
r = await get_page(f'{other_user_url}/test.ipynb', app, cookies=cookies, hub=False)
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||
assert r.status_code == 424
|
||||
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb')
|
||||
assert r.status_code == status
|
||||
|
||||
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
|
||||
r = await get_page(f'{other_user_url}/test.ipynb', app, hub=False)
|
||||
r.raise_for_status()
|
||||
print(urlparse(r.url))
|
||||
path = urlparse(r.url).path
|
||||
assert path == ujoin(app.base_url, '/hub/login')
|
||||
query = urlparse(r.url).query
|
||||
assert query == urlencode(
|
||||
{'next': ujoin(app.base_url, '/hub/user/baduser/test.ipynb')}
|
||||
{'next': ujoin(app.base_url, f'/hub/user/{other_user.name}/test.ipynb')}
|
||||
)
|
||||
|
||||
|
||||
@@ -1110,19 +1197,6 @@ async def test_server_not_running_api_request_legacy_status(app):
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
async def test_metrics_no_auth(app):
|
||||
r = await get_page("metrics", app)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_metrics_auth(app):
|
||||
cookies = await app.login_user('river')
|
||||
metrics_url = ujoin(public_host(app), app.hub.base_url, 'metrics')
|
||||
r = await get_page("metrics", app, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
assert r.url == metrics_url
|
||||
|
||||
|
||||
async def test_health_check_request(app):
|
||||
r = await get_page('health', app)
|
||||
assert r.status_code == 200
|
||||
|
@@ -81,6 +81,18 @@ async def test_spawner(db, request):
|
||||
assert isinstance(status, int)
|
||||
|
||||
|
||||
def test_spawner_from_db(app, user):
|
||||
spawner = user.spawners['name']
|
||||
user_options = {"test": "value"}
|
||||
spawner.orm_spawner.user_options = user_options
|
||||
app.db.commit()
|
||||
# delete and recreate the spawner from the db
|
||||
user.spawners.pop('name')
|
||||
new_spawner = user.spawners['name']
|
||||
assert new_spawner.orm_spawner.user_options == user_options
|
||||
assert new_spawner.user_options == user_options
|
||||
|
||||
|
||||
async def wait_for_spawner(spawner, timeout=10):
|
||||
"""Wait for an http server to show up
|
||||
|
||||
|
@@ -2,12 +2,16 @@
|
||||
import asyncio
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from async_generator import aclosing
|
||||
from tornado import gen
|
||||
from tornado.concurrent import run_on_executor
|
||||
from tornado.httpserver import HTTPRequest
|
||||
from tornado.httputil import HTTPHeaders
|
||||
|
||||
from .. import utils
|
||||
from ..utils import iterate_until
|
||||
|
||||
|
||||
@@ -88,3 +92,33 @@ async def test_tornado_coroutines():
|
||||
# verify that tornado gen and executor methods return awaitables
|
||||
assert (await t.on_executor()) == "executor"
|
||||
assert (await t.tornado_coroutine()) == "gen.coroutine"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"forwarded, x_scheme, x_forwarded_proto, expected",
|
||||
[
|
||||
("", "", "", "_attr_"),
|
||||
("for=1.2.3.4", "", "", "_attr_"),
|
||||
("for=1.2.3.4,proto=https", "", "", "_attr_"),
|
||||
("", "https", "http", "https"),
|
||||
("", "https, http", "", "https"),
|
||||
("", "https, http", "http", "https"),
|
||||
("proto=http ; for=1.2.3.4, proto=https", "https, http", "", "http"),
|
||||
("proto=invalid;for=1.2.3.4,proto=http", "https, http", "", "https"),
|
||||
("for=1.2.3.4,proto=http", "https, http", "", "https"),
|
||||
("", "invalid, http", "", "_attr_"),
|
||||
],
|
||||
)
|
||||
def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
|
||||
request = Mock(spec=HTTPRequest)
|
||||
request.protocol = "_attr_"
|
||||
request.headers = HTTPHeaders()
|
||||
if x_scheme:
|
||||
request.headers["X-Scheme"] = x_scheme
|
||||
if x_forwarded_proto:
|
||||
request.headers["X-Forwarded-Proto"] = x_forwarded_proto
|
||||
if forwarded:
|
||||
request.headers["Forwarded"] = forwarded
|
||||
|
||||
proto = utils.get_browser_protocol(request)
|
||||
assert proto == expected
|
||||
|
@@ -376,6 +376,7 @@ class User:
|
||||
oauth_client_id=client_id,
|
||||
cookie_options=self.settings.get('cookie_options', {}),
|
||||
trusted_alt_names=trusted_alt_names,
|
||||
user_options=orm_spawner.user_options or {},
|
||||
)
|
||||
|
||||
if self.settings.get('internal_ssl'):
|
||||
|
@@ -320,9 +320,11 @@ def admin_only(f):
|
||||
@auth_decorator
|
||||
def metrics_authentication(self):
|
||||
"""Decorator for restricting access to metrics"""
|
||||
user = self.current_user
|
||||
if user is None and self.authenticate_prometheus:
|
||||
raise web.HTTPError(403)
|
||||
if not self.authenticate_prometheus:
|
||||
return
|
||||
scope = 'read:metrics'
|
||||
if scope not in self.parsed_scopes:
|
||||
raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
|
||||
|
||||
|
||||
# Token utilities
|
||||
@@ -355,7 +357,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
|
||||
h.update(btoken)
|
||||
digest = h.hexdigest()
|
||||
|
||||
return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals())
|
||||
return f"{algorithm}:{rounds}:{salt}:{digest}"
|
||||
|
||||
|
||||
def compare_token(compare, token):
|
||||
@@ -683,3 +685,44 @@ def catch_db_error(f):
|
||||
return r
|
||||
|
||||
return catching
|
||||
|
||||
|
||||
def get_browser_protocol(request):
|
||||
"""Get the _protocol_ seen by the browser
|
||||
|
||||
Like tornado's _apply_xheaders,
|
||||
but in the case of multiple proxy hops,
|
||||
use the outermost value (what the browser likely sees)
|
||||
instead of the innermost value,
|
||||
which is the most trustworthy.
|
||||
|
||||
We care about what the browser sees,
|
||||
not where the request actually came from,
|
||||
so trusting possible spoofs is the right thing to do.
|
||||
"""
|
||||
headers = request.headers
|
||||
# first choice: Forwarded header
|
||||
forwarded_header = headers.get("Forwarded")
|
||||
if forwarded_header:
|
||||
first_forwarded = forwarded_header.split(",", 1)[0].strip()
|
||||
fields = {}
|
||||
forwarded_dict = {}
|
||||
for field in first_forwarded.split(";"):
|
||||
key, _, value = field.partition("=")
|
||||
fields[key.strip().lower()] = value.strip()
|
||||
if "proto" in fields and fields["proto"].lower() in {"http", "https"}:
|
||||
return fields["proto"].lower()
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Forwarded header present without protocol: {forwarded_header}"
|
||||
)
|
||||
|
||||
# second choice: X-Scheme or X-Forwarded-Proto
|
||||
proto_header = headers.get("X-Scheme", headers.get("X-Forwarded-Proto", None))
|
||||
if proto_header:
|
||||
proto_header = proto_header.split(",")[0].strip().lower()
|
||||
if proto_header in {"http", "https"}:
|
||||
return proto_header
|
||||
|
||||
# no forwarded headers
|
||||
return request.protocol
|
||||
|
@@ -11,7 +11,7 @@ target_version = [
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "2.0.1"
|
||||
current = "2.1.0"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
@@ -1,20 +1,5 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% macro th(label, key='', colspan=1) %}
|
||||
<th data-sort="{{key}}" colspan="{{colspan}}">{{label}}
|
||||
{% if key %}
|
||||
<a href="#"><i class="fa {% if sort.get(key) == 'asc' -%}
|
||||
fa-sort-asc
|
||||
{%- elif sort.get(key) == 'desc' -%}
|
||||
fa-sort-desc
|
||||
{%- else -%}
|
||||
fa-sort
|
||||
{%- endif %} sort-icon">
|
||||
</i></a>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block main %}
|
||||
<div id="react-admin-hook">
|
||||
<script id="jupyterhub-admin-config">
|
||||
|
@@ -18,8 +18,10 @@
|
||||
<p>
|
||||
{% if failed %}
|
||||
The latest attempt to start your server {{ server_name }} has failed.
|
||||
{% if failed_message %}
|
||||
{{ failed_message }}
|
||||
{% if failed_html_message %}
|
||||
</p><p>{{ failed_html_message | safe }}</p><p>
|
||||
{% elif failed_message %}
|
||||
</p><p>{{ failed_message }}</p><p>
|
||||
{% endif %}
|
||||
Would you like to retry starting it?
|
||||
{% else %}
|
||||
|
Reference in New Issue
Block a user