diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst index a86b3a410e3..de6cfdb0961 100644 --- a/doc/source/admin/galaxy_options.rst +++ b/doc/source/admin/galaxy_options.rst @@ -1885,6 +1885,17 @@ :Type: str +~~~~~~~~~~~~~~~~~~~~~ +``galaxy_url_prefix`` +~~~~~~~~~~~~~~~~~~~~~ + +:Description: + URL prefix for Galaxy application. If Galaxy should be served + under a prefix set this to the desired prefix value. +:Default: ``/`` +:Type: str + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``galaxy_infrastructure_url`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1946,7 +1957,7 @@ :Description: The brand image source. -:Default: ``/static/favicon.png`` +:Default: ``static/favicon.png`` :Type: str diff --git a/lib/galaxy/config/sample/galaxy.yml.sample b/lib/galaxy/config/sample/galaxy.yml.sample index aacdb8a7100..bad8e33a111 100644 --- a/lib/galaxy/config/sample/galaxy.yml.sample +++ b/lib/galaxy/config/sample/galaxy.yml.sample @@ -993,6 +993,10 @@ galaxy: # user_preferences_extra_conf.yml #default_locale: auto + # URL prefix for Galaxy application. If Galaxy should be served under + # a prefix set this to the desired prefix value. + #galaxy_url_prefix: / + # URL (with schema http/https) of the Galaxy instance as accessible # within your local network - if specified used as a default by pulsar # file staging and Jupyter Docker container for communicating back @@ -1020,7 +1024,7 @@ galaxy: #logo_url: / # The brand image source. - #logo_src: /static/favicon.png + #logo_src: static/favicon.png # The custom brand image source. #logo_src_secondary: null diff --git a/lib/galaxy/web/framework/middleware/static.py b/lib/galaxy/web/framework/middleware/static.py deleted file mode 100644 index fcc878f16ff..00000000000 --- a/lib/galaxy/web/framework/middleware/static.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -from typing import List, Tuple - -from paste import request -from paste.fileapp import FileApp -from paste.httpheaders import ETAG -from paste.urlparser import StaticURLParser - - -class CacheableStaticURLParser(StaticURLParser): - - def __init__(self, directory, cache_seconds=None, directory_per_host=None): - StaticURLParser.__init__(self, directory) - self.cache_seconds = cache_seconds - self.directory_per_host = directory_per_host - - def __call__(self, environ, start_response): - path_info = environ.get('PATH_INFO', '') - if not path_info: - # See if this is a static file hackishly mapped. - if os.path.exists(self.directory) and os.path.isfile(self.directory): - app = FileApp(self.directory) - if self.cache_seconds: - app.cache_control(max_age=int(self.cache_seconds)) - return app(environ, start_response) - return self.add_slash(environ, start_response) - if path_info == '/': - # @@: This should obviously be configurable - filename = 'index.html' - else: - filename = request.path_info_pop(environ) - - directory = self.directory - host = environ.get('HTTP_HOST') - if self.directory_per_host and host: - for host_key, host_val in self.directory_per_host.items(): - if host_key in host: - directory = host_val - break - - full = os.path.join(directory, filename) - if not os.path.exists(full): - return self.not_found(environ, start_response) - if os.path.isdir(full): - # @@: Cache? - return self.__class__(full)(environ, start_response) - if environ.get('PATH_INFO') and environ.get('PATH_INFO') != '/': - return self.error_extra_path(environ, start_response) - if_none_match = environ.get('HTTP_IF_NONE_MATCH') - if if_none_match: - mytime = os.stat(full).st_mtime - if str(mytime) == if_none_match: - headers: List[Tuple[str, str]] = [] - ETAG.update(headers, mytime) - start_response('304 Not Modified', headers) - return [''] # empty body - app = FileApp(full) - if self.cache_seconds: - app.cache_control(max_age=int(self.cache_seconds)) - return app(environ, start_response) - - -def make_static(global_conf, document_root, cache_seconds=None): - return CacheableStaticURLParser(document_root, cache_seconds) diff --git a/lib/galaxy/webapps/base/webapp.py b/lib/galaxy/webapps/base/webapp.py index 1b0e87aa6d0..dbe2a93fd3b 100644 --- a/lib/galaxy/webapps/base/webapp.py +++ b/lib/galaxy/webapps/base/webapp.py @@ -976,36 +976,3 @@ def build_native_uwsgi_app(paste_factory, config_section): app_kwds = get_app_kwds(config_section) uwsgi_app = paste_factory({}, load_app_kwds=app_kwds) return uwsgi_app - - -def build_url_map(app, global_conf, local_conf): - from paste.urlmap import URLMap - from galaxy.web.framework.middleware.static import CacheableStaticURLParser as Static - urlmap = URLMap() - # Merge the global and local configurations - conf = global_conf.copy() - conf.update(local_conf) - # Get cache time in seconds - cache_time = conf.get("static_cache_time", None) - if cache_time is not None: - cache_time = int(cache_time) - # Send to dynamic app by default - urlmap["/"] = app - - def get_static_from_config(option_name, default_path): - config_val = conf.get(option_name, default_url_path(default_path)) - per_host_config_option = f"{option_name}_by_host" - per_host_config = conf.get(per_host_config_option) - return Static(config_val, cache_time, directory_per_host=per_host_config) - - # Define static mappings from config - urlmap["/static"] = get_static_from_config("static_dir", "static/") - urlmap["/images"] = get_static_from_config("static_images_dir", "static/images") - urlmap["/static/scripts"] = get_static_from_config("static_scripts_dir", "static/scripts/") - urlmap["/static/welcome.html"] = get_static_from_config("static_welcome_html", "static/welcome.html") - urlmap["/favicon.ico"] = get_static_from_config("static_favicon_dir", "static/favicon.ico") - urlmap["/robots.txt"] = get_static_from_config("static_robots_txt", "static/robots.txt") - - if 'static_local_dir' in conf: - urlmap["/static_local"] = Static(conf["static_local_dir"], cache_time) - return urlmap, cache_time diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index df934b3aac9..10a8c52bffa 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -207,10 +207,6 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs): # Wrap the webapp in some useful middleware if kwargs.get('middleware', True): webapp = wrap_in_middleware(webapp, global_conf, app.application_stack, **kwargs) - if asbool(kwargs.get('static_enabled', True)): - webapp = wrap_if_allowed(webapp, app.application_stack, wrap_in_static, - args=(global_conf,), - kwargs=dict(plugin_frameworks=[app.visualizations_registry], **kwargs)) app.application_stack.register_postfork_function(postfork_setup) for th in threading.enumerate(): @@ -1399,8 +1395,3 @@ def wrap_in_middleware(app, global_conf, application_stack, **local_conf): from galaxy.web.framework.middleware.sqldebug import SQLDebugMiddleware app = wrap_if_allowed(app, stack, SQLDebugMiddleware, args=(webapp, {})) return app - - -def wrap_in_static(app, global_conf, plugin_frameworks=None, **local_conf): - urlmap, cache_time = galaxy.webapps.base.webapp.build_url_map(app, global_conf, local_conf) - return urlmap diff --git a/lib/galaxy/webapps/galaxy/config_schema.yml b/lib/galaxy/webapps/galaxy/config_schema.yml index 3d745236733..3ae5458e5f0 100644 --- a/lib/galaxy/webapps/galaxy/config_schema.yml +++ b/lib/galaxy/webapps/galaxy/config_schema.yml @@ -1361,6 +1361,14 @@ mapping: Users can override this settings in their user preferences if the localization settings are enabled in user_preferences_extra_conf.yml + galaxy_url_prefix: + type: str + default: '/' + required: false + desc: | + URL prefix for Galaxy application. If Galaxy should be served under a prefix set this to + the desired prefix value. + galaxy_infrastructure_url: type: str default: http://localhost:8080 @@ -1409,7 +1417,7 @@ mapping: logo_src: type: str - default: /static/favicon.png + default: static/favicon.png required: false per_host: true desc: | diff --git a/lib/galaxy/webapps/galaxy/fast_app.py b/lib/galaxy/webapps/galaxy/fast_app.py index 27051feccc6..2c81dd6a961 100644 --- a/lib/galaxy/webapps/galaxy/fast_app.py +++ b/lib/galaxy/webapps/galaxy/fast_app.py @@ -1,13 +1,23 @@ +import os +import stat +import typing from pathlib import Path from typing import cast +import anyio from fastapi import FastAPI, Request from fastapi.middleware.wsgi import WSGIMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.datastructures import Headers +from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware from starlette.responses import ( FileResponse, + RedirectResponse, Response, ) +from starlette.staticfiles import PathLike +from starlette.types import Scope from galaxy.webapps.base.api import ( add_exception_handler, @@ -16,6 +26,8 @@ from galaxy.webapps.base.api import ( ) from galaxy.webapps.base.webapp import config_allows_origin +if typing.TYPE_CHECKING: + from galaxy.config import Configuration # https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-tags api_tags_metadata = [ @@ -58,6 +70,90 @@ api_tags_metadata = [ ] +def get_static_from_config(conf, option_name, default_path): + config_val = conf.get(option_name, default_path) + per_host_config_option = f"{option_name}_by_host" + per_host_config = conf.get(per_host_config_option) + return (config_val, per_host_config or {}) + + +class GalaxyStaticFiles(StaticFiles): + def __init__( + self, *, config: "Configuration", html: bool = False, check_dir: bool = True + ) -> None: + self.config = config + self.directory, self.directory_per_host = get_static_from_config( + config, "static_dir", "static/" + ) + super().__init__( + directory=self.directory, packages=None, html=html, check_dir=check_dir + ) + + def get_directories( + self, directory: PathLike = None, packages: typing.List[str] = None + ) -> typing.List[PathLike]: + pass + + async def check_config(self) -> None: + """ + Perform a one-off configuration check that GalaxyStaticFiles is actually + pointed at directories, so that we can raise loud errors rather than + just returning 404 responses. + """ + for directory in (self.directory, *self.directory_per_host.values()): + try: + stat_result = await anyio.to_thread.run_sync(os.stat, directory) + except FileNotFoundError: + raise RuntimeError( + f"StaticFiles directory '{directory}' does not exist." + ) + if not ( + stat.S_ISDIR(stat_result.st_mode) or stat.S_ISLNK(stat_result.st_mode) + ): + raise RuntimeError( + f"StaticFiles path '{directory}' is not a directory." + ) + + async def get_response(self, path: str, scope: Scope) -> Response: + """ + Returns an HTTP response, given the incoming path, method and request headers. + """ + if scope["method"] not in ("GET", "HEAD"): + raise HTTPException(status_code=405) + + headers = Headers(scope=scope) + + try: + full_path, stat_result = await anyio.to_thread.run_sync( + self.lookup_path, path, headers.get("host") + ) + except PermissionError: + raise HTTPException(status_code=401) + except OSError: + raise + + if stat_result and stat.S_ISREG(stat_result.st_mode): + # We have a static file to serve. + return self.file_response(full_path, stat_result, scope) + + raise HTTPException(status_code=404) + + def lookup_path( + self, path: str, host: typing.Optional[str] = None + ) -> typing.Tuple[str, typing.Optional[os.stat_result]]: + directory = self.directory_per_host.get(host, self.directory) + full_path = os.path.realpath(os.path.join(directory, path)) + directory = os.path.realpath(directory) + if os.path.commonprefix([full_path, directory]) != directory: + # Don't allow misbehaving clients to break out of the static files + # directory. + return "", None + try: + return full_path, os.stat(full_path) + except (FileNotFoundError, NotADirectoryError): + return "", None + + class GalaxyCORSMiddleware(CORSMiddleware): def __init__(self, *args, **kwds): @@ -129,6 +225,16 @@ def initialize_fast_app(gx_wsgi_webapp, gx_app): add_galaxy_middleware(app, gx_app) add_request_id_middleware(app) include_all_package_routers(app, 'galaxy.webapps.galaxy.api') + app.mount("/static", GalaxyStaticFiles(config=gx_app.config), name="static") + + @app.get("/favicon.ico") + async def favicon(): + return RedirectResponse(url="static/favicon.ico") + wsgi_handler = WSGIMiddleware(gx_wsgi_webapp) app.mount('/', wsgi_handler) + if gx_app.config.galaxy_url_prefix != '/': + parent_app = FastAPI() + parent_app.mount(gx_app.config.galaxy_url_prefix, app=app) + return parent_app return app diff --git a/lib/galaxy/webapps/reports/buildapp.py b/lib/galaxy/webapps/reports/buildapp.py index 4b8098ea813..86508116077 100644 --- a/lib/galaxy/webapps/reports/buildapp.py +++ b/lib/galaxy/webapps/reports/buildapp.py @@ -68,10 +68,6 @@ def app_factory(global_conf, load_app_kwds=None, **kwargs): # Wrap the webapp in some useful middleware if kwargs.get('middleware', True): webapp = wrap_in_middleware(webapp, global_conf, app.application_stack, **kwargs) - if asbool(kwargs.get('static_enabled', True)): - webapp = wrap_if_allowed(webapp, app.application_stack, wrap_in_static, - args=(global_conf,), - kwargs=kwargs) return webapp @@ -121,10 +117,5 @@ def wrap_in_middleware(app, global_conf, application_stack, **local_conf): return app -def wrap_in_static(app, global_conf, **local_conf): - urlmap, _ = galaxy.webapps.base.webapp.build_url_map(app, global_conf, local_conf) - return urlmap - - def uwsgi_app(): return galaxy.webapps.base.webapp.build_native_uwsgi_app(app_factory, "reports") diff --git a/lib/tool_shed/webapp/buildapp.py b/lib/tool_shed/webapp/buildapp.py index 65affaf8d81..48ab3dee0ac 100644 --- a/lib/tool_shed/webapp/buildapp.py +++ b/lib/tool_shed/webapp/buildapp.py @@ -190,10 +190,6 @@ def app_factory(global_conf, load_app_kwds=None, **kwargs): # Wrap the webapp in some useful middleware if kwargs.get('middleware', True): webapp = wrap_in_middleware(webapp, global_conf, app.application_stack, **kwargs) - if asbool(kwargs.get('static_enabled', True)): - webapp = wrap_if_allowed(webapp, app.application_stack, wrap_in_static, - args=(global_conf,), - kwargs=kwargs) return webapp @@ -263,11 +259,6 @@ def wrap_in_middleware(app, global_conf, application_stack, **local_conf): return app -def wrap_in_static(app, global_conf, **local_conf): - urlmap, _ = galaxy.webapps.base.webapp.build_url_map(app, global_conf, local_conf) - return urlmap - - def _map_redirects(mapper): """ Add redirect to the Routes mapper and forward the received query string.