import base64 import hmac import inspect import logging from collections.abc import Awaitable from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any import anyio from aiohttp import web from aiohttp.web import Request, RequestHandler, Response from .cache import Cache from .config import Config from .DownloadQueue import DownloadQueue from .encoder import Encoder from .Events import EventBus from .ffprobe import ffprobe from .router import RouteType, get_routes from .Utils import decrypt_data, encrypt_data, get_file, get_mime_type, load_modules LOG: logging.Logger = logging.getLogger("http_api") class HttpAPI: def __init__(self, root_path: Path, queue: DownloadQueue): self.queue: DownloadQueue = queue or DownloadQueue.get_instance() self.encoder: Encoder = Encoder() self.config: Config = Config.get_instance() self._notify: EventBus = EventBus.get_instance() self.rootPath: Path = root_path self.cache = Cache() self.app: web.Application | None = None 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, this=self, ) ) 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(e) app.on_shutdown.append(self.on_shutdown) def add_routes(self, app: web.Application): """ 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 preload_static load_modules(self.rootPath, self.rootPath / "routes" / "api") preload_static(self.rootPath, self.config) async def options_handler(_: Request) -> Response: return web.Response(status=204) for route in get_routes(RouteType.HTTP).values(): routePath: str = f"/{route.path.lstrip('/')}" if self.config.base_path == route.path: pass elif "" == base_path or not routePath.rstrip("/").startswith(base_path.rstrip("/")): route.path = f"{base_path}/{route.path.lstrip('/')}" LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.") app.router.add_route(route.method, route.path, handler=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) app.router.add_static(f"{base_path}/api/download/", self.config.download_path, name="download_static") @staticmethod def basic_auth(username: str, password: str) -> Awaitable: """ Middleware to handle basic authentication. Args: username (str): The username. password (str): The password. Returns: Awaitable: The middleware handler. """ @web.middleware async def middleware_handler(request: Request, handler: RequestHandler) -> Response: # if OPTIONS request, skip auth if request.method == "OPTIONS": 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=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(e) 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 ) response = await handler(request) if request.path != "/": return response try: response.set_cookie( "auth", encrypt_data( f"{user_name}:{user_password}", key=Config.get_instance().secret_key, ), max_age=60 * 60 * 24 * 7, expires=datetime.now(UTC) + timedelta(days=7), httponly=True, samesite="Strict", ) except Exception as e: LOG.exception(e) return response return middleware_handler @staticmethod def middle_wares(app: web.Application, base_path: str, download_path: str, this: "HttpAPI") -> Awaitable: @web.middleware async def middleware_handler(request: Request, handler: RequestHandler) -> Response: 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("/") ) ) }, ) kwargs: dict[str, Any] = { "request": request, "queue": this.queue, "encoder": this.encoder, "config": this.config, "notify": this._notify, "cache": this.cache, "app": app, "http_api": this, } 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: filtered = {k: v for k, v in kwargs.items() if k in expected_args} response = await handler(**filtered) except TypeError as te: 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(e) response = web.json_response( data={"error": "Internal Server Error"}, status=web.HTTPInternalServerError.status_code, ) contentType: str | None = response.headers.get("content-type", None) if contentType and "/" != base_path and contentType.startswith("text/html"): rewrite_path: str = base_path.rstrip("/") async with await anyio.open_file(response._path, "rb") as f: content = await f.read() content: str = ( content.decode("utf-8") .replace('', f'') .replace('baseURL:""', f'baseURL:"{rewrite_path}/"') ) return web.Response( body=content.encode("utf-8"), status=response.status, headers=response.headers, ) if request.path.startswith(static_path) and isinstance(response, web.FileResponse): try: ff_info = await ffprobe(response._path) mime_type = get_mime_type(ff_info.get("metadata", {}), response._path) response.content_type = mime_type except Exception: pass return response return middleware_handler