Files
MediaFusion/api/middleware.py
T

159 lines
5.6 KiB
Python

import hashlib
import logging
from typing import Callable, Optional
from fastapi.requests import Request
from fastapi.responses import Response
from redis.asyncio import Redis
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Match
from db.config import settings
from db.schemas import UserData
from utils import crypto, const
def get_client_ip(request: Request) -> str | None:
"""
Extract the client's real IP address from the request headers or fallback to the client host.
"""
x_forwarded_for = request.headers.get("X-Forwarded-For")
if x_forwarded_for:
# In some cases, this header can contain multiple IPs
# separated by commas.
# The first one is the original client's IP.
return x_forwarded_for.split(",")[0].strip()
# Fallback to X-Real-IP if X-Forwarded-For is not available
x_real_ip = request.headers.get("X-Real-IP")
if x_real_ip:
return x_real_ip
return request.client.host if request.client else "Unknown"
async def find_route_handler(app, request: Request) -> Optional[Callable]:
for route in app.routes:
match, scope = route.matches(request.scope)
if match == Match.FULL:
request.scope["path_params"] = scope["path_params"]
request.scope["endpoint"] = route.endpoint
return route.endpoint
return None
class SecureLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
await self.custom_log(request, response)
return response
@staticmethod
async def custom_log(request: Request, response: Response):
ip = get_client_ip(request)
url_path = str(request.url)
if request.path_params.get("secret_str"):
url_path = url_path.replace(
request.path_params.get("secret_str"), "***MASKED***"
)
logging.info(
f'{ip} - "{request.method} {url_path} HTTP/1.1" {response.status_code}'
)
class UserDataMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable):
endpoint = await find_route_handler(request.app, request)
secret_str = request.path_params.get("secret_str")
# Decrypt and parse the UserData from secret_str
user_data = crypto.decrypt_user_data(secret_str)
# validate api password if set
if settings.api_password and settings.is_public_instance is False:
is_auth_required = getattr(endpoint, "auth_required", False)
if is_auth_required and user_data.api_password != settings.api_password:
return Response(
content="Unauthorized",
status_code=401,
headers=const.NO_CACHE_HEADERS,
)
# Attach UserData to request state for access in endpoints
request.scope["user"] = user_data
try:
return await call_next(request)
except RuntimeError as exc:
if str(exc) == "No response returned." and await request.is_disconnected():
return Response(status_code=204)
raise
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, redis_client: Redis):
super().__init__(app)
self.redis = redis_client
async def dispatch(self, request: Request, call_next: Callable):
# Skip rate limiting for exempt paths
if not settings.enable_rate_limit:
return await call_next(request)
# Retrieve the endpoint function from the request
endpoint = request.scope.get("endpoint")
if not endpoint:
return await call_next(request)
is_exclude = getattr(endpoint, "exclude_rate_limit", False)
if is_exclude:
return await call_next(request)
limit = getattr(endpoint, "limit", 50) # Default rate limit
window = getattr(endpoint, "window", 60)
scope = getattr(endpoint, "scope", "default") # Default scope
ip = get_client_ip(request)
# Generate a unique key for rate limiting
identifier = self.generate_identifier(ip, request.user)
key = f"rate_limit:{identifier}:{scope}"
# Check and apply rate limit
allowed = await self.check_rate_limit_with_redis(key, limit, window)
if not allowed:
return Response(
content="Rate limit exceeded",
status_code=429,
headers=const.NO_CACHE_HEADERS,
)
return await call_next(request)
@staticmethod
def generate_identifier(ip: str, user_data: UserData) -> str:
raw_identifier = f"{ip}"
if user_data.streaming_provider:
provider_profile = (
user_data.streaming_provider.token
or user_data.streaming_provider.username
)
raw_identifier += f"-{provider_profile}"
return hashlib.md5(raw_identifier.encode()).hexdigest()
async def check_rate_limit_with_redis(
self, key: str, limit: int, window: int
) -> bool:
try:
results = await (
self.redis.pipeline(transaction=True)
.incr(key)
.expire(key, window)
.execute()
)
current_count = results[0]
if current_count > limit:
return False # Rate limit exceeded
return True
except Exception as e:
# Log error but allow the request to proceed to avoid blocking legitimate requests
logging.error(f"Rate limit error: {e}")
return True