207 lines
5.7 KiB
Python
207 lines
5.7 KiB
Python
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)}
|
|
)
|