From 0913d5b2235d77a6b573407acd4f034f36da2636 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 30 Mar 2022 12:57:20 +0200 Subject: [PATCH 01/13] Add ModuleResourceHandler to server --- panel/command/serve.py | 6 +- panel/io/resources.py | 37 ++++++------ panel/io/server.py | 125 ++++++++++++++++++++++++++++++++++++++++- panel/template/base.py | 42 ++++++++++---- 4 files changed, 176 insertions(+), 34 deletions(-) diff --git a/panel/command/serve.py b/panel/command/serve.py index 80f7592646..ad031f1132 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -26,7 +26,7 @@ from ..util import fullpath from ..io.rest import REST_PROVIDERS from ..io.reload import record_modules, watch -from ..io.server import INDEX_HTML, get_static_routes, set_curdoc +from ..io.server import INDEX_HTML, ModuleResourceHandler, get_static_routes, set_curdoc from ..io.state import state log = logging.getLogger(__name__) @@ -225,6 +225,10 @@ def customize_kwargs(self, args, server_kwargs): static_dirs = parse_vars(args.static_dirs) patterns += get_static_routes(static_dirs) + patterns.append(( + '/components/(.*)', ModuleResourceHandler, {} + )) + files = [] for f in args.files: if args.glob: diff --git a/panel/io/resources.py b/panel/io/resources.py index 36e58ca31a..cde8d3fbe9 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -25,7 +25,7 @@ from jinja2.environment import Environment from jinja2.loaders import FileSystemLoader -from ..util import url_path +from ..util import isurl, url_path from .state import state @@ -177,6 +177,17 @@ def from_bokeh(cls, bkr): root_dir=bkr.root_dir, **kwargs ) + def extra_resources(self, resources, resource_type): + from ..reactive import ReactiveHTML + for model in param.concrete_descendents(ReactiveHTML).values(): + if not (getattr(model, resource_type, None) and model._loaded()): + continue + for resource in getattr(model, resource_type, []): + if not isurl(resource) and not resource.startswith('static/extensions'): + resource = f'components/{model.__module__}/{resource}' + if resource not in resources: + resources.append(resource) + @property def css_raw(self): from ..config import config @@ -203,15 +214,9 @@ def css_raw(self): @property def js_files(self): from ..config import config - from ..reactive import ReactiveHTML files = super(Resources, self).js_files - - for model in param.concrete_descendents(ReactiveHTML).values(): - if getattr(model, '__javascript__', None) and model._loaded(): - for jsfile in model.__javascript__: - if jsfile not in files: - files.append(jsfile) + self.extra_resources(files, '__javascript__') js_files = [] for js_file in files: @@ -244,27 +249,16 @@ def js_files(self): @property def js_modules(self): from ..config import config - from ..reactive import ReactiveHTML modules = list(config.js_modules.values()) - for model in param.concrete_descendents(ReactiveHTML).values(): - if hasattr(model, '__javascript_modules__') and model._loaded(): - for jsmodule in model.__javascript_modules__: - if jsmodule not in modules: - modules.append(jsmodule) + self.extra_resources(modules, '__javascript_modules__') return modules @property def css_files(self): from ..config import config - from ..reactive import ReactiveHTML files = super(Resources, self).css_files - - for model in param.concrete_descendents(ReactiveHTML).values(): - if getattr(model, '__css__', None) and model._loaded(): - for css_file in model.__css__: - if css_file not in files: - files.append(css_file) + self.extra_resources(files, '__css__') for cssf in config.css_files: if os.path.isfile(cssf) or cssf in files: @@ -277,6 +271,7 @@ def css_files(self): dist_dir = LOCAL_DIST else: dist_dir = CDN_DIST + for cssf in glob.glob(str(DIST_DIR / 'css' / '*.css')): if self.mode == 'inline': break diff --git a/panel/io/server.py b/panel/io/server.py index 779c61d5e3..f4a3228617 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -4,6 +4,7 @@ import datetime as dt import gc import html +import importlib import logging import os import pathlib @@ -40,8 +41,9 @@ from bokeh.server.views.static_handler import StaticHandler # Tornado imports +from tornado import httputil from tornado.ioloop import IOLoop -from tornado.web import RequestHandler, StaticFileHandler, authenticated +from tornado.web import HTTPError, RequestHandler, StaticFileHandler, authenticated from tornado.wsgi import WSGIContainer # Internal imports @@ -292,6 +294,127 @@ async def get(self, *args, **kwargs): per_app_patterns[3] = (r'/autoload.js', AutoloadJsHandler) + +class ModuleResourceHandler(StaticFileHandler): + """ + A handler that serves local resources relative to a Python module. + The handler resolves the module and then resolves any path relative + to the location of that module making it possible to resolve any + resources bundled inside a Python module. + + /// + """ + + def initialize(self, path=None, default_filename=None): + self.root = path + self.default_filename = default_filename + + def parse_url_pattern(self, path): + parts = path.split('/') + if len(parts) < 2: + raise HTTPError(400, 'Malformed URL') + module, *subpath = parts + try: + module = importlib.import_module(module) + except Exception as e: + raise HTTPError(404, 'Module not found') + if subpath[0] == '': + path = os.path.join('/', *subpath[1:]) + else: + path = pathlib.Path(module.__file__).parent / os.path.join(*subpath) + return path + + async def get(self, path: str, include_body: bool = True) -> None: + # Set up our path instance variables. + self.path = self.parse_url_pattern(path) + del path # make sure we don't refer to path instead of self.path again + self.absolute_path = self.validate_absolute_path(self.root, self.path) + print(self.absolute_path) + if self.absolute_path is None: + return + + self.modified = self.get_modified_time() + self.set_headers() + + if self.should_return_304(): + self.set_status(304) + return + + request_range = None + range_header = self.request.headers.get("Range") + if range_header: + # As per RFC 2616 14.16, if an invalid Range header is specified, + # the request will be treated as if the header didn't exist. + request_range = httputil._parse_request_range(range_header) + + size = self.get_content_size() + if request_range: + start, end = request_range + if start is not None and start < 0: + start += size + if start < 0: + start = 0 + if ( + start is not None + and (start >= size or (end is not None and start >= end)) + ) or end == 0: + # As per RFC 2616 14.35.1, a range is not satisfiable only: if + # the first requested byte is equal to or greater than the + # content, or when a suffix with length 0 is specified. + # https://tools.ietf.org/html/rfc7233#section-2.1 + # A byte-range-spec is invalid if the last-byte-pos value is present + # and less than the first-byte-pos. + self.set_status(416) # Range Not Satisfiable + self.set_header("Content-Type", "text/plain") + self.set_header("Content-Range", "bytes */%s" % (size,)) + return + if end is not None and end > size: + # Clients sometimes blindly use a large range to limit their + # download size; cap the endpoint at the actual file size. + end = size + # Note: only return HTTP 206 if less than the entire range has been + # requested. Not only is this semantically correct, but Chrome + # refuses to play audio if it gets an HTTP 206 in response to + # ``Range: bytes=0-``. + if size != (end or size) - (start or 0): + self.set_status(206) # Partial Content + self.set_header( + "Content-Range", httputil._get_content_range(start, end, size) + ) + else: + start = end = None + + if start is not None and end is not None: + content_length = end - start + elif end is not None: + content_length = end + elif start is not None: + content_length = size - start + else: + content_length = size + self.set_header("Content-Length", content_length) + + if include_body: + content = self.get_content(self.absolute_path, start, end) + if isinstance(content, bytes): + content = [content] + for chunk in content: + try: + self.write(chunk) + await self.flush() + except iostream.StreamClosedError: + return + else: + assert self.request.method == "HEAD" + + def validate_absolute_path(self, root, absolute_path): + if not os.path.exists(absolute_path): + raise HTTPError(404) + if not os.path.isfile(absolute_path): + raise HTTPError(403, "%s is not a file", self.path) + return absolute_path + + def modify_document(self, doc): from bokeh.io.doc import set_curdoc as bk_set_curdoc from ..config import config diff --git a/panel/template/base.py b/panel/template/base.py index 6b247d23f7..ad6b1df407 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -29,7 +29,7 @@ from ..pane import panel as _panel, HTML, Str, HoloViews from ..pane.image import ImageBase from ..reactive import ReactiveHTML -from ..util import url_path +from ..util import isurl, url_path from ..viewable import ServableMixin, Viewable from ..widgets import Button from ..widgets.indicators import BooleanIndicator, LoadingSpinner @@ -525,7 +525,11 @@ def _apply_hooks(self, viewable, root): root.document.theme = theme.bokeh_theme def _get_theme(self): - return self.theme.find_theme(type(self))() + for cls in type(self).__mro__: + try: + return self.theme.find_theme(cls)() + except Exception: + pass def _template_resources(self): name = type(self).__name__.lower() @@ -544,11 +548,20 @@ def _template_resources(self): css_path = url_path(css) if (BUNDLE_DIR / 'css' / css_path.replace('/', os.path.sep)).is_file(): css_files[cssname] = dist_path + f'bundled/css/{css_path}' + elif isurl(js): + css_files[jsname] = js + else: + css_files[jsname] = f'components/{self.__module__}/{css}' js_files = dict(self._resources.get('js', {})) for jsname, js in js_files.items(): js_path = url_path(js) if (BUNDLE_DIR / 'js' / js_path.replace('/', os.path.sep)).is_file(): js_files[jsname] = dist_path + f'bundled/js/{js_path}' + elif isurl(js): + js_files[jsname] = js + else: + js_files[jsname] = f'components/{self.__module__}/{js}' + js_modules = dict(self._resources.get('js_modules', {})) for jsname, js in js_modules.items(): js_path = url_path(js) @@ -586,9 +599,10 @@ def _template_resources(self): css_file = os.path.basename(css) if (BUNDLE_DIR / tmpl_name / css_file).is_file(): css_files[f'base_{css_file}'] = dist_path + f'bundled/{tmpl_name}/{css_file}' + elif is_url(css): + css_files[f'base_{css_file'] = css else: - with open(css, encoding='utf-8') as f: - raw_css.append(f.read()) + css_files[f'base_{css_file}' ] = f'components/{self.__module__}/{css}' # JS files base_js = self._js @@ -600,9 +614,13 @@ def _template_resources(self): tmpl_js = cls._js if isinstance(cls._js, list) else [cls._js] if js in tmpl_js: tmpl_name = cls.__name__.lower() - js = os.path.basename(js) - if (BUNDLE_DIR / tmpl_name / js).is_file(): - js_files[f'base_{js}'] = dist_path + f'bundled/{tmpl_name}/{js}' + js_name = os.path.basename(js) + if (BUNDLE_DIR / tmpl_name / js_name).is_file(): + js_files[f'base_{js_name}'] = dist_path + f'bundled/{tmpl_name}/{js_name}' + elif isurl(js): + js_files[f'base_{js_name}'] = js + else: + js_files[f'base_{js_name}' ] = f'components/{self.__module__}/{js}' if self.theme: theme = self.theme.find_theme(type(self)) @@ -612,16 +630,18 @@ def _template_resources(self): owner = theme.param.base_css.owner.__name__.lower() if (BUNDLE_DIR / owner / basename).is_file(): css_files['theme_base'] = dist_path + f'bundled/{owner}/{basename}' + elif isurl(theme.base_css): + css_files['theme_base'] = theme.base_css else: - with open(theme.base_css, encoding='utf-8') as f: - raw_css.append(f.read()) + css_files['theme_base'] = f'components/{theme.__module__}/{theme.base_css}' if theme.css: basename = os.path.basename(theme.css) if (BUNDLE_DIR / name / basename).is_file(): css_files['theme'] = dist_path + f'bundled/{name}/{basename}' + elif isurl(theme.css): + css_files['theme'] = theme.css else: - with open(theme.css, encoding='utf-8') as f: - raw_css.append(f.read()) + css_files['theme'] = f'components/{theme.__module__}/{theme.css}' return { 'css': css_files, From e810c97ef30d6526eefc9133dd0f2242c60a661e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 30 Mar 2022 13:06:20 +0200 Subject: [PATCH 02/13] Fold module handler route into get_static_routes --- panel/command/serve.py | 6 +----- panel/io/server.py | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/panel/command/serve.py b/panel/command/serve.py index ad031f1132..80f7592646 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -26,7 +26,7 @@ from ..util import fullpath from ..io.rest import REST_PROVIDERS from ..io.reload import record_modules, watch -from ..io.server import INDEX_HTML, ModuleResourceHandler, get_static_routes, set_curdoc +from ..io.server import INDEX_HTML, get_static_routes, set_curdoc from ..io.state import state log = logging.getLogger(__name__) @@ -225,10 +225,6 @@ def customize_kwargs(self, args, server_kwargs): static_dirs = parse_vars(args.static_dirs) patterns += get_static_routes(static_dirs) - patterns.append(( - '/components/(.*)', ModuleResourceHandler, {} - )) - files = [] for f in args.files: if args.glob: diff --git a/panel/io/server.py b/panel/io/server.py index f4a3228617..f9125a26b8 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -628,6 +628,9 @@ def get_static_routes(static_dirs): patterns.append( (r"%s/(.*)" % slug, StaticFileHandler, {"path": path}) ) + patterns.append(( + '/components/(.*)', ModuleResourceHandler, {} + )) return patterns From 545e069daf52c0a29168661c03a0a530bfb98410 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 30 Mar 2022 13:22:14 +0200 Subject: [PATCH 03/13] Fix typo --- panel/template/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/template/base.py b/panel/template/base.py index ad6b1df407..e3a6858a78 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -600,7 +600,7 @@ def _template_resources(self): if (BUNDLE_DIR / tmpl_name / css_file).is_file(): css_files[f'base_{css_file}'] = dist_path + f'bundled/{tmpl_name}/{css_file}' elif is_url(css): - css_files[f'base_{css_file'] = css + css_files[f'base_{css_file}'] = css else: css_files[f'base_{css_file}' ] = f'components/{self.__module__}/{css}' From ccd0d6e81cd5b39286b737f974d388529d47ac6d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 30 Mar 2022 14:58:44 +0200 Subject: [PATCH 04/13] Only serve listed resources --- panel/command/serve.py | 5 ++-- panel/io/resources.py | 2 +- panel/io/server.py | 55 +++++++++++++++++++++++++++++++++--------- panel/template/base.py | 29 +++++++++++++--------- panel/util.py | 2 +- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/panel/command/serve.py b/panel/command/serve.py index 80f7592646..d9fd35c157 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -221,9 +221,8 @@ def customize_kwargs(self, args, server_kwargs): # Handle tranquilized functions in the supplied functions kwargs['extra_patterns'] = patterns = kwargs.get('extra_patterns', []) - if args.static_dirs: - static_dirs = parse_vars(args.static_dirs) - patterns += get_static_routes(static_dirs) + static_dirs = parse_vars(args.static_dirs) if args.static_dirs else {} + patterns += get_static_routes(static_dirs) files = [] for f in args.files: diff --git a/panel/io/resources.py b/panel/io/resources.py index cde8d3fbe9..03269dbaa0 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -184,7 +184,7 @@ def extra_resources(self, resources, resource_type): continue for resource in getattr(model, resource_type, []): if not isurl(resource) and not resource.startswith('static/extensions'): - resource = f'components/{model.__module__}/{resource}' + resource = f'components/{model.__module__}/{model.__name__}/{resource_type}/{resource}' if resource not in resources: resources.append(resource) diff --git a/panel/io/server.py b/panel/io/server.py index f9125a26b8..971bd9d718 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -41,7 +41,7 @@ from bokeh.server.views.static_handler import StaticHandler # Tornado imports -from tornado import httputil +from tornado import httputil, iostream from tornado.ioloop import IOLoop from tornado.web import HTTPError, RequestHandler, StaticFileHandler, authenticated from tornado.wsgi import WSGIContainer @@ -295,14 +295,14 @@ async def get(self, *args, **kwargs): per_app_patterns[3] = (r'/autoload.js', AutoloadJsHandler) -class ModuleResourceHandler(StaticFileHandler): +class ComponentResourceHandler(StaticFileHandler): """ A handler that serves local resources relative to a Python module. - The handler resolves the module and then resolves any path relative - to the location of that module making it possible to resolve any - resources bundled inside a Python module. + The handler resolves the module and component then resolves any + path relative to the location of that module making it possible to + resolve any resources bundled inside a Python module. - /// + ///// """ def initialize(self, path=None, default_filename=None): @@ -311,25 +311,55 @@ def initialize(self, path=None, default_filename=None): def parse_url_pattern(self, path): parts = path.split('/') - if len(parts) < 2: + if len(parts) < 4: raise HTTPError(400, 'Malformed URL') - module, *subpath = parts + module, cls, rtype, *subpath = parts try: module = importlib.import_module(module) - except Exception as e: + except Exception: raise HTTPError(404, 'Module not found') + print(module) + try: + component = getattr(module, cls) + except AttributeError: + raise HTTPError(404, 'Component not found') + try: + resources = getattr(component, rtype) + except AttributeError: + raise HTTPError(404, 'Resource type not found') + + # Handle template resources + if rtype == '_resources': + rtype = subpath[0] + subpath = subpath[1:] + if rtype in resources: + resources = resources[rtype] + else: + raise HTTPError(404, 'Resource type not found') + + if isinstance(resources, dict): + resources = list(resources.values()) + + if subpath[0] == '': + path = os.path.join('/', *subpath[1:]) + else: + path = os.path.join(*subpath) + + if path not in resources and f'./{path}' not in resources: + raise HTTPError(403, 'Requested resource was not listed.') + if subpath[0] == '': path = os.path.join('/', *subpath[1:]) else: - path = pathlib.Path(module.__file__).parent / os.path.join(*subpath) + path = pathlib.Path(module.__file__).parent / path return path async def get(self, path: str, include_body: bool = True) -> None: # Set up our path instance variables. + print(path) self.path = self.parse_url_pattern(path) del path # make sure we don't refer to path instead of self.path again self.absolute_path = self.validate_absolute_path(self.root, self.path) - print(self.absolute_path) if self.absolute_path is None: return @@ -629,8 +659,9 @@ def get_static_routes(static_dirs): (r"%s/(.*)" % slug, StaticFileHandler, {"path": path}) ) patterns.append(( - '/components/(.*)', ModuleResourceHandler, {} + '/components/(.*)', ComponentResourceHandler, {} )) + print(patterns) return patterns diff --git a/panel/template/base.py b/panel/template/base.py index e3a6858a78..d1518aa1d6 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -532,7 +532,8 @@ def _get_theme(self): pass def _template_resources(self): - name = type(self).__name__.lower() + clsname = type(self).__name__ + name = clsname.lower() resources = _settings.resources(default="server") if resources == 'server': if state.rel_path: @@ -549,9 +550,9 @@ def _template_resources(self): if (BUNDLE_DIR / 'css' / css_path.replace('/', os.path.sep)).is_file(): css_files[cssname] = dist_path + f'bundled/css/{css_path}' elif isurl(js): - css_files[jsname] = js + css_files[cssname] = css else: - css_files[jsname] = f'components/{self.__module__}/{css}' + css_files[cssname] = f'components/{self.__module__}/{clsname}/_resources/css/{css}' js_files = dict(self._resources.get('js', {})) for jsname, js in js_files.items(): js_path = url_path(js) @@ -560,7 +561,7 @@ def _template_resources(self): elif isurl(js): js_files[jsname] = js else: - js_files[jsname] = f'components/{self.__module__}/{js}' + js_files[jsname] = f'components/{self.__module__}/{clsname}/_resources/js/{js}' js_modules = dict(self._resources.get('js_modules', {})) for jsname, js in js_modules.items(): @@ -571,6 +572,11 @@ def _template_resources(self): js_path += '.mjs' if os.path.isfile(BUNDLE_DIR / js_path.replace('/', os.path.sep)): js_modules[jsname] = dist_path + f'bundled/js/{js_path}' + elif isurl(js): + js_modules[jsname] = js + else: + js_modules[jsname] = f'components/{self.__module__}/{clsname}/_resources/js_modules/{js}' + for name, js in self.config.js_files.items(): if not '//' in js and state.rel_path: js = f'{state.rel_path}/{js}' @@ -599,10 +605,10 @@ def _template_resources(self): css_file = os.path.basename(css) if (BUNDLE_DIR / tmpl_name / css_file).is_file(): css_files[f'base_{css_file}'] = dist_path + f'bundled/{tmpl_name}/{css_file}' - elif is_url(css): + elif isurl(css): css_files[f'base_{css_file}'] = css else: - css_files[f'base_{css_file}' ] = f'components/{self.__module__}/{css}' + css_files[f'base_{css_file}' ] = f'components/{self.__module__}/{clsname}/_css/{css}' # JS files base_js = self._js @@ -620,20 +626,21 @@ def _template_resources(self): elif isurl(js): js_files[f'base_{js_name}'] = js else: - js_files[f'base_{js_name}' ] = f'components/{self.__module__}/{js}' + js_files[f'base_{js_name}' ] = f'components/{self.__module__}/{clsname}/_js/{js}' if self.theme: - theme = self.theme.find_theme(type(self)) + theme = self._get_theme() + theme_name = type(theme).__name__ if theme: if theme.base_css: basename = os.path.basename(theme.base_css) - owner = theme.param.base_css.owner.__name__.lower() + owner = type(theme.param.base_css.owner).__name__.lower() if (BUNDLE_DIR / owner / basename).is_file(): css_files['theme_base'] = dist_path + f'bundled/{owner}/{basename}' elif isurl(theme.base_css): css_files['theme_base'] = theme.base_css else: - css_files['theme_base'] = f'components/{theme.__module__}/{theme.base_css}' + css_files['theme_base'] = f'components/{theme.__module__}/{theme_name}/base_css/{theme.base_css}' if theme.css: basename = os.path.basename(theme.css) if (BUNDLE_DIR / name / basename).is_file(): @@ -641,7 +648,7 @@ def _template_resources(self): elif isurl(theme.css): css_files['theme'] = theme.css else: - css_files['theme'] = f'components/{theme.__module__}/{theme.css}' + css_files['theme'] = f'components/{theme.__module__}/{theme_name}/css/{theme.css}' return { 'css': css_files, diff --git a/panel/util.py b/panel/util.py index 380af2a8d2..f5010955a8 100644 --- a/panel/util.py +++ b/panel/util.py @@ -38,7 +38,7 @@ def isfile(path): return False -def isurl(obj, formats): +def isurl(obj, formats=None): if not isinstance(obj, str): return False lower_string = obj.lower().split('?')[0].split('#')[0] From 65327f81ae6c962c7a65549483788fa6729eb914 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 30 Mar 2022 15:03:27 +0200 Subject: [PATCH 05/13] Cleanup and comments --- panel/io/server.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/panel/io/server.py b/panel/io/server.py index 971bd9d718..887bae5050 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -298,11 +298,11 @@ async def get(self, *args, **kwargs): class ComponentResourceHandler(StaticFileHandler): """ A handler that serves local resources relative to a Python module. - The handler resolves the module and component then resolves any - path relative to the location of that module making it possible to - resolve any resources bundled inside a Python module. + The handler resolves a specific Panel component by module reference + and name, then resolves an attribute on that component to check + if it contains the requested resource path. - ///// + ///// """ def initialize(self, path=None, default_filename=None): @@ -310,6 +310,9 @@ def initialize(self, path=None, default_filename=None): self.default_filename = default_filename def parse_url_pattern(self, path): + """ + Resolves the resource the URL pattern refers to. + """ parts = path.split('/') if len(parts) < 4: raise HTTPError(400, 'Malformed URL') @@ -345,18 +348,17 @@ def parse_url_pattern(self, path): else: path = os.path.join(*subpath) + # Important: May only access resources explicitly listed on the component + # Otherwise this potentially exposes all files to the web if path not in resources and f'./{path}' not in resources: raise HTTPError(403, 'Requested resource was not listed.') - if subpath[0] == '': - path = os.path.join('/', *subpath[1:]) - else: + if not path.startswith('/'): path = pathlib.Path(module.__file__).parent / path return path async def get(self, path: str, include_body: bool = True) -> None: # Set up our path instance variables. - print(path) self.path = self.parse_url_pattern(path) del path # make sure we don't refer to path instead of self.path again self.absolute_path = self.validate_absolute_path(self.root, self.path) @@ -661,7 +663,6 @@ def get_static_routes(static_dirs): patterns.append(( '/components/(.*)', ComponentResourceHandler, {} )) - print(patterns) return patterns From 2d46a3605d8e894b3a5b2f29c6c36fcdabf896a6 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 30 Mar 2022 15:28:03 +0200 Subject: [PATCH 06/13] Fix flake --- panel/template/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/template/base.py b/panel/template/base.py index d1518aa1d6..b210b2aeac 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -549,7 +549,7 @@ def _template_resources(self): css_path = url_path(css) if (BUNDLE_DIR / 'css' / css_path.replace('/', os.path.sep)).is_file(): css_files[cssname] = dist_path + f'bundled/css/{css_path}' - elif isurl(js): + elif isurl(css): css_files[cssname] = css else: css_files[cssname] = f'components/{self.__module__}/{clsname}/_resources/css/{css}' From f109ca85efe50450218d391837ea047ea5c84e6b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 31 Mar 2022 10:35:37 +0200 Subject: [PATCH 07/13] Do not override ComponentResourceHandler.get --- panel/io/server.py | 86 ++-------------------------------------------- 1 file changed, 3 insertions(+), 83 deletions(-) diff --git a/panel/io/server.py b/panel/io/server.py index 887bae5050..2076e46169 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -309,7 +309,7 @@ def initialize(self, path=None, default_filename=None): self.root = path self.default_filename = default_filename - def parse_url_pattern(self, path): + def parse_url_path(self, path): """ Resolves the resource the URL pattern refers to. """ @@ -321,7 +321,6 @@ def parse_url_pattern(self, path): module = importlib.import_module(module) except Exception: raise HTTPError(404, 'Module not found') - print(module) try: component = getattr(module, cls) except AttributeError: @@ -357,87 +356,8 @@ def parse_url_pattern(self, path): path = pathlib.Path(module.__file__).parent / path return path - async def get(self, path: str, include_body: bool = True) -> None: - # Set up our path instance variables. - self.path = self.parse_url_pattern(path) - del path # make sure we don't refer to path instead of self.path again - self.absolute_path = self.validate_absolute_path(self.root, self.path) - if self.absolute_path is None: - return - - self.modified = self.get_modified_time() - self.set_headers() - - if self.should_return_304(): - self.set_status(304) - return - - request_range = None - range_header = self.request.headers.get("Range") - if range_header: - # As per RFC 2616 14.16, if an invalid Range header is specified, - # the request will be treated as if the header didn't exist. - request_range = httputil._parse_request_range(range_header) - - size = self.get_content_size() - if request_range: - start, end = request_range - if start is not None and start < 0: - start += size - if start < 0: - start = 0 - if ( - start is not None - and (start >= size or (end is not None and start >= end)) - ) or end == 0: - # As per RFC 2616 14.35.1, a range is not satisfiable only: if - # the first requested byte is equal to or greater than the - # content, or when a suffix with length 0 is specified. - # https://tools.ietf.org/html/rfc7233#section-2.1 - # A byte-range-spec is invalid if the last-byte-pos value is present - # and less than the first-byte-pos. - self.set_status(416) # Range Not Satisfiable - self.set_header("Content-Type", "text/plain") - self.set_header("Content-Range", "bytes */%s" % (size,)) - return - if end is not None and end > size: - # Clients sometimes blindly use a large range to limit their - # download size; cap the endpoint at the actual file size. - end = size - # Note: only return HTTP 206 if less than the entire range has been - # requested. Not only is this semantically correct, but Chrome - # refuses to play audio if it gets an HTTP 206 in response to - # ``Range: bytes=0-``. - if size != (end or size) - (start or 0): - self.set_status(206) # Partial Content - self.set_header( - "Content-Range", httputil._get_content_range(start, end, size) - ) - else: - start = end = None - - if start is not None and end is not None: - content_length = end - start - elif end is not None: - content_length = end - elif start is not None: - content_length = size - start - else: - content_length = size - self.set_header("Content-Length", content_length) - - if include_body: - content = self.get_content(self.absolute_path, start, end) - if isinstance(content, bytes): - content = [content] - for chunk in content: - try: - self.write(chunk) - await self.flush() - except iostream.StreamClosedError: - return - else: - assert self.request.method == "HEAD" + def get_absolute_path(self, root, path): + return path def validate_absolute_path(self, root, absolute_path): if not os.path.exists(absolute_path): From bdac027d3d7f75ac84904439450f447c8809ed72 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 31 Mar 2022 10:49:43 +0200 Subject: [PATCH 08/13] Only allow access to resources listed in specific attributes --- panel/io/server.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/panel/io/server.py b/panel/io/server.py index 2076e46169..c6358cd9d8 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -305,6 +305,11 @@ class ComponentResourceHandler(StaticFileHandler): ///// """ + _resource_attrs = [ + '__css__', '__javascript__', '__js_module__', '_resources', + '_css', '_js', 'base_css', 'css' + ] + def initialize(self, path=None, default_filename=None): self.root = path self.default_filename = default_filename @@ -319,12 +324,17 @@ def parse_url_path(self, path): module, cls, rtype, *subpath = parts try: module = importlib.import_module(module) - except Exception: + except ModuleNotFoundError: raise HTTPError(404, 'Module not found') try: component = getattr(module, cls) except AttributeError: raise HTTPError(404, 'Component not found') + + # May only access resources listed in specific attributes + if rtype not in self._resource_attrs: + raise HTTPError(403, 'Requested resource type not valid.') + try: resources = getattr(component, rtype) except AttributeError: From 6440207021b7dd739bbdb8c01fb4d319398d9e55 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 31 Mar 2022 11:49:30 +0200 Subject: [PATCH 09/13] Handle relative paths --- panel/io/resources.py | 22 ++++++++++-- panel/template/base.py | 35 +++++++++++-------- panel/tests/assets/custom.css | 1 + panel/tests/test_server.py | 66 ++++++++++++++++++++++++++++++++++- 4 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 panel/tests/assets/custom.css diff --git a/panel/io/resources.py b/panel/io/resources.py index 03269dbaa0..ae1470ac02 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -4,6 +4,7 @@ """ import copy import glob +import importlib import json import os @@ -83,6 +84,20 @@ def set_resource_mode(mode): RESOURCE_MODE = old_mode _settings.resources.set_value(old_resources) +def resolve_custom_path(obj, path): + """ + Attempts to resolve a path relative to some component. + """ + if not path: + return + elif path.startswith(os.path.sep): + return os.path.isfile(path) + try: + mod = importlib.import_module(obj.__module__) + return (Path(mod.__file__).parent / path).is_file() + except Exception as e: + print(e) + return None def loading_css(): from ..config import config @@ -96,7 +111,6 @@ def loading_css(): }} """ - def bundled_files(model, file_type='javascript'): bdir = os.path.join(PANEL_DIR, 'dist', 'bundled', model.__name__.lower()) name = model.__name__.lower() @@ -111,7 +125,6 @@ def bundled_files(model, file_type='javascript'): files.append(url) return files - def bundle_resources(roots, resources): from ..config import panel_extension as ext global RESOURCE_MODE @@ -179,12 +192,15 @@ def from_bokeh(cls, bkr): def extra_resources(self, resources, resource_type): from ..reactive import ReactiveHTML + custom_path = "components" + if state.rel_path: + custom_path = f"{state.rel_path}/{custom_path}" for model in param.concrete_descendents(ReactiveHTML).values(): if not (getattr(model, resource_type, None) and model._loaded()): continue for resource in getattr(model, resource_type, []): if not isurl(resource) and not resource.startswith('static/extensions'): - resource = f'components/{model.__module__}/{model.__name__}/{resource_type}/{resource}' + resource = f'{custom_path}/{model.__module__}/{model.__name__}/{resource_type}/{resource}' if resource not in resources: resources.append(resource) diff --git a/panel/template/base.py b/panel/template/base.py index b210b2aeac..fac7a746a6 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -21,7 +21,7 @@ from ..config import _base_config, config, panel_extension from ..io.model import add_to_doc from ..io.notebook import render_template -from ..io.resources import CDN_DIST, LOCAL_DIST, BUNDLE_DIR +from ..io.resources import CDN_DIST, LOCAL_DIST, BUNDLE_DIR, resolve_custom_path from ..io.save import save from ..io.state import state from ..layout import Column, ListLike, GridSpec @@ -543,6 +543,10 @@ def _template_resources(self): else: dist_path = self._CDN + custom_path = "components" + if state.rel_path: + custom_path = f"{state.rel_path}/{custom_path}" + # External resources css_files = dict(self._resources.get('css', {})) for cssname, css in css_files.items(): @@ -551,8 +555,9 @@ def _template_resources(self): css_files[cssname] = dist_path + f'bundled/css/{css_path}' elif isurl(css): css_files[cssname] = css - else: - css_files[cssname] = f'components/{self.__module__}/{clsname}/_resources/css/{css}' + elif resolve_custom_path(self, css): + css_files[cssname] = f'{custom_path}/{self.__module__}/{clsname}/_resources/css/{css}' + js_files = dict(self._resources.get('js', {})) for jsname, js in js_files.items(): js_path = url_path(js) @@ -560,8 +565,8 @@ def _template_resources(self): js_files[jsname] = dist_path + f'bundled/js/{js_path}' elif isurl(js): js_files[jsname] = js - else: - js_files[jsname] = f'components/{self.__module__}/{clsname}/_resources/js/{js}' + elif resolve_custom_path(self, js): + js_files[jsname] = f'{custom_path}/{self.__module__}/{clsname}/_resources/js/{js}' js_modules = dict(self._resources.get('js_modules', {})) for jsname, js in js_modules.items(): @@ -574,8 +579,8 @@ def _template_resources(self): js_modules[jsname] = dist_path + f'bundled/js/{js_path}' elif isurl(js): js_modules[jsname] = js - else: - js_modules[jsname] = f'components/{self.__module__}/{clsname}/_resources/js_modules/{js}' + elif resolve_custom_path(self, js): + js_modules[jsname] = f'{custom_path}/{self.__module__}/{clsname}/_resources/js_modules/{js}' for name, js in self.config.js_files.items(): if not '//' in js and state.rel_path: @@ -607,8 +612,8 @@ def _template_resources(self): css_files[f'base_{css_file}'] = dist_path + f'bundled/{tmpl_name}/{css_file}' elif isurl(css): css_files[f'base_{css_file}'] = css - else: - css_files[f'base_{css_file}' ] = f'components/{self.__module__}/{clsname}/_css/{css}' + elif resolve_custom_path(self, css): + css_files[f'base_{css_file}' ] = f'{custom_path}/{self.__module__}/{clsname}/_css/{css}' # JS files base_js = self._js @@ -625,8 +630,8 @@ def _template_resources(self): js_files[f'base_{js_name}'] = dist_path + f'bundled/{tmpl_name}/{js_name}' elif isurl(js): js_files[f'base_{js_name}'] = js - else: - js_files[f'base_{js_name}' ] = f'components/{self.__module__}/{clsname}/_js/{js}' + elif resolve_custom_path(self, js): + js_files[f'base_{js_name}' ] = f'{custom_path}/{self.__module__}/{clsname}/_js/{js}' if self.theme: theme = self._get_theme() @@ -639,16 +644,16 @@ def _template_resources(self): css_files['theme_base'] = dist_path + f'bundled/{owner}/{basename}' elif isurl(theme.base_css): css_files['theme_base'] = theme.base_css - else: - css_files['theme_base'] = f'components/{theme.__module__}/{theme_name}/base_css/{theme.base_css}' + elif resolve_custom_path(theme, theme.base_css): + css_files['theme_base'] = f'{custom_path}/{theme.__module__}/{theme_name}/base_css/{theme.base_css}' if theme.css: basename = os.path.basename(theme.css) if (BUNDLE_DIR / name / basename).is_file(): css_files['theme'] = dist_path + f'bundled/{name}/{basename}' elif isurl(theme.css): css_files['theme'] = theme.css - else: - css_files['theme'] = f'components/{theme.__module__}/{theme_name}/css/{theme.css}' + elif resolve_custom_path(theme, theme.css): + css_files['theme'] = f'{custom_path}/{theme.__module__}/{theme_name}/css/{theme.css}' return { 'css': css_files, diff --git a/panel/tests/assets/custom.css b/panel/tests/assets/custom.css new file mode 100644 index 0000000000..7c37b30b91 --- /dev/null +++ b/panel/tests/assets/custom.css @@ -0,0 +1 @@ +/* Test */ diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 75b1430684..55e18de819 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -110,7 +110,7 @@ def test_server_template_static_resources_with_subpath_and_prefix_relative_url() template = BootstrapTemplate() port = 6004 - serve({'/subpath/template': template}, port=6004, threaded=True, show=False, prefix='prefix') + serve({'/subpath/template': template}, port=port, threaded=True, show=False, prefix='prefix') # Wait for server to start time.sleep(1) @@ -554,3 +554,67 @@ def loaded(): # Checks whether onload callbacks were executed concurrently assert max(counts) >= 2 + + +class CustomBootstrapTemplate(BootstrapTemplate): + + _css = './assets/custom.css' + + +def test_server_template_custom_resources(): + template = CustomBootstrapTemplate() + + port = 6019 + serve({'template': template}, port=port, threaded=True, show=False) + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/components/panel.tests.test_server/CustomBootstrapTemplate/_css/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_template_static_resources_with_prefix(): + template = CustomBootstrapTemplate() + + port = 6020 + serve({'template': template}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/components/panel.tests.test_server/CustomBootstrapTemplate/_css/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_template_custom_resources_with_prefix_relative_url(): + template = CustomBootstrapTemplate() + + port = 6021 + serve({'template': template}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/template") + content = r.content.decode('utf-8') + print(content) + assert 'href="components/panel.tests.test_server/CustomBootstrapTemplate/_css/./assets/custom.css"' in content + + +def test_server_template_custom_resources_with_subpath_and_prefix_relative_url(): + template = CustomBootstrapTemplate() + + port = 6022 + serve({'/subpath/template': template}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/subpath/template") + content = r.content.decode('utf-8') + print(content) + assert 'href="../components/panel.tests.test_server/CustomBootstrapTemplate/_css/./assets/custom.css"' in content + From 11c5520cc20fe1c1c057f279a2ad028e4ccc5889 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 31 Mar 2022 12:00:22 +0200 Subject: [PATCH 10/13] Add more tests --- MANIFEST.in | 2 ++ panel/tests/test_server.py | 67 +++++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 03df33b1ac..468cf35595 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -12,6 +12,8 @@ include panel/models/vtk/*.ts include panel/_templates/*.css include panel/_templates/*.js include panel/_templates/*.html +include panel/tests/assets/*.css +include panel/tests/assets/*.js include panel/tests/test_data/*.png include panel/tests/pane/assets/*.mp3 include panel/tests/pane/assets/*.mp4 diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 55e18de819..6295990de7 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -10,12 +10,13 @@ from panel.config import config from panel.io import state +from panel.io.resources import DIST_DIR +from panel.io.server import get_server, serve, set_curdoc from panel.layout import Row from panel.models import HTML as BkHTML from panel.models.tabulator import TableEditEvent from panel.pane import Markdown -from panel.io.resources import DIST_DIR -from panel.io.server import get_server, serve, set_curdoc +from panel.reactive import ReactiveHTML from panel.template import BootstrapTemplate from panel.widgets import Button, Tabulator @@ -600,7 +601,6 @@ def test_server_template_custom_resources_with_prefix_relative_url(): r = requests.get(f"http://localhost:{port}/prefix/template") content = r.content.decode('utf-8') - print(content) assert 'href="components/panel.tests.test_server/CustomBootstrapTemplate/_css/./assets/custom.css"' in content @@ -615,6 +615,65 @@ def test_server_template_custom_resources_with_subpath_and_prefix_relative_url() r = requests.get(f"http://localhost:{port}/prefix/subpath/template") content = r.content.decode('utf-8') - print(content) assert 'href="../components/panel.tests.test_server/CustomBootstrapTemplate/_css/./assets/custom.css"' in content + +class CustomComponent(ReactiveHTML): + + __css__ = ['./assets/custom.css'] + + +def test_server_component_custom_resources(): + component = CustomComponent() + + port = 6023 + serve({'component': component}, port=port, threaded=True, show=False) + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/components/panel.tests.test_server/CustomComponent/__css__/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_component_static_resources_with_prefix(): + component = CustomComponent() + + port = 6024 + serve({'component': component}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/components/panel.tests.test_server/CustomComponent/__css__/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_component_custom_resources_with_prefix_relative_url(): + component = CustomComponent() + + port = 6025 + serve({'component': component}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/component") + content = r.content.decode('utf-8') + assert 'href="components/panel.tests.test_server/CustomComponent/__css__/./assets/custom.css"' in content + + +def test_server_component_custom_resources_with_subpath_and_prefix_relative_url(): + component = CustomComponent() + + port = 6026 + serve({'/subpath/component': component}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/subpath/component") + content = r.content.decode('utf-8') + assert 'href="../components/panel.tests.test_server/CustomComponent/__css__/./assets/custom.css"' in content From 2a02caf2d6004297217c63b05a8a51819fff5dfc Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 31 Mar 2022 12:15:21 +0200 Subject: [PATCH 11/13] Fix flakes --- panel/io/resources.py | 1 - panel/io/server.py | 1 - panel/tests/test_server.py | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/panel/io/resources.py b/panel/io/resources.py index ae1470ac02..0508156713 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -96,7 +96,6 @@ def resolve_custom_path(obj, path): mod = importlib.import_module(obj.__module__) return (Path(mod.__file__).parent / path).is_file() except Exception as e: - print(e) return None def loading_css(): diff --git a/panel/io/server.py b/panel/io/server.py index c6358cd9d8..24fef2b985 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -41,7 +41,6 @@ from bokeh.server.views.static_handler import StaticHandler # Tornado imports -from tornado import httputil, iostream from tornado.ioloop import IOLoop from tornado.web import HTTPError, RequestHandler, StaticFileHandler, authenticated from tornado.wsgi import WSGIContainer diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 6295990de7..f0e10447db 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -576,7 +576,7 @@ def test_server_template_custom_resources(): assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') -def test_server_template_static_resources_with_prefix(): +def test_server_template_custom_resources_with_prefix(): template = CustomBootstrapTemplate() port = 6020 @@ -637,7 +637,7 @@ def test_server_component_custom_resources(): assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') -def test_server_component_static_resources_with_prefix(): +def test_server_component_custom_resources_with_prefix(): component = CustomComponent() port = 6024 From 1fec5a0c889e91dbe48ef96f3d66519df1fa3fe5 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 31 Mar 2022 12:36:00 +0200 Subject: [PATCH 12/13] Fix final flake --- panel/io/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/io/resources.py b/panel/io/resources.py index 0508156713..6fc8ba5442 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -95,7 +95,7 @@ def resolve_custom_path(obj, path): try: mod = importlib.import_module(obj.__module__) return (Path(mod.__file__).parent / path).is_file() - except Exception as e: + except Exception: return None def loading_css(): From b80f01f4761c4671a5f1604e11b1ad430b3a11e2 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 31 Mar 2022 13:45:06 +0200 Subject: [PATCH 13/13] Attempt to fix windows --- panel/io/server.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/panel/io/server.py b/panel/io/server.py index 24fef2b985..33693a7344 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -352,9 +352,8 @@ def parse_url_path(self, path): resources = list(resources.values()) if subpath[0] == '': - path = os.path.join('/', *subpath[1:]) - else: - path = os.path.join(*subpath) + subpath = tuple('/')+subpath[1:] + path = '/'.join(subpath) # Important: May only access resources explicitly listed on the component # Otherwise this potentially exposes all files to the web