|
"""Base Tornado handlers for the Jupyter server.""" |
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
|
import functools |
|
import inspect |
|
import ipaddress |
|
import json |
|
import mimetypes |
|
import os |
|
import re |
|
import types |
|
import warnings |
|
from http.client import responses |
|
from logging import Logger |
|
from typing import TYPE_CHECKING, Any, Awaitable, Coroutine, Sequence, cast |
|
from urllib.parse import urlparse |
|
|
|
import prometheus_client |
|
from jinja2 import TemplateNotFound |
|
from jupyter_core.paths import is_hidden |
|
from tornado import web |
|
from tornado.log import app_log |
|
from traitlets.config import Application |
|
|
|
import jupyter_server |
|
from jupyter_server import CallContext |
|
from jupyter_server._sysinfo import get_sys_info |
|
from jupyter_server._tz import utcnow |
|
from jupyter_server.auth.decorator import allow_unauthenticated, authorized |
|
from jupyter_server.auth.identity import User |
|
from jupyter_server.i18n import combine_translations |
|
from jupyter_server.services.security import csp_report_uri |
|
from jupyter_server.utils import ( |
|
ensure_async, |
|
filefind, |
|
url_escape, |
|
url_is_absolute, |
|
url_path_join, |
|
urldecode_unix_socket_path, |
|
) |
|
|
|
if TYPE_CHECKING: |
|
from jupyter_client.kernelspec import KernelSpecManager |
|
from jupyter_events import EventLogger |
|
from jupyter_server_terminals.terminalmanager import TerminalManager |
|
from tornado.concurrent import Future |
|
|
|
from jupyter_server.auth.authorizer import Authorizer |
|
from jupyter_server.auth.identity import IdentityProvider |
|
from jupyter_server.serverapp import ServerApp |
|
from jupyter_server.services.config.manager import ConfigManager |
|
from jupyter_server.services.contents.manager import ContentsManager |
|
from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager |
|
from jupyter_server.services.sessions.sessionmanager import SessionManager |
|
|
|
|
|
|
|
|
|
|
|
_sys_info_cache = None |
|
|
|
|
|
def json_sys_info(): |
|
"""Get sys info as json.""" |
|
global _sys_info_cache |
|
if _sys_info_cache is None: |
|
_sys_info_cache = json.dumps(get_sys_info()) |
|
return _sys_info_cache |
|
|
|
|
|
def log() -> Logger: |
|
"""Get the application log.""" |
|
if Application.initialized(): |
|
return cast(Logger, Application.instance().log) |
|
else: |
|
return app_log |
|
|
|
|
|
class AuthenticatedHandler(web.RequestHandler): |
|
"""A RequestHandler with an authenticated user.""" |
|
|
|
@property |
|
def base_url(self) -> str: |
|
return cast(str, self.settings.get("base_url", "/")) |
|
|
|
@property |
|
def content_security_policy(self) -> str: |
|
"""The default Content-Security-Policy header |
|
|
|
Can be overridden by defining Content-Security-Policy in settings['headers'] |
|
""" |
|
if "Content-Security-Policy" in self.settings.get("headers", {}): |
|
|
|
return cast(str, self.settings["headers"]["Content-Security-Policy"]) |
|
|
|
return "; ".join( |
|
[ |
|
"frame-ancestors 'self'", |
|
|
|
"report-uri " |
|
+ self.settings.get("csp_report_uri", url_path_join(self.base_url, csp_report_uri)), |
|
] |
|
) |
|
|
|
def set_default_headers(self) -> None: |
|
"""Set the default headers.""" |
|
headers = {} |
|
headers["X-Content-Type-Options"] = "nosniff" |
|
headers.update(self.settings.get("headers", {})) |
|
|
|
headers["Content-Security-Policy"] = self.content_security_policy |
|
|
|
|
|
for header_name, value in headers.items(): |
|
try: |
|
self.set_header(header_name, value) |
|
except Exception as e: |
|
|
|
|
|
|
|
self.log.exception( |
|
"Could not set default headers: %s", e |
|
) |
|
|
|
@property |
|
def cookie_name(self) -> str: |
|
warnings.warn( |
|
"""JupyterHandler.login_handler is deprecated in 2.0, |
|
use JupyterHandler.identity_provider. |
|
""", |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
return self.identity_provider.get_cookie_name(self) |
|
|
|
def force_clear_cookie(self, name: str, path: str = "/", domain: str | None = None) -> None: |
|
"""Force a cookie clear.""" |
|
warnings.warn( |
|
"""JupyterHandler.login_handler is deprecated in 2.0, |
|
use JupyterHandler.identity_provider. |
|
""", |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
self.identity_provider._force_clear_cookie(self, name, path=path, domain=domain) |
|
|
|
def clear_login_cookie(self) -> None: |
|
"""Clear a login cookie.""" |
|
warnings.warn( |
|
"""JupyterHandler.login_handler is deprecated in 2.0, |
|
use JupyterHandler.identity_provider. |
|
""", |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
self.identity_provider.clear_login_cookie(self) |
|
|
|
def get_current_user(self) -> str: |
|
"""Get the current user.""" |
|
clsname = self.__class__.__name__ |
|
msg = ( |
|
f"Calling `{clsname}.get_current_user()` directly is deprecated in jupyter-server 2.0." |
|
" Use `self.current_user` instead (works in all versions)." |
|
) |
|
if hasattr(self, "_jupyter_current_user"): |
|
|
|
warnings.warn( |
|
msg, |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
return cast(str, self._jupyter_current_user) |
|
|
|
raise RuntimeError(msg) |
|
|
|
def skip_check_origin(self) -> bool: |
|
"""Ask my login_handler if I should skip the origin_check |
|
|
|
For example: in the default LoginHandler, if a request is token-authenticated, |
|
origin checking should be skipped. |
|
""" |
|
if self.request.method == "OPTIONS": |
|
|
|
return True |
|
return not self.identity_provider.should_check_origin(self) |
|
|
|
@property |
|
def token_authenticated(self) -> bool: |
|
"""Have I been authenticated with a token?""" |
|
return self.identity_provider.is_token_authenticated(self) |
|
|
|
@property |
|
def logged_in(self) -> bool: |
|
"""Is a user currently logged in?""" |
|
user = self.current_user |
|
return bool(user and user != "anonymous") |
|
|
|
@property |
|
def login_handler(self) -> Any: |
|
"""Return the login handler for this application, if any.""" |
|
warnings.warn( |
|
"""JupyterHandler.login_handler is deprecated in 2.0, |
|
use JupyterHandler.identity_provider. |
|
""", |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
return self.identity_provider.login_handler_class |
|
|
|
@property |
|
def token(self) -> str | None: |
|
"""Return the login token for this application, if any.""" |
|
return self.identity_provider.token |
|
|
|
@property |
|
def login_available(self) -> bool: |
|
"""May a user proceed to log in? |
|
|
|
This returns True if login capability is available, irrespective of |
|
whether the user is already logged in or not. |
|
|
|
""" |
|
return cast(bool, self.identity_provider.login_available) |
|
|
|
@property |
|
def authorizer(self) -> Authorizer: |
|
if "authorizer" not in self.settings: |
|
warnings.warn( |
|
"The Tornado web application does not have an 'authorizer' defined " |
|
"in its settings. In future releases of jupyter_server, this will " |
|
"be a required key for all subclasses of `JupyterHandler`. For an " |
|
"example, see the jupyter_server source code for how to " |
|
"add an authorizer to the tornado settings: " |
|
"https://github.com/jupyter-server/jupyter_server/blob/" |
|
"653740cbad7ce0c8a8752ce83e4d3c2c754b13cb/jupyter_server/serverapp.py" |
|
"#L234-L256", |
|
stacklevel=2, |
|
) |
|
from jupyter_server.auth import AllowAllAuthorizer |
|
|
|
self.settings["authorizer"] = AllowAllAuthorizer( |
|
config=self.settings.get("config", None), |
|
identity_provider=self.identity_provider, |
|
) |
|
|
|
return cast("Authorizer", self.settings.get("authorizer")) |
|
|
|
@property |
|
def identity_provider(self) -> IdentityProvider: |
|
if "identity_provider" not in self.settings: |
|
warnings.warn( |
|
"The Tornado web application does not have an 'identity_provider' defined " |
|
"in its settings. In future releases of jupyter_server, this will " |
|
"be a required key for all subclasses of `JupyterHandler`. For an " |
|
"example, see the jupyter_server source code for how to " |
|
"add an identity provider to the tornado settings: " |
|
"https://github.com/jupyter-server/jupyter_server/blob/v2.0.0/" |
|
"jupyter_server/serverapp.py#L242", |
|
stacklevel=2, |
|
) |
|
from jupyter_server.auth import IdentityProvider |
|
|
|
|
|
self.settings["identity_provider"] = IdentityProvider( |
|
config=self.settings.get("config", None) |
|
) |
|
return cast("IdentityProvider", self.settings["identity_provider"]) |
|
|
|
|
|
class JupyterHandler(AuthenticatedHandler): |
|
"""Jupyter-specific extensions to authenticated handling |
|
|
|
Mostly property shortcuts to Jupyter-specific settings. |
|
""" |
|
|
|
@property |
|
def config(self) -> dict[str, Any] | None: |
|
return cast("dict[str, Any] | None", self.settings.get("config", None)) |
|
|
|
@property |
|
def log(self) -> Logger: |
|
"""use the Jupyter log by default, falling back on tornado's logger""" |
|
return log() |
|
|
|
@property |
|
def jinja_template_vars(self) -> dict[str, Any]: |
|
"""User-supplied values to supply to jinja templates.""" |
|
return cast("dict[str, Any]", self.settings.get("jinja_template_vars", {})) |
|
|
|
@property |
|
def serverapp(self) -> ServerApp | None: |
|
return cast("ServerApp | None", self.settings["serverapp"]) |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
def version_hash(self) -> str: |
|
"""The version hash to use for cache hints for static files""" |
|
return cast(str, self.settings.get("version_hash", "")) |
|
|
|
@property |
|
def mathjax_url(self) -> str: |
|
url = cast(str, self.settings.get("mathjax_url", "")) |
|
if not url or url_is_absolute(url): |
|
return url |
|
return url_path_join(self.base_url, url) |
|
|
|
@property |
|
def mathjax_config(self) -> str: |
|
return cast(str, self.settings.get("mathjax_config", "TeX-AMS-MML_HTMLorMML-full,Safe")) |
|
|
|
@property |
|
def default_url(self) -> str: |
|
return cast(str, self.settings.get("default_url", "")) |
|
|
|
@property |
|
def ws_url(self) -> str: |
|
return cast(str, self.settings.get("websocket_url", "")) |
|
|
|
@property |
|
def contents_js_source(self) -> str: |
|
self.log.debug( |
|
"Using contents: %s", |
|
self.settings.get("contents_js_source", "services/contents"), |
|
) |
|
return cast(str, self.settings.get("contents_js_source", "services/contents")) |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
def kernel_manager(self) -> AsyncMappingKernelManager: |
|
return cast("AsyncMappingKernelManager", self.settings["kernel_manager"]) |
|
|
|
@property |
|
def contents_manager(self) -> ContentsManager: |
|
return cast("ContentsManager", self.settings["contents_manager"]) |
|
|
|
@property |
|
def session_manager(self) -> SessionManager: |
|
return cast("SessionManager", self.settings["session_manager"]) |
|
|
|
@property |
|
def terminal_manager(self) -> TerminalManager: |
|
return cast("TerminalManager", self.settings["terminal_manager"]) |
|
|
|
@property |
|
def kernel_spec_manager(self) -> KernelSpecManager: |
|
return cast("KernelSpecManager", self.settings["kernel_spec_manager"]) |
|
|
|
@property |
|
def config_manager(self) -> ConfigManager: |
|
return cast("ConfigManager", self.settings["config_manager"]) |
|
|
|
@property |
|
def event_logger(self) -> EventLogger: |
|
return cast("EventLogger", self.settings["event_logger"]) |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
def allow_origin(self) -> str: |
|
"""Normal Access-Control-Allow-Origin""" |
|
return cast(str, self.settings.get("allow_origin", "")) |
|
|
|
@property |
|
def allow_origin_pat(self) -> str | None: |
|
"""Regular expression version of allow_origin""" |
|
return cast("str | None", self.settings.get("allow_origin_pat", None)) |
|
|
|
@property |
|
def allow_credentials(self) -> bool: |
|
"""Whether to set Access-Control-Allow-Credentials""" |
|
return cast(bool, self.settings.get("allow_credentials", False)) |
|
|
|
def set_default_headers(self) -> None: |
|
"""Add CORS headers, if defined""" |
|
super().set_default_headers() |
|
|
|
def set_cors_headers(self) -> None: |
|
"""Add CORS headers, if defined |
|
|
|
Now that current_user is async (jupyter-server 2.0), |
|
must be called at the end of prepare(), instead of in set_default_headers. |
|
""" |
|
if self.allow_origin: |
|
self.set_header("Access-Control-Allow-Origin", self.allow_origin) |
|
elif self.allow_origin_pat: |
|
origin = self.get_origin() |
|
if origin and re.match(self.allow_origin_pat, origin): |
|
self.set_header("Access-Control-Allow-Origin", origin) |
|
elif self.token_authenticated and "Access-Control-Allow-Origin" not in self.settings.get( |
|
"headers", {} |
|
): |
|
|
|
|
|
self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", "")) |
|
|
|
if self.allow_credentials: |
|
self.set_header("Access-Control-Allow-Credentials", "true") |
|
|
|
def set_attachment_header(self, filename: str) -> None: |
|
"""Set Content-Disposition: attachment header |
|
|
|
As a method to ensure handling of filename encoding |
|
""" |
|
escaped_filename = url_escape(filename) |
|
self.set_header( |
|
"Content-Disposition", |
|
f"attachment; filename*=utf-8''{escaped_filename}", |
|
) |
|
|
|
def get_origin(self) -> str | None: |
|
|
|
|
|
|
|
|
|
if "Origin" in self.request.headers: |
|
origin = self.request.headers.get("Origin") |
|
else: |
|
origin = self.request.headers.get("Sec-Websocket-Origin", None) |
|
return origin |
|
|
|
|
|
|
|
def check_origin(self, origin_to_satisfy_tornado: str = "") -> bool: |
|
"""Check Origin for cross-site API requests, including websockets |
|
|
|
Copied from WebSocket with changes: |
|
|
|
- allow unspecified host/origin (e.g. scripts) |
|
- allow token-authenticated requests |
|
""" |
|
if self.allow_origin == "*" or self.skip_check_origin(): |
|
return True |
|
|
|
host = self.request.headers.get("Host") |
|
origin = self.request.headers.get("Origin") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if origin is None or host is None: |
|
return True |
|
|
|
origin = origin.lower() |
|
origin_host = urlparse(origin).netloc |
|
|
|
|
|
if origin_host == host: |
|
return True |
|
|
|
|
|
if self.allow_origin: |
|
allow = bool(self.allow_origin == origin) |
|
elif self.allow_origin_pat: |
|
allow = bool(re.match(self.allow_origin_pat, origin)) |
|
else: |
|
|
|
allow = False |
|
if not allow: |
|
self.log.warning( |
|
"Blocking Cross Origin API request for %s. Origin: %s, Host: %s", |
|
self.request.path, |
|
origin, |
|
host, |
|
) |
|
return allow |
|
|
|
def check_referer(self) -> bool: |
|
"""Check Referer for cross-site requests. |
|
Disables requests to certain endpoints with |
|
external or missing Referer. |
|
If set, allow_origin settings are applied to the Referer |
|
to whitelist specific cross-origin sites. |
|
Used on GET for api endpoints and /files/ |
|
to block cross-site inclusion (XSSI). |
|
""" |
|
if self.allow_origin == "*" or self.skip_check_origin(): |
|
return True |
|
|
|
host = self.request.headers.get("Host") |
|
referer = self.request.headers.get("Referer") |
|
|
|
if not host: |
|
self.log.warning("Blocking request with no host") |
|
return False |
|
if not referer: |
|
self.log.warning("Blocking request with no referer") |
|
return False |
|
|
|
referer_url = urlparse(referer) |
|
referer_host = referer_url.netloc |
|
if referer_host == host: |
|
return True |
|
|
|
|
|
origin = f"{referer_url.scheme}://{referer_url.netloc}" |
|
if self.allow_origin: |
|
allow = self.allow_origin == origin |
|
elif self.allow_origin_pat: |
|
allow = bool(re.match(self.allow_origin_pat, origin)) |
|
else: |
|
|
|
allow = False |
|
|
|
if not allow: |
|
self.log.warning( |
|
"Blocking Cross Origin request for %s. Referer: %s, Host: %s", |
|
self.request.path, |
|
origin, |
|
host, |
|
) |
|
return allow |
|
|
|
def check_xsrf_cookie(self) -> None: |
|
"""Bypass xsrf cookie checks when token-authenticated""" |
|
if not hasattr(self, "_jupyter_current_user"): |
|
|
|
return None |
|
if self.token_authenticated or self.settings.get("disable_check_xsrf", False): |
|
|
|
|
|
return None |
|
try: |
|
return super().check_xsrf_cookie() |
|
except web.HTTPError as e: |
|
if self.request.method in {"GET", "HEAD"}: |
|
|
|
if not self.check_referer(): |
|
referer = self.request.headers.get("Referer") |
|
if referer: |
|
msg = f"Blocking Cross Origin request from {referer}." |
|
else: |
|
msg = "Blocking request from unknown origin" |
|
raise web.HTTPError(403, msg) from e |
|
else: |
|
raise |
|
|
|
def check_host(self) -> bool: |
|
"""Check the host header if remote access disallowed. |
|
|
|
Returns True if the request should continue, False otherwise. |
|
""" |
|
if self.settings.get("allow_remote_access", False): |
|
return True |
|
|
|
|
|
match = re.match(r"^(.*?)(:\d+)?$", self.request.host) |
|
assert match is not None |
|
host = match.group(1) |
|
|
|
|
|
if host.startswith("[") and host.endswith("]"): |
|
host = host[1:-1] |
|
|
|
|
|
check_host = urldecode_unix_socket_path(host) |
|
if check_host.startswith("/") and os.path.exists(check_host): |
|
allow = True |
|
else: |
|
try: |
|
addr = ipaddress.ip_address(host) |
|
except ValueError: |
|
|
|
allow = host in self.settings.get("local_hostnames", ["localhost"]) |
|
else: |
|
allow = addr.is_loopback |
|
|
|
if not allow: |
|
self.log.warning( |
|
( |
|
"Blocking request with non-local 'Host' %s (%s). " |
|
"If the server should be accessible at that name, " |
|
"set ServerApp.allow_remote_access to disable the check." |
|
), |
|
host, |
|
self.request.host, |
|
) |
|
return allow |
|
|
|
async def prepare(self, *, _redirect_to_login=True) -> Awaitable[None] | None: |
|
"""Prepare a response.""" |
|
|
|
CallContext.set(CallContext.JUPYTER_HANDLER, self) |
|
|
|
if not self.check_host(): |
|
self.current_user = self._jupyter_current_user = None |
|
raise web.HTTPError(403) |
|
|
|
from jupyter_server.auth import IdentityProvider |
|
|
|
mod_obj = inspect.getmodule(self.get_current_user) |
|
assert mod_obj is not None |
|
user: User | None = None |
|
|
|
if type(self.identity_provider) is IdentityProvider and mod_obj.__name__ != __name__: |
|
|
|
|
|
|
|
warnings.warn( |
|
"Overriding JupyterHandler.get_current_user is deprecated in jupyter-server 2.0." |
|
" Use an IdentityProvider class.", |
|
DeprecationWarning, |
|
stacklevel=1, |
|
) |
|
user = User(self.get_current_user()) |
|
else: |
|
_user = self.identity_provider.get_user(self) |
|
if isinstance(_user, Awaitable): |
|
|
|
_user = await _user |
|
user = _user |
|
|
|
|
|
|
|
|
|
self.current_user = self._jupyter_current_user = user |
|
|
|
self.set_cors_headers() |
|
if self.request.method not in {"GET", "HEAD", "OPTIONS"}: |
|
self.check_xsrf_cookie() |
|
|
|
if not self.settings.get("allow_unauthenticated_access", False): |
|
if not self.request.method: |
|
raise HTTPError(403) |
|
method = getattr(self, self.request.method.lower()) |
|
if not getattr(method, "__allow_unauthenticated", False): |
|
if _redirect_to_login: |
|
|
|
|
|
return web.authenticated(lambda _: super().prepare())(self) |
|
else: |
|
|
|
user = self.current_user |
|
if user is None: |
|
self.log.warning( |
|
f"Couldn't authenticate {self.__class__.__name__} connection" |
|
) |
|
raise web.HTTPError(403) |
|
|
|
return super().prepare() |
|
|
|
|
|
|
|
|
|
|
|
def get_template(self, name): |
|
"""Return the jinja template object for a given name""" |
|
return self.settings["jinja2_env"].get_template(name) |
|
|
|
def render_template(self, name, **ns): |
|
"""Render a template by name.""" |
|
ns.update(self.template_namespace) |
|
template = self.get_template(name) |
|
return template.render(**ns) |
|
|
|
@property |
|
def template_namespace(self) -> dict[str, Any]: |
|
return dict( |
|
base_url=self.base_url, |
|
default_url=self.default_url, |
|
ws_url=self.ws_url, |
|
logged_in=self.logged_in, |
|
allow_password_change=getattr(self.identity_provider, "allow_password_change", False), |
|
auth_enabled=self.identity_provider.auth_enabled, |
|
login_available=self.identity_provider.login_available, |
|
token_available=bool(self.token), |
|
static_url=self.static_url, |
|
sys_info=json_sys_info(), |
|
contents_js_source=self.contents_js_source, |
|
version_hash=self.version_hash, |
|
xsrf_form_html=self.xsrf_form_html, |
|
token=self.token, |
|
xsrf_token=self.xsrf_token.decode("utf8"), |
|
nbjs_translations=json.dumps( |
|
combine_translations(self.request.headers.get("Accept-Language", "")) |
|
), |
|
**self.jinja_template_vars, |
|
) |
|
|
|
def get_json_body(self) -> dict[str, Any] | None: |
|
"""Return the body of the request as JSON data.""" |
|
if not self.request.body: |
|
return None |
|
|
|
body = self.request.body.strip().decode("utf-8") |
|
try: |
|
model = json.loads(body) |
|
except Exception as e: |
|
self.log.debug("Bad JSON: %r", body) |
|
self.log.error("Couldn't parse JSON", exc_info=True) |
|
raise web.HTTPError(400, "Invalid JSON in body of request") from e |
|
return cast("dict[str, Any]", model) |
|
|
|
def write_error(self, status_code: int, **kwargs: Any) -> None: |
|
"""render custom error pages""" |
|
exc_info = kwargs.get("exc_info") |
|
message = "" |
|
status_message = responses.get(status_code, "Unknown HTTP Error") |
|
|
|
if exc_info: |
|
exception = exc_info[1] |
|
|
|
try: |
|
message = exception.log_message % exception.args |
|
except Exception: |
|
pass |
|
|
|
|
|
reason = getattr(exception, "reason", "") |
|
if reason: |
|
status_message = reason |
|
else: |
|
exception = "(unknown)" |
|
|
|
|
|
ns = { |
|
"status_code": status_code, |
|
"status_message": status_message, |
|
"message": message, |
|
"exception": exception, |
|
} |
|
|
|
self.set_header("Content-Type", "text/html") |
|
|
|
try: |
|
html = self.render_template("%s.html" % status_code, **ns) |
|
except TemplateNotFound: |
|
html = self.render_template("error.html", **ns) |
|
|
|
self.write(html) |
|
|
|
|
|
class APIHandler(JupyterHandler): |
|
"""Base class for API handlers""" |
|
|
|
async def prepare(self) -> None: |
|
"""Prepare an API response.""" |
|
await super().prepare() |
|
if not self.check_origin(): |
|
raise web.HTTPError(404) |
|
|
|
def write_error(self, status_code: int, **kwargs: Any) -> None: |
|
"""APIHandler errors are JSON, not human pages""" |
|
self.set_header("Content-Type", "application/json") |
|
message = responses.get(status_code, "Unknown HTTP Error") |
|
reply: dict[str, Any] = { |
|
"message": message, |
|
} |
|
exc_info = kwargs.get("exc_info") |
|
if exc_info: |
|
e = exc_info[1] |
|
if isinstance(e, HTTPError): |
|
reply["message"] = e.log_message or message |
|
reply["reason"] = e.reason |
|
else: |
|
reply["message"] = "Unhandled error" |
|
reply["reason"] = None |
|
|
|
|
|
reply["traceback"] = "" |
|
self.log.warning("wrote error: %r", reply["message"], exc_info=True) |
|
self.finish(json.dumps(reply)) |
|
|
|
def get_login_url(self) -> str: |
|
"""Get the login url.""" |
|
|
|
|
|
|
|
if not self.current_user: |
|
raise web.HTTPError(403) |
|
return super().get_login_url() |
|
|
|
@property |
|
def content_security_policy(self) -> str: |
|
csp = "; ".join( |
|
[ |
|
super().content_security_policy, |
|
"default-src 'none'", |
|
] |
|
) |
|
return csp |
|
|
|
|
|
_track_activity = True |
|
|
|
def update_api_activity(self) -> None: |
|
"""Update last_activity of API requests""" |
|
|
|
if ( |
|
self._track_activity |
|
and getattr(self, "_jupyter_current_user", None) |
|
and self.get_argument("no_track_activity", None) is None |
|
): |
|
self.settings["api_last_activity"] = utcnow() |
|
|
|
def finish(self, *args: Any, **kwargs: Any) -> Future[Any]: |
|
"""Finish an API response.""" |
|
self.update_api_activity() |
|
|
|
set_content_type = kwargs.pop("set_content_type", "application/json") |
|
self.set_header("Content-Type", set_content_type) |
|
return super().finish(*args, **kwargs) |
|
|
|
@allow_unauthenticated |
|
def options(self, *args: Any, **kwargs: Any) -> None: |
|
"""Get the options.""" |
|
if "Access-Control-Allow-Headers" in self.settings.get("headers", {}): |
|
self.set_header( |
|
"Access-Control-Allow-Headers", |
|
self.settings["headers"]["Access-Control-Allow-Headers"], |
|
) |
|
else: |
|
self.set_header( |
|
"Access-Control-Allow-Headers", |
|
"accept, content-type, authorization, x-xsrftoken", |
|
) |
|
self.set_header("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, DELETE, OPTIONS") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requested_headers = self.request.headers.get("Access-Control-Request-Headers", "").split( |
|
"," |
|
) |
|
if ( |
|
requested_headers |
|
and any(h.strip().lower() == "authorization" for h in requested_headers) |
|
and ( |
|
|
|
|
|
self.login_available |
|
) |
|
and ( |
|
self.allow_origin |
|
or self.allow_origin_pat |
|
or "Access-Control-Allow-Origin" in self.settings.get("headers", {}) |
|
) |
|
): |
|
self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", "")) |
|
|
|
|
|
class Template404(JupyterHandler): |
|
"""Render our 404 template""" |
|
|
|
async def prepare(self) -> None: |
|
"""Prepare a 404 response.""" |
|
await super().prepare() |
|
raise web.HTTPError(404) |
|
|
|
|
|
class AuthenticatedFileHandler(JupyterHandler, web.StaticFileHandler): |
|
"""static files should only be accessible when logged in""" |
|
|
|
auth_resource = "contents" |
|
|
|
@property |
|
def content_security_policy(self) -> str: |
|
|
|
|
|
return super().content_security_policy + "; sandbox allow-scripts" |
|
|
|
@web.authenticated |
|
@authorized |
|
def head(self, path: str) -> Awaitable[None]: |
|
"""Get the head response for a path.""" |
|
self.check_xsrf_cookie() |
|
return super().head(path) |
|
|
|
@web.authenticated |
|
@authorized |
|
def get( |
|
self, path: str, **kwargs: Any |
|
) -> Awaitable[None]: |
|
"""Get a file by path.""" |
|
self.check_xsrf_cookie() |
|
if os.path.splitext(path)[1] == ".ipynb" or self.get_argument("download", None): |
|
name = path.rsplit("/", 1)[-1] |
|
self.set_attachment_header(name) |
|
|
|
return web.StaticFileHandler.get(self, path, **kwargs) |
|
|
|
def get_content_type(self) -> str: |
|
"""Get the content type.""" |
|
assert self.absolute_path is not None |
|
path = self.absolute_path.strip("/") |
|
if "/" in path: |
|
_, name = path.rsplit("/", 1) |
|
else: |
|
name = path |
|
if name.endswith(".ipynb"): |
|
return "application/x-ipynb+json" |
|
else: |
|
cur_mime = mimetypes.guess_type(name)[0] |
|
if cur_mime == "text/plain": |
|
return "text/plain; charset=UTF-8" |
|
else: |
|
return super().get_content_type() |
|
|
|
def set_headers(self) -> None: |
|
"""Set the headers.""" |
|
super().set_headers() |
|
|
|
if "v" not in self.request.arguments: |
|
self.add_header("Cache-Control", "no-cache") |
|
|
|
def compute_etag(self) -> str | None: |
|
"""Compute the etag.""" |
|
return None |
|
|
|
def validate_absolute_path(self, root: str, absolute_path: str) -> str: |
|
"""Validate and return the absolute path. |
|
|
|
Requires tornado 3.1 |
|
|
|
Adding to tornado's own handling, forbids the serving of hidden files. |
|
""" |
|
abs_path = super().validate_absolute_path(root, absolute_path) |
|
abs_root = os.path.abspath(root) |
|
assert abs_path is not None |
|
if not self.contents_manager.allow_hidden and is_hidden(abs_path, abs_root): |
|
self.log.info( |
|
"Refusing to serve hidden file, via 404 Error, use flag 'ContentsManager.allow_hidden' to enable" |
|
) |
|
raise web.HTTPError(404) |
|
return abs_path |
|
|
|
|
|
def json_errors(method: Any) -> Any: |
|
"""Decorate methods with this to return GitHub style JSON errors. |
|
|
|
This should be used on any JSON API on any handler method that can raise HTTPErrors. |
|
|
|
This will grab the latest HTTPError exception using sys.exc_info |
|
and then: |
|
|
|
1. Set the HTTP status code based on the HTTPError |
|
2. Create and return a JSON body with a message field describing |
|
the error in a human readable form. |
|
""" |
|
warnings.warn( |
|
"@json_errors is deprecated in notebook 5.2.0. Subclass APIHandler instead.", |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
|
|
@functools.wraps(method) |
|
def wrapper(self, *args, **kwargs): |
|
self.write_error = types.MethodType(APIHandler.write_error, self) |
|
return method(self, *args, **kwargs) |
|
|
|
return wrapper |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HTTPError = web.HTTPError |
|
|
|
|
|
class FileFindHandler(JupyterHandler, web.StaticFileHandler): |
|
"""subclass of StaticFileHandler for serving files from a search path |
|
|
|
The setting "static_immutable_cache" can be set up to serve some static |
|
file as immutable (e.g. file name containing a hash). The setting is a |
|
list of base URL, every static file URL starting with one of those will |
|
be immutable. |
|
""" |
|
|
|
|
|
_static_paths: dict[str, str] = {} |
|
root: tuple[str] |
|
|
|
def set_headers(self) -> None: |
|
"""Set the headers.""" |
|
super().set_headers() |
|
|
|
immutable_paths = self.settings.get("static_immutable_cache", []) |
|
|
|
|
|
if any(self.request.path.startswith(path) for path in immutable_paths): |
|
self.set_header("Cache-Control", "public, max-age=31536000, immutable") |
|
|
|
|
|
elif "v" not in self.request.arguments or any( |
|
self.request.path.startswith(path) for path in self.no_cache_paths |
|
): |
|
self.set_header("Cache-Control", "no-cache") |
|
|
|
def initialize( |
|
self, |
|
path: str | list[str], |
|
default_filename: str | None = None, |
|
no_cache_paths: list[str] | None = None, |
|
) -> None: |
|
"""Initialize the file find handler.""" |
|
self.no_cache_paths = no_cache_paths or [] |
|
|
|
if isinstance(path, str): |
|
path = [path] |
|
|
|
self.root = tuple(os.path.abspath(os.path.expanduser(p)) + os.sep for p in path) |
|
self.default_filename = default_filename |
|
|
|
def compute_etag(self) -> str | None: |
|
"""Compute the etag.""" |
|
return None |
|
|
|
|
|
|
|
@allow_unauthenticated |
|
def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]: |
|
return super().get(path, include_body) |
|
|
|
|
|
|
|
@allow_unauthenticated |
|
def head(self, path: str) -> Awaitable[None]: |
|
return super().head(path) |
|
|
|
@classmethod |
|
def get_absolute_path(cls, roots: Sequence[str], path: str) -> str: |
|
"""locate a file to serve on our static file search path""" |
|
with cls._lock: |
|
if path in cls._static_paths: |
|
return cls._static_paths[path] |
|
try: |
|
abspath = os.path.abspath(filefind(path, roots)) |
|
except OSError: |
|
|
|
return "" |
|
|
|
cls._static_paths[path] = abspath |
|
|
|
log().debug(f"Path {path} served from {abspath}") |
|
return abspath |
|
|
|
def validate_absolute_path(self, root: str, absolute_path: str) -> str | None: |
|
"""check if the file should be served (raises 404, 403, etc.)""" |
|
if not absolute_path: |
|
raise web.HTTPError(404) |
|
|
|
for root in self.root: |
|
if (absolute_path + os.sep).startswith(root): |
|
break |
|
|
|
return super().validate_absolute_path(root, absolute_path) |
|
|
|
|
|
class APIVersionHandler(APIHandler): |
|
"""An API handler for the server version.""" |
|
|
|
_track_activity = False |
|
|
|
@allow_unauthenticated |
|
def get(self) -> None: |
|
"""Get the server version info.""" |
|
|
|
self.finish(json.dumps({"version": jupyter_server.__version__})) |
|
|
|
|
|
class TrailingSlashHandler(web.RequestHandler): |
|
"""Simple redirect handler that strips trailing slashes |
|
|
|
This should be the first, highest priority handler. |
|
""" |
|
|
|
@allow_unauthenticated |
|
def get(self) -> None: |
|
"""Handle trailing slashes in a get.""" |
|
assert self.request.uri is not None |
|
path, *rest = self.request.uri.partition("?") |
|
|
|
|
|
path = "/" + path.strip("/") |
|
new_uri = "".join([path, *rest]) |
|
self.redirect(new_uri) |
|
|
|
post = put = get |
|
|
|
|
|
class MainHandler(JupyterHandler): |
|
"""Simple handler for base_url.""" |
|
|
|
@allow_unauthenticated |
|
def get(self) -> None: |
|
"""Get the main template.""" |
|
html = self.render_template("main.html") |
|
self.write(html) |
|
|
|
post = put = get |
|
|
|
|
|
class FilesRedirectHandler(JupyterHandler): |
|
"""Handler for redirecting relative URLs to the /files/ handler""" |
|
|
|
@staticmethod |
|
async def redirect_to_files(self: Any, path: str) -> None: |
|
"""make redirect logic a reusable static method |
|
|
|
so it can be called from other handlers. |
|
""" |
|
cm = self.contents_manager |
|
if await ensure_async(cm.dir_exists(path)): |
|
|
|
url = url_path_join(self.base_url, "tree", url_escape(path)) |
|
else: |
|
orig_path = path |
|
|
|
parts = path.split("/") |
|
|
|
if not await ensure_async(cm.file_exists(path=path)) and "files" in parts: |
|
|
|
|
|
self.log.warning("Deprecated files/ URL: %s", orig_path) |
|
parts.remove("files") |
|
path = "/".join(parts) |
|
|
|
if not await ensure_async(cm.file_exists(path=path)): |
|
raise web.HTTPError(404) |
|
|
|
url = url_path_join(self.base_url, "files", url_escape(path)) |
|
self.log.debug("Redirecting %s to %s", self.request.path, url) |
|
self.redirect(url) |
|
|
|
@allow_unauthenticated |
|
async def get(self, path: str = "") -> None: |
|
return await self.redirect_to_files(self, path) |
|
|
|
|
|
class RedirectWithParams(web.RequestHandler): |
|
"""Same as web.RedirectHandler, but preserves URL parameters""" |
|
|
|
def initialize(self, url: str, permanent: bool = True) -> None: |
|
"""Initialize a redirect handler.""" |
|
self._url = url |
|
self._permanent = permanent |
|
|
|
@allow_unauthenticated |
|
def get(self) -> None: |
|
"""Get a redirect.""" |
|
sep = "&" if "?" in self._url else "?" |
|
url = sep.join([self._url, self.request.query]) |
|
self.redirect(url, permanent=self._permanent) |
|
|
|
|
|
class PrometheusMetricsHandler(JupyterHandler): |
|
""" |
|
Return prometheus metrics for this server |
|
""" |
|
|
|
@allow_unauthenticated |
|
def get(self) -> None: |
|
"""Get prometheus metrics.""" |
|
if self.settings["authenticate_prometheus"] and not self.logged_in: |
|
raise web.HTTPError(403) |
|
|
|
self.set_header("Content-Type", prometheus_client.CONTENT_TYPE_LATEST) |
|
self.write(prometheus_client.generate_latest(prometheus_client.REGISTRY)) |
|
|
|
|
|
class PublicStaticFileHandler(web.StaticFileHandler): |
|
"""Same as web.StaticFileHandler, but decorated to acknowledge that auth is not required.""" |
|
|
|
@allow_unauthenticated |
|
def head(self, path: str) -> Awaitable[None]: |
|
return super().head(path) |
|
|
|
@allow_unauthenticated |
|
def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]: |
|
return super().get(path, include_body) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))" |
|
|
|
|
|
|
|
|
|
|
|
|
|
default_handlers = [ |
|
(r".*/", TrailingSlashHandler), |
|
(r"api", APIVersionHandler), |
|
(r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler), |
|
(r"/metrics", PrometheusMetricsHandler), |
|
] |
|
|