import base64
import hmac
import inspect
import logging
from collections.abc import Awaitable
from datetime import UTC, datetime, timedelta
from pathlib import Path
import anyio
from aiohttp import web
from aiohttp.web import Request, RequestHandler, Response
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: logging.Logger = logging.getLogger("http_api")
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) -> Awaitable:
"""
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: RequestHandler) -> Response:
# 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=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: Response = 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=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) -> 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("/")
)
)
},
)
response: Response = 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("/")
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["expires"],
domain=morsel["domain"],
max_age=morsel["max-age"],
path=morsel["path"],
secure=bool(morsel["secure"]),
httponly=bool(morsel["httponly"]),
samesite=morsel["samesite"] or None,
)
return new_response
return response
return middleware_handler
async def _handle(self, handler: RequestHandler, request: Request) -> Response:
"""
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