diff --git a/panel/io/application.py b/panel/io/application.py index 90efa39693..f256281e6b 100644 --- a/panel/io/application.py +++ b/panel/io/application.py @@ -12,6 +12,7 @@ from typing import ( TYPE_CHECKING, Any, Callable, Mapping, ) +from urllib.parse import urljoin import bokeh.command.util @@ -121,8 +122,30 @@ def add(self, handler: Handler) -> None: handler._on_session_destroyed = _on_session_destroyed super().add(handler) + def _set_session_prefix(self, doc): + session_context = doc.session_context + if not (session_context and session_context.server_context): + return + request = session_context.request + app_context = session_context.server_context.application_context + prefix = request.uri.replace(app_context._url, '') + if not prefix.endswith('/'): + prefix += '/' + base_url = urljoin('/', prefix) + rel_path = '/'.join(['..'] * app_context._url.strip('/').count('/')) + + # Handle autoload.js absolute paths + abs_url = request.arguments.get('bokeh-absolute-url') + if abs_url: + rel_path = abs_url[0].decode('utf-8').replace(app_context._url, '') + + with set_curdoc(doc): + state.base_url = base_url + state.rel_path = rel_path + def initialize_document(self, doc): logger.info(LOG_SESSION_LAUNCHING, id(doc)) + self._set_session_prefix(doc) super().initialize_document(doc) if doc in state._templates and doc not in state._templates[doc]._documents: template = state._templates[doc] diff --git a/panel/io/server.py b/panel/io/server.py index e2b38dfca2..78107b388c 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -14,13 +14,12 @@ import sys import uuid -from contextlib import contextmanager from functools import partial, wraps from html import escape from typing import ( TYPE_CHECKING, Any, Callable, Mapping, Optional, ) -from urllib.parse import urljoin, urlparse +from urllib.parse import urlparse import bokeh import param @@ -56,7 +55,7 @@ # Internal imports from ..config import config -from ..util import edit_readonly, fullpath +from ..util import fullpath from ..util.warnings import warn from .application import build_applications from .document import ( # noqa @@ -325,32 +324,6 @@ async def stop_autoreload(): bokeh.server.server.Server = Server -class SessionPrefixHandler: - - @contextmanager - def _session_prefix(self): - prefix = self.request.uri.replace(self.application_context._url, '') - if not prefix.endswith('/'): - prefix += '/' - base_url = urljoin('/', prefix) - rel_path = '/'.join(['..'] * self.application_context._url.strip('/').count('/')) - old_url, old_rel = state.base_url, state.rel_path - - # Handle autoload.js absolute paths - abs_url = self.get_argument('bokeh-absolute-url', default=None) - if abs_url is not None: - rel_path = abs_url.replace(self.application_context._url, '') - - with edit_readonly(state): - state.base_url = base_url - state.rel_path = rel_path - try: - yield - finally: - with edit_readonly(state): - state.base_url = old_url - state.rel_path = old_rel - class LoginUrlMixin: """ Overrides the AuthRequestHandler.get_login_url implementation to @@ -369,7 +342,7 @@ def get_login_url(self): raise RuntimeError('login_url or get_login_url() must be supplied when authentication hooks are enabled') -class DocHandler(LoginUrlMixin, BkDocHandler, SessionPrefixHandler): +class DocHandler(LoginUrlMixin, BkDocHandler): @authenticated async def get_session(self): @@ -488,42 +461,41 @@ async def get(self, *args, **kwargs): return app = self.application - with self._session_prefix(): - key_func = state._session_key_funcs.get(self.request.path, lambda r: r.path) - old_request = key_func(self.request) in state._sessions - session = await self.get_session() - if old_request and state._sessions.get(key_func(self.request)) is session: - session_id = generate_session_id( - secret_key=self.application.secret_key, - signed=self.application.sign_sessions - ) - payload = get_token_payload(session.token) - payload.update(payload) - del payload['session_expiry'] - token = generate_jwt_token( - session_id, - secret_key=app.secret_key, - signed=app.sign_sessions, - expiration=app.session_token_expiration, - extra_payload=payload + key_func = state._session_key_funcs.get(self.request.path, lambda r: r.path) + old_request = key_func(self.request) in state._sessions + session = await self.get_session() + if old_request and state._sessions.get(key_func(self.request)) is session: + session_id = generate_session_id( + secret_key=self.application.secret_key, + signed=self.application.sign_sessions + ) + payload = get_token_payload(session.token) + payload.update(payload) + del payload['session_expiry'] + token = generate_jwt_token( + session_id, + secret_key=app.secret_key, + signed=app.sign_sessions, + expiration=app.session_token_expiration, + extra_payload=payload + ) + else: + token = session.token + logger.info(LOG_SESSION_CREATED, id(session.document)) + with set_curdoc(session.document): + resources = Resources.from_bokeh(self.application.resources()) + # Session authorization callback + authorized, auth_error = self._authorize(session=True) + if authorized: + page = server_html_page_for_session( + session, resources=resources, title=session.document.title, + token=token, template=session.document.template, + template_variables=session.document.template_variables, ) + elif authorized is None: + return else: - token = session.token - logger.info(LOG_SESSION_CREATED, id(session.document)) - with set_curdoc(session.document): - resources = Resources.from_bokeh(self.application.resources()) - # Session authorization callback - authorized, auth_error = self._authorize(session=True) - if authorized: - page = server_html_page_for_session( - session, resources=resources, title=session.document.title, - token=token, template=session.document.template, - template_variables=session.document.template_variables, - ) - elif authorized is None: - return - else: - page = self._render_auth_error(auth_error) + page = self._render_auth_error(auth_error) self.set_header("Content-Type", 'text/html') self.write(page) @@ -531,7 +503,7 @@ async def get(self, *args, **kwargs): per_app_patterns[0] = (r'/?', DocHandler) # Patch Bokeh Autoload handler -class AutoloadJsHandler(BkAutoloadJsHandler, SessionPrefixHandler): +class AutoloadJsHandler(BkAutoloadJsHandler): ''' Implements a custom Tornado handler for the autoload JS chunk ''' @@ -550,16 +522,15 @@ async def get(self, *args, **kwargs) -> None: else: server_url = None - with self._session_prefix(): - session = await self.get_session() - with set_curdoc(session.document): - resources = Resources.from_bokeh( - self.application.resources(server_url), absolute=True - ) - js = autoload_js_script( - session.document, resources, session.token, element_id, - app_path, absolute_url, absolute=True - ) + session = await self.get_session() + with set_curdoc(session.document): + resources = Resources.from_bokeh( + self.application.resources(server_url), absolute=True + ) + js = autoload_js_script( + session.document, resources, session.token, element_id, + app_path, absolute_url, absolute=True + ) self.set_header("Content-Type", 'application/javascript') self.write(js) diff --git a/panel/io/state.py b/panel/io/state.py index d4f1ba845e..a16fb5fa70 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -89,9 +89,6 @@ class _state(param.Parameterized): apps to indicate their state to a user. """ - base_url = param.String(default='/', readonly=True, doc=""" - Base URL for all server paths.""") - busy = param.Boolean(default=False, readonly=True, doc=""" Whether the application is currently busy processing a user callback.""") @@ -104,11 +101,6 @@ class _state(param.Parameterized): Object with encrypt and decrypt methods to support encryption of secret variables including OAuth information.""") - rel_path = param.String(default='', readonly=True, doc=""" - Relative path from the current app being served to the root URL. - If application is embedded in a different server via autoload.js - this will instead reflect an absolute path.""") - session_info = param.Dict(default={'total': 0, 'live': 0, 'sessions': {}}, doc=""" Tracks information and statistics about user sessions.""") @@ -116,12 +108,20 @@ class _state(param.Parameterized): webdriver = param.Parameter(default=None, doc=""" Selenium webdriver used to export bokeh models to pngs.""") + _base_url = param.String(default='/', readonly=True, doc=""" + Base URL for all server paths.""") + _busy_counter = param.Integer(default=0, doc=""" Count of active callbacks current being processed.""") _memoize_cache = param.Dict(default={}, doc=""" A dictionary used by the cache decorator.""") + _rel_path = param.String(default='', readonly=True, doc=""" + Relative path from the current app being served to the root URL. + If application is embedded in a different server via autoload.js + this will instead reflect an absolute path.""") + # Holds temporary curdoc overrides per thread _curdoc = ContextVar('curdoc', default=None) @@ -220,6 +220,10 @@ class _state(param.Parameterized): _oauth_user_overrides = {} _active_users = Counter() + # Paths + _rel_paths: ClassVar[WeakKeyDictionary[Document, str]] = WeakKeyDictionary() + _base_urls: ClassVar[WeakKeyDictionary[Document, str]] = WeakKeyDictionary() + # Watchers _watch_events: list[asyncio.Event] = [] @@ -1019,6 +1023,34 @@ def headers(self) -> dict[str, str | list[str]]: """ return self.curdoc.session_context.request.headers if self.curdoc and self.curdoc.session_context else {} + @property + def base_url(self) -> str: + base_url = self._base_url + if self.curdoc: + return self._base_urls.get(self.curdoc, base_url) + return base_url + + @base_url.setter + def base_url(self, value: str): + if self.curdoc: + self._base_urls[self.curdoc] = value + else: + self._base_url = value + + @property + def rel_path(self) -> str | None: + rel_path = self._rel_path + if self.curdoc: + return self._rel_paths.get(self.curdoc, rel_path) + return rel_path + + @rel_path.setter + def rel_path(self, value: str | None): + if self.curdoc: + self._rel_paths[self.curdoc] = value + else: + self._rel_path = value + @property def loaded(self) -> bool: """