from pathlib import Path, PurePosixPath import magic from aiohttp import web from aiohttp.web import Request, StreamResponse from app.library.config import Config from app.library.log import get_logger from app.library.router import add_route from app.library.Utils import get_file MIME = magic.Magic(mime=True) LOG = get_logger() EXT_TO_MIME: dict[str, str] = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".m4a": "audio/mp4", } class StaticState: def __init__(self) -> None: self.root: Path | None = None self.index_file: Path | None = None STATIC_STATE = StaticState() def get_root(root_path: Path, config: Config) -> Path | None: """ Get the static root directory. Args: root_path (Path): The application root path. config (Config): The configuration instance. Returns: Path | None: The static directory, or None when UI is ignored. """ search_paths: list[Path] = [] if config.static_ui_path: search_paths.append(Path(config.static_ui_path).absolute()) search_paths.extend( [ (root_path / "ui" / "exported").absolute(), (root_path.parent / "ui" / "exported").absolute(), ] ) for path in search_paths: if path.is_dir(): return path paths = [str(path) for path in search_paths] message: str = f"Could not find frontend assets in any configured search path: {paths}." if config.ignore_ui: LOG.warning(message) return None raise ValueError(message) def normalize_path(path: str, base_path: str) -> str: """ Normalize the request path by stripping the base path. Args: path (str): The raw request path. base_path (str): The configured base path. Returns: str: The path relative to the static root. """ if "/" == base_path: return path or "/" base_prefix: str = base_path.rstrip("/") if path == base_prefix: return "/" if path.startswith(f"{base_prefix}/"): stripped: str = path[len(base_prefix) :] return stripped or "/" return path or "/" def get_static_file(path: str) -> Path | None: """ Get the static file corresponding to a request path. Args: path (str): The normalized request path. Returns: Path | None: The resolved file path. """ if STATIC_STATE.root is None: return None relative_path: str = path.lstrip("/") if not relative_path: return STATIC_STATE.index_file if STATIC_STATE.index_file and STATIC_STATE.index_file.is_file() else None file, status = get_file(STATIC_STATE.root, relative_path) if web.HTTPOk.status_code == status and file.is_file(): return file return None async def serve_static_file(request: Request, config: Config) -> StreamResponse: """ Serve frontend static files with SPA fallback handling. Args: request (Request): The request object. config (Config): The configuration instance. Returns: StreamResponse: The response object. """ path: str = normalize_path(request.path, config.base_path) file_path: Path | None = get_static_file(path) if file_path is None: if ( STATIC_STATE.index_file is not None and not path.startswith("/api/") and not PurePosixPath(path.lstrip("/")).suffix ): file_path = STATIC_STATE.index_file else: return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code) response = web.FileResponse( path=file_path, headers={ "Pragma": "public", "Cache-Control": "public, max-age=31536000", "Content-Type": EXT_TO_MIME.get(file_path.suffix, MIME.from_file(str(file_path))), }, status=web.HTTPOk.status_code, ) if STATIC_STATE.index_file is not None and file_path == STATIC_STATE.index_file: response.set_cookie( name="simple_mode", value="true" if config.simple_mode else "false", path=config.base_path if "/" != config.base_path else "/", samesite="Lax", ) return response def setup_static_routes(root_path: Path, config: Config) -> None: """ Set up routes for serving frontend static files. Args: root_path (Path): The application root path. config (Config): The configuration instance. """ STATIC_STATE.root = get_root(root_path, config) STATIC_STATE.index_file = None if STATIC_STATE.root is None: return index_file: Path = (STATIC_STATE.root / "index.html").resolve() if not index_file.is_file(): message: str = f"Failed to find frontend entry file at '{index_file}'." if config.ignore_ui: LOG.warning(message) STATIC_STATE.root = None return raise ValueError(message) STATIC_STATE.index_file = index_file add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index") add_route(method="GET", path="/{path:.*}", handler=serve_static_file, name="static_fallback") if "/" != config.base_path: async def redirect_index(config: Config) -> web.Response: return web.Response(status=web.HTTPFound.status_code, headers={"Location": config.base_path}) add_route(method="GET", path="/", handler=redirect_index, name="index_redirect") LOG.info( "Serving frontend static assets from '%s'.", STATIC_STATE.root, extra={"static_root": str(STATIC_STATE.root)} )