Skip to content

Commit d5e4161

Browse files
committed
Allow for more ui customizations
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
1 parent 5af304e commit d5e4161

20 files changed

Lines changed: 664 additions & 88 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test-py: ## run python tests
9797
tests-py: test-py
9898

9999
coverage-py: ## run python tests and collect test coverage
100-
python -m pytest -v csp_gateway/tests --cov=csp_gateway --cov-report term-missing --cov-report xml
100+
python -m pytest -v csp_gateway/tests --junitxml=junit.xml --cov=csp_gateway --cov-report term-missing --cov-report xml
101101

102102
.PHONY: test-js tests-js coverage-js
103103
test-js: ## run js tests

csp_gateway/server/middleware/api_key.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ def rest(self, app: GatewayWebApp) -> None:
6666
check = self.get_check_dependency()
6767

6868
@auth_router.get("/login")
69-
async def route_login_and_add_cookie(api_key: str = Depends(check)):
70-
response = RedirectResponse(url="/")
69+
async def route_login_and_add_cookie(request: Request, api_key: str = Depends(check)):
70+
response = RedirectResponse(url=app.root_path_url(request, "/"))
7171
response.set_cookie(
7272
self.api_key_name,
7373
value=api_key,
@@ -79,8 +79,8 @@ async def route_login_and_add_cookie(api_key: str = Depends(check)):
7979
return response
8080

8181
@auth_router.get("/logout")
82-
async def route_logout_and_remove_cookie():
83-
response = RedirectResponse(url="/login")
82+
async def route_logout_and_remove_cookie(request: Request):
83+
response = RedirectResponse(url=app.root_path_url(request, "/login"))
8484
response.delete_cookie(self.api_key_name, domain=self.domain)
8585
return response
8686

@@ -94,7 +94,7 @@ def _setup_public_routes(self, app: GatewayWebApp) -> None:
9494
async def get_login_page(token: str = "", request: Request = None):
9595
if token:
9696
if token != "":
97-
return RedirectResponse(url=f"{app.settings.API_STR}/auth/login?token={token}")
97+
return RedirectResponse(url=app.root_path_url(request, f"{app.settings.API_STR}/auth/login?token={token}"))
9898
return app.templates.TemplateResponse(
9999
request,
100100
"login.html.j2",

csp_gateway/server/middleware/oauth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ async def oauth_callback(request: Request, code: str = None, error: str = None):
311311
"id_token": tokens.get("id_token"),
312312
}
313313

314-
response = RedirectResponse(url="/")
314+
response = RedirectResponse(url=app.root_path_url(request, "/"))
315315
response.set_cookie(
316316
self.cookie_name,
317317
value=session_uuid,
@@ -332,7 +332,7 @@ async def logout(request: Request):
332332
if session_uuid and session_uuid in self._identity_store:
333333
self._identity_store.pop(session_uuid, None)
334334

335-
response = RedirectResponse(url="/login")
335+
response = RedirectResponse(url=app.root_path_url(request, "/login"))
336336
response.delete_cookie(self.cookie_name, domain=self.domain)
337337
return response
338338

@@ -355,4 +355,4 @@ async def auth_error_handler(request: Request, exc):
355355
{"detail": self.unauthorized_status_message, "status_code": exc.status_code},
356356
status_code=exc.status_code,
357357
)
358-
return RedirectResponse(url="/login")
358+
return RedirectResponse(url=app.root_path_url(request, "/login"))

csp_gateway/server/middleware/simple.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,13 @@ async def post_login(request: Request):
383383
password = form.get("password", "")
384384

385385
if not username or not password:
386-
return RedirectResponse(url="/login?error=missing_credentials", status_code=303)
386+
return RedirectResponse(url=app.root_path_url(request, "/login?error=missing_credentials"), status_code=303)
387387

388388
identity = self._validate_credentials(str(username), str(password))
389389

390390
if identity and isinstance(identity, dict):
391391
session_uuid = self._create_session(identity)
392-
response = RedirectResponse(url="/", status_code=303)
392+
response = RedirectResponse(url=app.root_path_url(request, "/"), status_code=303)
393393
response.set_cookie(
394394
self.cookie_name,
395395
value=session_uuid,
@@ -400,12 +400,12 @@ async def post_login(request: Request):
400400
)
401401
return response
402402

403-
return RedirectResponse(url="/login?error=invalid_credentials", status_code=303)
403+
return RedirectResponse(url=app.root_path_url(request, "/login?error=invalid_credentials"), status_code=303)
404404

405405
@auth_router.get("/login")
406-
async def api_login(session_uuid: str = Depends(check)):
406+
async def api_login(request: Request, session_uuid: str = Depends(check)):
407407
"""API login endpoint - validates Basic Auth and returns session cookie."""
408-
response = RedirectResponse(url="/")
408+
response = RedirectResponse(url=app.root_path_url(request, "/"))
409409
response.set_cookie(
410410
self.cookie_name,
411411
value=session_uuid,
@@ -423,7 +423,7 @@ async def logout(request: Request):
423423
if session_uuid and session_uuid in self._identity_store:
424424
self._identity_store.pop(session_uuid, None)
425425

426-
response = RedirectResponse(url="/login")
426+
response = RedirectResponse(url=app.root_path_url(request, "/login"))
427427
response.delete_cookie(self.cookie_name, domain=self.domain)
428428
return response
429429

@@ -450,7 +450,7 @@ async def auth_error_handler(request: Request, exc):
450450
status_code=exc.status_code,
451451
)
452452
if self.enable_form_login:
453-
return RedirectResponse(url="/login")
453+
return RedirectResponse(url=app.root_path_url(request, "/login"))
454454
# Return 401 with Basic Auth challenge
455455
return JSONResponse(
456456
{"detail": self.unauthorized_status_message},

csp_gateway/server/settings.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,47 @@ class Settings(BaseSettings):
3333
BIND: str = "0.0.0.0"
3434
PORT: int = 8000
3535

36+
ROOT_PATH: str = Field(
37+
default="",
38+
description="URL path prefix the app is served under when behind a reverse proxy that "
39+
"strips the prefix (e.g. '/watchtower'). Passed to the ASGI server as root_path and used "
40+
"to prefix all server-rendered asset/API URLs so the UI works under a sub-path. Leave "
41+
"empty when served at a domain root.",
42+
)
43+
3644
UI: bool = Field(False, description="Enables ui in the web application")
3745

38-
# --- DEPRECATED auth settings ---
46+
# UI customization fields that let downstream applications white-label the
47+
# default UI from server-side config alone, without a custom Javascript bundle.
48+
HEADER_LOGO: Optional[str] = Field(
49+
default=None,
50+
description="Header logo image, given as an http(s) URL, a data URI, an absolute "
51+
"URL path, or a local file path (local files are served automatically).",
52+
)
53+
FOOTER_LOGO: Optional[str] = Field(
54+
default=None,
55+
description="Footer logo image, given as an http(s) URL, a data URI, an absolute "
56+
"URL path, or a local file path (local files are served automatically).",
57+
)
58+
CUSTOM_JS: List[str] = Field(
59+
default_factory=list,
60+
description="Custom Javascript files to inject into the UI, given as URLs or local "
61+
"file paths (local files are served automatically). Loaded after the main bundle.",
62+
)
63+
CUSTOM_CSS: List[str] = Field(
64+
default_factory=list,
65+
description="Custom CSS files to inject into the UI, given as URLs or local file "
66+
"paths (local files are served automatically). Loaded after the main stylesheet.",
67+
)
68+
CUSTOM_STATIC_DIR: Optional[str] = Field(
69+
default=None,
70+
description="Local directory served at <root_path>/custom. The entire directory is exposed "
71+
"as public static content (useful for assets like logos, e.g. /custom/logo.svg); its top-level "
72+
"*.js and *.css files are additionally auto-injected into the UI in sorted filename order. Do "
73+
"not point this at a directory containing private files.",
74+
)
75+
76+
# DEPRECATED auth settings
3977
# Historically (csp-gateway <2.5), auth was configured via these two fields
4078
# on Settings. In 2.5+, auth moved onto `MountAPIKeyMiddleware` as module
4179
# fields. Keeping these here (default-None sentinels) lets existing YAML

csp_gateway/server/web/app.py

Lines changed: 160 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Any, Callable, Dict, List, Optional, Set
99

1010
from csp.impl.types.tstype import isTsType
11-
from fastapi import APIRouter, FastAPI
11+
from fastapi import APIRouter, FastAPI, HTTPException, Request
1212
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
1313
from fastapi.openapi.utils import get_openapi
1414
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
@@ -77,11 +77,13 @@ def __init__(
7777
_in_test: bool = False,
7878
):
7979
# Instantiate a new FastAPI instance
80+
root_path = self._normalize_root_path(settings.ROOT_PATH)
8081
self.app = FastAPI(
8182
title=settings.TITLE,
8283
description=settings.DESCRIPTION,
8384
version=settings.VERSION,
8485
contact={"name": settings.AUTHOR, "email": settings.EMAIL},
86+
root_path=root_path,
8587
lifespan=self._lifespan,
8688
)
8789
self.templates = Jinja2Templates(
@@ -98,8 +100,14 @@ def __init__(
98100
# setup controls
99101
self._controls = {}
100102

103+
# local files (logos / custom js / css) served by url, keyed by url path
104+
self._custom_asset_routes: Dict[str, str] = {}
105+
106+
# raw UI customization config (root-relative URLs); populated in add_static_files
107+
self._ui_config_raw: Dict[str, Any] = {}
108+
101109
# update ui in settings
102-
self.settings = settings.model_copy()
110+
self.settings = settings.model_copy(update={"ROOT_PATH": root_path})
103111
if ui:
104112
self.settings.UI = True
105113

@@ -189,24 +197,131 @@ def add_docs(self) -> None:
189197

190198
# Mount openapi
191199
@app_router.get("/openapi.json", include_in_schema=False)
192-
def getOpenapi() -> Dict[str, Any]:
200+
def getOpenapi(request: Request) -> Dict[str, Any]:
201+
root_path = request.scope.get("root_path", "")
193202
return get_openapi(
194203
title=self.settings.TITLE,
195204
version=self.settings.VERSION,
196205
routes=self.app.routes,
206+
servers=[{"url": root_path}] if root_path else None,
197207
)
198208

199209
@app_router.get("/docs/", include_in_schema=False, response_class=HTMLResponse)
200-
def getDocs():
201-
return get_swagger_ui_html(openapi_url="/openapi.json", title=self.settings.TITLE)
210+
def getDocs(request: Request):
211+
root_path = request.scope.get("root_path", "")
212+
return get_swagger_ui_html(openapi_url=f"{root_path}/openapi.json", title=self.settings.TITLE)
202213

203214
@app_router.get("/redoc/", include_in_schema=False, response_class=HTMLResponse)
204-
def getRedoc():
205-
return get_redoc_html(openapi_url="/openapi.json", title=self.settings.TITLE)
215+
def getRedoc(request: Request):
216+
root_path = request.scope.get("root_path", "")
217+
return get_redoc_html(openapi_url=f"{root_path}/openapi.json", title=self.settings.TITLE)
218+
219+
@staticmethod
220+
def _is_asset_url(value: str) -> bool:
221+
"""Whether an asset reference is already a servable URL (vs a local file path)."""
222+
return value.startswith(("http://", "https://", "data:"))
223+
224+
@staticmethod
225+
def _normalize_root_path(value: Optional[str]) -> str:
226+
"""Normalize a configured ROOT_PATH to '' or a leading-slash, no-trailing-slash path.
227+
228+
'' / '/' -> '', 'watchtower' -> '/watchtower', '/watchtower/' -> '/watchtower'.
229+
"""
230+
if not value:
231+
return ""
232+
value = value.strip().rstrip("/")
233+
if not value:
234+
return ""
235+
if not value.startswith("/"):
236+
value = "/" + value
237+
return value
238+
239+
@staticmethod
240+
def _join_root_path(root_path: str, url: Optional[str]) -> Optional[str]:
241+
"""Prefix a root-relative URL with the proxy root_path, leaving absolute URLs alone."""
242+
if not url or not root_path:
243+
return url
244+
if url.startswith(("http://", "https://", "data:")):
245+
return url
246+
if url.startswith("/"):
247+
return f"{root_path.rstrip('/')}{url}"
248+
return url
249+
250+
@staticmethod
251+
def root_path_url(request: Optional[Request], url: str) -> str:
252+
"""Prefix a root-relative URL with the current request's proxy root_path.
253+
254+
Use for redirect targets and template links so auth and navigation flows
255+
work when the app is served under a sub-path behind a reverse proxy.
256+
"""
257+
root_path = request.scope.get("root_path", "") if request is not None else ""
258+
return GatewayWebApp._join_root_path(root_path, url) or url
259+
260+
def _prefixed_ui_config(self, root_path: str) -> Dict[str, Any]:
261+
"""Return the UI config with all local asset URLs prefixed for the current root_path."""
262+
raw = self._ui_config_raw
263+
return {
264+
**raw,
265+
"basePath": root_path or "",
266+
"headerLogo": self._join_root_path(root_path, raw["headerLogo"]),
267+
"footerLogo": self._join_root_path(root_path, raw["footerLogo"]),
268+
"customCss": [self._join_root_path(root_path, css) for css in raw["customCss"]],
269+
"customJs": [self._join_root_path(root_path, js) for js in raw["customJs"]],
270+
}
271+
272+
def _resolve_asset(self, value: Optional[str], kind: str) -> Optional[str]:
273+
"""Resolve an asset reference to a URL, serving local files automatically."""
274+
if not value:
275+
return None
276+
# http(s) URLs and data URIs are used as-is. Anything that exists on disk
277+
# is served automatically; everything else is treated as a URL path
278+
# (e.g. an already-mounted "/static/..." or "/img/..." asset).
279+
if self._is_asset_url(value) or not path.isfile(value):
280+
return value
281+
abspath = path.abspath(value)
282+
url = f"/custom-assets/{kind}-{len(self._custom_asset_routes)}-{path.basename(abspath)}"
283+
self._custom_asset_routes[url] = abspath
284+
return url
285+
286+
def _resolve_ui_assets(self):
287+
"""Resolve all configured UI assets into URLs, mounting/serving local files."""
288+
header_logo = self._resolve_asset(self.settings.HEADER_LOGO, "logo")
289+
footer_logo = self._resolve_asset(self.settings.FOOTER_LOGO, "logo")
290+
custom_css = [self._resolve_asset(css, "css") for css in self.settings.CUSTOM_CSS]
291+
custom_js = [self._resolve_asset(js, "js") for js in self.settings.CUSTOM_JS]
292+
293+
# Auto-discover any *.js / *.css in the configured custom static directory
294+
if self.settings.CUSTOM_STATIC_DIR:
295+
custom_dir = path.abspath(self.settings.CUSTOM_STATIC_DIR)
296+
if path.isdir(custom_dir):
297+
self.app.mount(
298+
"/custom",
299+
CacheControlledStaticFiles(directory=custom_dir, check_dir=False),
300+
name="custom",
301+
)
302+
for fname in sorted(os.listdir(custom_dir)):
303+
if fname.endswith(".css"):
304+
custom_css.append(f"/custom/{fname}")
305+
elif fname.endswith(".js"):
306+
custom_js.append(f"/custom/{fname}")
307+
else:
308+
self.logger.warning("CUSTOM_STATIC_DIR %s is not a directory", custom_dir)
309+
310+
# Raw config with root-relative URLs; prefixed per-request via _prefixed_ui_config.
311+
self._ui_config_raw = {
312+
"title": self.settings.TITLE,
313+
"description": "",
314+
"headerLogo": header_logo,
315+
"footerLogo": footer_logo,
316+
"customCss": custom_css,
317+
"customJs": custom_js,
318+
}
319+
return self._ui_config_raw
206320

207321
def add_static_files(self) -> None:
208322
"""Add static file handlers to FastAPI app"""
209323
app_router: APIRouter = self.get_router("app")
324+
public_router: APIRouter = self.get_router("public")
210325

211326
# Mount static files
212327
self.app.mount(
@@ -222,6 +337,25 @@ def add_static_files(self) -> None:
222337
name="img",
223338
)
224339

340+
# Resolve UI customization assets (logos, custom js/css), serving local files
341+
self._resolve_ui_assets()
342+
343+
# Serve any local files referenced by the UI customization settings
344+
if self._custom_asset_routes:
345+
346+
@app_router.get("/custom-assets/{name:path}", include_in_schema=False, response_class=FileResponse)
347+
async def serve_custom_asset(name: str):
348+
target = self._custom_asset_routes.get(f"/custom-assets/{name}")
349+
if target is None:
350+
raise HTTPException(status_code=404, detail="Not found")
351+
return FileResponse(target)
352+
353+
# Expose the UI customization config (title, logos, custom assets) for the frontend.
354+
# Public (no auth) so the UI shell can render before authentication.
355+
@public_router.get("/ui-config", include_in_schema=False)
356+
async def get_ui_config(request: Request) -> Dict[str, Any]:
357+
return self._prefixed_ui_config(request.scope.get("root_path", ""))
358+
225359
# Mount top level routes
226360
@self.app.get("/favicon.ico", include_in_schema=False, response_class=FileResponse)
227361
async def readFavicon():
@@ -230,15 +364,29 @@ async def readFavicon():
230364
# Add UI if present, otherwise redirect to docs
231365
if self.settings.UI:
232366

233-
@app_router.get("/", include_in_schema=False, response_class=FileResponse)
234-
async def serve_react_app():
235-
return FileResponse(path.join(build_files_dir, "index.html"))
367+
@app_router.get("/", include_in_schema=False, response_class=HTMLResponse)
368+
async def serve_react_app(request: Request):
369+
root_path = request.scope.get("root_path", "")
370+
ui_config = self._prefixed_ui_config(root_path)
371+
return self.templates.TemplateResponse(
372+
request,
373+
"index.html.j2",
374+
{
375+
"title": ui_config["title"],
376+
"description": ui_config["description"],
377+
"base_path": root_path,
378+
"ui_config": ui_config,
379+
"custom_css": ui_config["customCss"],
380+
"custom_js": ui_config["customJs"],
381+
},
382+
)
236383

237384
else:
238385

239386
@self.app.get("/", include_in_schema=False, response_class=RedirectResponse)
240-
async def serve_react_app():
241-
return RedirectResponse("/redoc")
387+
async def serve_react_app(request: Request):
388+
root_path = request.scope.get("root_path", "")
389+
return RedirectResponse(f"{root_path}/redoc")
242390

243391
def add_api(self) -> None:
244392
"""Add API handlers to FastAPI app"""

0 commit comments

Comments
 (0)