diff --git a/conda-store-server/conda_store_server/_internal/server/app.py b/conda-store-server/conda_store_server/_internal/server/app.py index dda9affc0..ba0a0de9a 100644 --- a/conda-store-server/conda_store_server/_internal/server/app.py +++ b/conda-store-server/conda_store_server/_internal/server/app.py @@ -289,15 +289,32 @@ async def exception_handler(request, exc): # docker registry api specification does not support a url_prefix app.include_router(views.router_registry) - if self.enable_ui: + if self.enable_metrics: app.include_router( - views.router_ui, - prefix=trim_slash(self.url_prefix) + "/admin", + views.router_metrics, + prefix=trim_slash(self.url_prefix), ) + if self.additional_routes: + for path, method, func in self.additional_routes: + getattr(app, method)(path, name=func.__name__)(func) + + if isinstance(self.conda_store.storage, storage.LocalStorage): + self.conda_store.storage.storage_url = ( + f"{trim_slash(self.url_prefix)}/storage" + ) + app.mount( + self.conda_store.storage.storage_url, + StaticFiles(directory=self.conda_store.storage.storage_path), + name="static-storage", + ) + + # This needs to come at the end because if the UI is enabled, + # it becomes the catch all route + if self.enable_ui: app.include_router( - views.router_conda_store_ui, - prefix=trim_slash(self.url_prefix), + views.router_ui, + prefix=trim_slash(self.url_prefix) + "/admin", ) # serving static files @@ -314,15 +331,20 @@ async def exception_handler(request, exc): name="static", ) - # convenience to redirect "/" to home page when using a prefix - # realistically this url will not be hit with a proxy + prefix - if self.url_prefix != "/": - - @app.get("/") - def redirect_home(request: Request): - return RedirectResponse(request.url_for("get_conda_store_ui")) + # Redirect both "/" and `url_prefix` to the conda-store-ui React app. + # Realistically the "/" will not be hit with a proxy + prefix. + @app.get( + # Yes if url_prefix is "/" then this decorator is redundant but FastAPI doesn't seem to be bothered. + "/" + ) + @app.get(self.url_prefix) + # This function name may be used by url_for() so be careful renaming it + def redirect_root_to_ui(request: Request): + return RedirectResponse(request.url_for("get_conda_store_ui")) - @app.get("/favicon.ico", include_in_schema=False) + @app.get( + trim_slash(self.url_prefix) + "/favicon.ico", include_in_schema=False + ) async def favicon(): return FileResponse( os.path.join( @@ -332,26 +354,12 @@ async def favicon(): ) ) - if self.enable_metrics: + # Put this at the very end app.include_router( - views.router_metrics, + views.router_conda_store_ui, prefix=trim_slash(self.url_prefix), ) - if self.additional_routes: - for path, method, func in self.additional_routes: - getattr(app, method)(path, name=func.__name__)(func) - - if isinstance(self.conda_store.storage, storage.LocalStorage): - self.conda_store.storage.storage_url = ( - f"{trim_slash(self.url_prefix)}/storage" - ) - app.mount( - self.conda_store.storage.storage_url, - StaticFiles(directory=self.conda_store.storage.storage_path), - name="static-storage", - ) - return app def _check_worker(self, delay=5): diff --git a/conda-store-server/conda_store_server/_internal/server/dependencies.py b/conda-store-server/conda_store_server/_internal/server/dependencies.py index 84c04fd54..7226760bd 100644 --- a/conda-store-server/conda_store_server/_internal/server/dependencies.py +++ b/conda-store-server/conda_store_server/_internal/server/dependencies.py @@ -23,3 +23,7 @@ async def get_entity(request: Request, auth=Depends(get_auth)): async def get_templates(request: Request): return request.state.templates + + +async def get_url_prefix(request: Request, server=Depends(get_server)): + return server.url_prefix diff --git a/conda-store-server/conda_store_server/_internal/server/templates/conda-store-ui.html b/conda-store-server/conda_store_server/_internal/server/templates/conda-store-ui.html index 2a05a49db..b651732c1 100644 --- a/conda-store-server/conda_store_server/_internal/server/templates/conda-store-ui.html +++ b/conda-store-server/conda_store_server/_internal/server/templates/conda-store-ui.html @@ -22,14 +22,15 @@ REACT_APP_AUTH_TOKEN: "", REACT_APP_STYLE_TYPE: "green-accent", REACT_APP_SHOW_LOGIN_ICON: "true", - REACT_APP_API_URL: "{{ url_for('get_conda_store_ui') }}", + REACT_APP_API_URL: "{{ url_for('redirect_root_to_ui') }}", REACT_APP_LOGIN_PAGE_URL: "{{ url_for('get_login_method') }}?next=", REACT_APP_LOGOUT_PAGE_URL: "{{ url_for('post_logout_method') }}?next=/", - REACT_APP_URL_BASENAME: new URL("{{ url_for('get_conda_store_ui') }}").pathname + REACT_APP_URL_BASENAME: "{{ url_prefix.rstrip('/') }}{{ ui_prefix }}" }; - - + + + diff --git a/conda-store-server/conda_store_server/_internal/server/views/conda_store_ui.py b/conda-store-server/conda_store_server/_internal/server/views/conda_store_ui.py index 511c1700b..bd60c14ce 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/conda_store_ui.py +++ b/conda-store-server/conda_store_server/_internal/server/views/conda_store_ui.py @@ -3,18 +3,36 @@ # license that can be found in the LICENSE file. from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse from conda_store_server._internal.server import dependencies -router_conda_store_ui = APIRouter(tags=["conda-store-ui"]) +router_conda_store_ui = APIRouter( + prefix="/ui", + tags=["conda-store-ui"], + # Provide the response class so that it can show up in the autogenerated API + # docs. The docs show the endpoints as returning HTML rather than JSON. + default_response_class=HTMLResponse, +) +# Yes, "{path:path}"" makes "/" redundant. But if we don't define "/", then we +# always have to pass `path` to the `url_for` function like so: +# url_for("get_conda_store_ui", path="") @router_conda_store_ui.get("/") +@router_conda_store_ui.get("{path:path}") async def get_conda_store_ui( request: Request, + path: str, templates=Depends(dependencies.get_templates), + url_prefix=Depends(dependencies.get_url_prefix), ): context = { "request": request, + "url_prefix": url_prefix, + "ui_prefix": router_conda_store_ui.prefix, } - return templates.TemplateResponse("conda-store-ui.html", context) + response = templates.TemplateResponse("conda-store-ui.html", context) + if path.endswith("not-found"): + response.status_code = 404 + return response diff --git a/conda-store-server/tests/_internal/server/views/test_conda_store_ui.py b/conda-store-server/tests/_internal/server/views/test_conda_store_ui.py new file mode 100644 index 000000000..cba77cf18 --- /dev/null +++ b/conda-store-server/tests/_internal/server/views/test_conda_store_ui.py @@ -0,0 +1,104 @@ +# Copyright (c) conda-store development team. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +import pytest +from fastapi.testclient import TestClient + + +def assert_client_app(response): + """A few checks that should all pass if the response is ok and contains + the client app + """ + assert response.is_success + assert "text/html" in response.headers["content-type"] + assert "condaStoreConfig" in response.text + + +class TestUIRoutes: + url_prefix = "/" + + def full_route(self, route): + if self.url_prefix == "/": + return route + else: + return self.url_prefix + route + + @pytest.fixture + def testclient(self, conda_store_server): + conda_store_server.enable_ui = True + conda_store_server.url_prefix = self.url_prefix + return TestClient(conda_store_server.init_fastapi_app()) + + def test_base_route(self, testclient): + """The server should return the client app""" + response = testclient.get(self.url_prefix) + assert_client_app(response) + + def test_unknown_routes(self, testclient): + """Rather than return a 404, the server should return the client app + and let it handle unknown routes + """ + response = testclient.get(self.full_route("/ui/foo")) + assert_client_app(response) + + response = testclient.get(self.full_route("/ui/foo/bar")) + assert_client_app(response) + + def test_not_found_route(self, testclient): + """The /not-found route should also return the client app + but with a 404 status code + """ + response = testclient.get(self.full_route("/ui/not-found")) + assert response.status_code == 404 + assert "text/html" in response.headers["content-type"] + assert "condaStoreConfig" in response.text + + def test_route_outside_ui_app(self, testclient): + """The server should not return the client app for a server-side + route + """ + response = testclient.get(self.full_route("/admin/")) + assert response.is_success + assert "condaStoreConfig" not in response.text + + response = testclient.get(self.full_route("/favicon.ico")) + assert response.is_success + assert response.headers["content-type"].startswith("image/") + + +def assert_not_found_not_client_app(response): + assert response.status_code == 404 + assert "condaStoreConfig" not in response.text + + +class TestUIRoutesCustomPrefix(TestUIRoutes): + url_prefix = "/conda-store" + + def test_unknown_route_outside_prefix(self, testclient): + """The server should return a 404 for an unknown route outside + the url prefix and should not return the client app + """ + response = testclient.get("/ui/foo/bar") + assert_not_found_not_client_app(response) + + +def test_ui_disabled(conda_store_server): + """When the UI is disabled, the server should return 404s for + all UI routes and should not return the client app + """ + conda_store_server.enable_ui = False + conda_store_server.url_prefix = "/" + testclient = TestClient(conda_store_server.init_fastapi_app()) + + response = testclient.get("/") + assert_not_found_not_client_app(response) + + response = testclient.get("/ui/not-found") + assert_not_found_not_client_app(response) + + response = testclient.get("/admin/") + assert_not_found_not_client_app(response) + + response = testclient.get("/favicon.ico") + assert_not_found_not_client_app(response) diff --git a/docusaurus-docs/conda-store-ui/how-tos/configure-ui.md b/docusaurus-docs/conda-store-ui/how-tos/configure-ui.md index 5d1d71670..71a1d271b 100644 --- a/docusaurus-docs/conda-store-ui/how-tos/configure-ui.md +++ b/docusaurus-docs/conda-store-ui/how-tos/configure-ui.md @@ -23,6 +23,7 @@ REACT_APP_AUTH_TOKEN= REACT_APP_STYLE_TYPE=green-accent REACT_APP_SHOW_AUTH_BUTTON=true REACT_APP_LOGOUT_PAGE_URL=http://localhost:8080/conda-store/logout?next=/ +REACT_APP_URL_BASENAME="/conda-store/ui" ``` ### At runtime, using `condaStoreConfig` @@ -40,6 +41,7 @@ In your HTML file, add the following **before** loading the react app : REACT_APP_API_URL: "http://localhost:8080/conda-store", REACT_APP_LOGIN_PAGE_URL: "http://localhost:8080/conda-store/login?next=", REACT_APP_LOGOUT_PAGE_URL: "http://localhost:8080/conda-store/logout?next=/", + REACT_APP_URL_BASENAME="/conda-store/ui", }; ```