import base64 import hmac import inspect from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast import anyio from aiohttp import web from aiohttp.typedefs import Handler, Middleware from aiohttp.web import Request, Response from aiohttp.web_log import AccessLogger from aiohttp.web_request import BaseRequest from aiohttp.web_response import StreamResponse from app.library.log import get_logger from app.library.Services import Services from .cache import Cache from .config import Config from .encoder import Encoder from .Events import EventBus from .router import RouteType, get_routes from .Utils import decrypt_data, encrypt_data, get_file, load_modules LOG = get_logger("http") class HttpAccessLogger(AccessLogger): def log(self, request: BaseRequest, response: StreamResponse, time: float) -> None: try: fmt_info = self._format_line(request, response, time) values: list[object] = [] extra: dict[str, Any] = {"elapsed_ms": round(time * 1000.0, 2)} for key, value in fmt_info: values.append(value) if isinstance(key, str): extra[key] = value else: parent, child = key group: dict[str, Any] = extra.get(parent, {}) # type: ignore[assignment] if not isinstance(group, dict): group = {} group[child] = value extra[parent] = group message = self._log_format % tuple(values) self.logger.info(message, extra=extra) except Exception: self.logger.exception("Error in logging") class HttpAPI: def __init__(self, root_path: Path): self.encoder: Encoder = Encoder() self.config: Config = Config.get_instance() self._notify: EventBus = EventBus.get_instance() self.rootPath: Path = root_path self.cache: Cache = Cache.get_instance() self.app: web.Application | None = None services: Services = Services.get_instance() services.add_all( { k: v for k, v in { "encoder": self.encoder, "config": self.config, "notify": self._notify, "cache": self.cache, "http_api": self, "root_path": self.rootPath, }.items() if not services.has(k) } ) async def on_shutdown(self, _: web.Application): pass def attach(self, app: web.Application): """ Attach the routes to the application. Args: app (web.Application): The application to attach the routes to. Returns: HttpAPI: The instance of the HttpAPI. """ self.app = app app.middlewares.append( HttpAPI.middle_wares( app=app, base_path=self.config.base_path.rstrip("/"), download_path=self.config.download_path, ) ) if self.config.auth_username and self.config.auth_password: app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password)) self.add_routes(app) async def on_prepare(request: Request, response: Response): if "Server" in response.headers: del response.headers["Server"] response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*") response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" response.headers["Access-Control-Allow-Methods"] = "GET, PATCH, PUT, POST, DELETE" response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Max-Age"] = str(60 * 15) try: app.on_response_prepare.append(on_prepare) except Exception as e: LOG.exception( "Failed to register response preparation middleware.", extra={"operation": "register_on_response_prepare", "exception_type": type(e).__name__}, ) app.on_shutdown.append(self.on_shutdown) def add_routes(self, app: web.Application) -> None: """ Add the routes to the application. Args: app (web.Application): The application to attach the routes to. Returns: HttpAPI: The instance of the HttpAPI. """ registered_options: list = [] base_path: str = self.config.base_path.rstrip("/") from app.routes.api._static import setup_static_routes load_modules(self.rootPath, self.rootPath / "routes" / "api") setup_static_routes(self.rootPath, self.config) async def options_handler(_: Request) -> Response: return web.Response(status=204) def _handle(handler): async def wrapped(request): return await self._handle(handler, request) return wrapped for route in get_routes(RouteType.HTTP).values(): routePath: str = f"/{route.path.lstrip('/')}" if route.path in (self.config.base_path, "/"): pass elif "" == base_path or not routePath.rstrip("/").startswith(base_path.rstrip("/")): route.path = f"{base_path}/{route.path.lstrip('/')}" if self.config.debug: LOG.debug( "Adding route '%s' %s: %s.", route.name, route.method, route.path, extra={"route_name": route.name, "method": route.method, "path": route.path}, ) app.router.add_route(route.method, route.path, handler=_handle(route.handler), name=route.name) if route.path in registered_options: continue app.router.add_route("OPTIONS", route.path, handler=options_handler, name=f"{route.name}_opts") registered_options.append(route.path) @staticmethod def basic_auth(username: str, password: str) -> Middleware: """ Middleware to handle basic authentication. Args: username (str): The username. password (str): The password. Returns: Awaitable: The middleware handler. """ @web.middleware async def auth_handler(request: Request, handler: Handler) -> StreamResponse: # if OPTIONS request, skip auth if "OPTIONS" == request.method: return await handler(request) auth_header = request.headers.get("Authorization") if auth_header is None and request.query.get("apikey") is not None: auth_header = f"Basic {request.query.get('apikey')}" if auth_header is None: auth_cookie = request.cookies.get("auth") if auth_cookie is not None: try: data = decrypt_data(auth_cookie, key=cast("bytes", Config.get_instance().secret_key)) if data is not None: data = base64.b64encode(data.encode()).decode() auth_header = f"Basic {data}" except Exception as e: LOG.exception( "Failed to decrypt authentication cookie.", extra={ "route": str(request.rel_url), "method": request.method, "exception_type": type(e).__name__, }, ) if auth_header is None: return web.json_response( status=web.HTTPUnauthorized.status_code, headers={ "WWW-Authenticate": 'Basic realm="Authorization Required."', }, data={"error": "Authorization Required."}, ) auth_type, encoded_credentials = auth_header.split(" ", 1) if "basic" != auth_type.lower(): return web.json_response( data={"error": "Unsupported authentication method.", "method": auth_type}, status=web.HTTPUnauthorized.status_code, ) decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8") user_name, _, user_password = decoded_credentials.partition(":") user_match = hmac.compare_digest(user_name, username) pass_match = hmac.compare_digest(user_password, password) if not (user_match and pass_match): return web.json_response( data={"error": "Unauthorized (Invalid credentials)."}, status=web.HTTPUnauthorized.status_code, headers={ "WWW-Authenticate": 'Basic realm="Authorization Required."', }, ) response: StreamResponse = await handler(request) contentType: str | None = response.headers.get("content-type", None) if contentType and not contentType.startswith("text/html"): return response try: response.set_cookie( "auth", encrypt_data( f"{user_name}:{user_password}", key=cast("bytes", Config.get_instance().secret_key), ), max_age=60 * 60 * 24 * 7, expires=(datetime.now(UTC) + timedelta(days=7)).strftime("%a, %d %b %Y %H:%M:%S GMT"), httponly=True, samesite="Strict", ) except Exception as e: LOG.exception( "Failed to set authentication cookie for '%s'.", request.rel_url, extra={"route": str(request.rel_url), "method": request.method, "exception_type": type(e).__name__}, ) return response return auth_handler @staticmethod def middle_wares(app: web.Application, base_path: str, download_path: str) -> Middleware: @web.middleware async def middleware_handler(request: Request, handler: Handler) -> StreamResponse: static_path = str(app.router["download_static"].url_for(filename="")) if request.path.startswith(static_path): realFile, status = get_file( download_path=download_path, file=request.path.replace(static_path, ""), ) if web.HTTPFound.status_code == status: return Response( status=status, headers={ "Location": str( app.router["download_static"].url_for( filename=str(realFile).replace(download_path, "").strip("/") ) ) }, ) response: StreamResponse = await handler(request) contentType: str = str(response.headers.get("content-type", "")) if contentType.startswith("text/html") and getattr(response, "_path", None): rewrite_path: str = base_path.rstrip("/") response_path = cast("Any", response)._path async with await anyio.open_file(response_path, "rb") as f: content = await f.read() content: str = ( content.decode("utf-8") .replace('', f'') .replace("/_base_path/", f"{rewrite_path}/") ) new_response = web.Response(text=content, content_type="text/html") for k, v in response.headers.items(): if k.lower() != "content-type": new_response.headers[k] = v for morsel in response.cookies.values(): new_response.set_cookie( morsel.key, morsel.value, expires=morsel.get("expires"), domain=morsel.get("domain"), max_age=morsel.get("max-age"), path=morsel.get("path") or "/", secure=bool(morsel.get("secure")), httponly=bool(morsel.get("httponly")), samesite=morsel.get("samesite") or None, ) return new_response return response return middleware_handler async def _handle(self, handler: Handler, request: Request) -> StreamResponse: """ Call the handler with the request and return the response. Args: handler (RequestHandler): The handler to call. request (Request): The request object. Returns: Response: The response object. """ try: sig = inspect.signature(handler) expected_args = sig.parameters.keys() try: if 1 == len(expected_args) and "request" in expected_args: response = await handler(request) else: response = await Services.get_instance().handle_async(handler, request=request) except TypeError as te: LOG.exception( "Failed to inject route handler dependencies for '%s'.", getattr(handler, "__name__", handler.__class__.__name__), extra={ "handler": getattr(handler, "__name__", handler.__class__.__name__), "route": str(request.rel_url), "method": request.method, "exception_type": type(te).__name__, }, ) if "missing 1 required positional argument" in str(te) and "request" in str(te): response = await handler(request) else: raise except web.HTTPException as e: return web.json_response(data={"error": str(e)}, status=e.status_code) except Exception as e: LOG.exception( "Failed to handle request '%s %s'.", request.method, request.rel_url, extra={"route": str(request.rel_url), "method": request.method, "exception_type": type(e).__name__}, ) response = web.json_response( data={"error": "Internal Server Error"}, status=web.HTTPInternalServerError.status_code, ) return response