88from typing import Any , Callable , Dict , List , Optional , Set
99
1010from csp .impl .types .tstype import isTsType
11- from fastapi import APIRouter , FastAPI
11+ from fastapi import APIRouter , FastAPI , HTTPException , Request
1212from fastapi .openapi .docs import get_redoc_html , get_swagger_ui_html
1313from fastapi .openapi .utils import get_openapi
1414from 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