1
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
|
||||
BLOCKED_UA_PATTERNS = [
|
||||
re.compile(r"(?i)(curl|wget|python-requests|scrapy|httpx|go-http-client|java/)"),
|
||||
]
|
||||
|
||||
|
||||
class AntiCrawlerMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
# Skip health/docs
|
||||
if request.url.path in ("/health", "/api-docs", "/openapi.json", "/api-redoc"):
|
||||
return await call_next(request)
|
||||
|
||||
# Skip callback endpoints
|
||||
if request.url.path.startswith("/api/callbacks/"):
|
||||
return await call_next(request)
|
||||
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
# Block empty User-Agent
|
||||
if not user_agent:
|
||||
return JSONResponse(status_code=403, content={"detail": "Forbidden"})
|
||||
|
||||
# Block known bot User-Agents (unless they have an API key)
|
||||
api_key = request.headers.get("x-api-key")
|
||||
if not api_key:
|
||||
for pattern in BLOCKED_UA_PATTERNS:
|
||||
if pattern.search(user_agent):
|
||||
return JSONResponse(
|
||||
status_code=403, content={"detail": "Forbidden"}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
|
||||
from app.services.log_config import LOG_R_Q_DIR, LOG_FILENAME_FORMAT, LOG_DATE_FORMAT, encrypt_data
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
os.makedirs(LOG_R_Q_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def get_today_log_file() -> str:
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
filename = LOG_FILENAME_FORMAT.format(date=today)
|
||||
return os.path.join(LOG_R_Q_DIR, filename)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
path = request.url.path
|
||||
|
||||
if path.startswith("/static/") or path.startswith("/videos/") or path.startswith("/images/"):
|
||||
return await call_next(request)
|
||||
|
||||
if path in ("/internal/health", "/internal/api-docs", "/openapi.json", "/internal/api-redoc","internal/decrypt-data"):
|
||||
return await call_next(request)
|
||||
#GET请求不记录日志
|
||||
if request.method == "GET":
|
||||
return await call_next(request)
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
start = time.perf_counter()
|
||||
|
||||
request_body = {}
|
||||
try:
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
body = await request.json()
|
||||
request_body = body
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
duration_ms = round((time.perf_counter() - start) * 1000, 1)
|
||||
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query_params": dict(request.query_params),
|
||||
"request_body": encrypt_data(request_body) if request_body else "",
|
||||
"status": response.status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"ip": request.client.host if request.client else "-",
|
||||
"user_agent": request.headers.get("user-agent", "-"),
|
||||
}
|
||||
|
||||
log_file = get_today_log_file()
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
||||
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
@@ -0,0 +1,78 @@
|
||||
import time
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.redis import get_redis
|
||||
|
||||
|
||||
RATE_LIMITS = {
|
||||
"POST:/api/auth/login": (10, 60),
|
||||
"POST:/api/auth/change-password": (5, 60),
|
||||
"POST:/api/generation-records/optimize": (20, 60),
|
||||
"POST:/api/generation-records/*/generate": (10, 60),
|
||||
"POST:/api/generation-records/*/retry": (10, 60),
|
||||
}
|
||||
|
||||
|
||||
def _match_rate_limit(path: str, method: str) -> tuple[int, int] | None:
|
||||
key = f"{method}:{path}"
|
||||
if key in RATE_LIMITS:
|
||||
return RATE_LIMITS[key]
|
||||
# Check wildcard patterns
|
||||
for pattern, limit in RATE_LIMITS.items():
|
||||
pattern_method, pattern_path = pattern.split(":", 1)
|
||||
if method != pattern_method:
|
||||
continue
|
||||
pattern_parts = pattern_path.split("/")
|
||||
key_parts = path.split("/")
|
||||
if len(pattern_parts) != len(key_parts):
|
||||
continue
|
||||
match = True
|
||||
for pp, kp in zip(pattern_parts, key_parts):
|
||||
if pp != "*" and pp != kp:
|
||||
match = False
|
||||
break
|
||||
if match:
|
||||
return limit
|
||||
return None
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return await call_next(request)
|
||||
|
||||
limit_info = _match_rate_limit(request.url.path, request.method)
|
||||
if not limit_info:
|
||||
return await call_next(request)
|
||||
|
||||
max_requests, window = limit_info
|
||||
# Use user_id from token or IP as identifier
|
||||
identifier = request.client.host if request.client else "unknown"
|
||||
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
return await call_next(request)
|
||||
|
||||
redis_key = f"ratelimit:{request.method}:{request.url.path}:{identifier}"
|
||||
|
||||
try:
|
||||
current = await redis.incr(redis_key)
|
||||
if current == 1:
|
||||
await redis.expire(redis_key, window)
|
||||
if current > max_requests:
|
||||
ttl = await redis.ttl(redis_key)
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "请求过于频繁,请稍后重试"},
|
||||
headers={"Retry-After": str(max(ttl, 1))},
|
||||
)
|
||||
except Exception:
|
||||
pass # Redis unavailable, skip rate limiting
|
||||
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,102 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
|
||||
def _get_aesgcm() -> AESGCM:
|
||||
"""Get AESGCM instance from configured key."""
|
||||
key_bytes = base64.b64decode(settings.ENCRYPTION_KEY)
|
||||
if len(key_bytes) != 32:
|
||||
# Pad or truncate to 32 bytes
|
||||
key_bytes = (key_bytes + b"\x00" * 32)[:32]
|
||||
return AESGCM(key_bytes)
|
||||
|
||||
|
||||
def encrypt_data(plaintext: bytes) -> bytes:
|
||||
"""Encrypt data using AES-256-GCM. Returns nonce + ciphertext."""
|
||||
import os
|
||||
aesgcm = _get_aesgcm()
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
||||
return base64.b64encode(nonce + ciphertext)
|
||||
|
||||
|
||||
def decrypt_data(data: bytes) -> bytes:
|
||||
"""Decrypt AES-256-GCM encrypted data."""
|
||||
raw = base64.b64decode(data)
|
||||
nonce = raw[:12]
|
||||
ciphertext = raw[12:]
|
||||
aesgcm = _get_aesgcm()
|
||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
||||
|
||||
|
||||
class RequestEncryptMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
encrypted = request.headers.get("X-Encrypted", "").lower() == "true"
|
||||
if not encrypted:
|
||||
return await call_next(request)
|
||||
|
||||
# GET requests have no body to decrypt — just pass through
|
||||
if request.method == "GET":
|
||||
response = await call_next(request)
|
||||
response_body = b""
|
||||
async for chunk in response.body_iterator:
|
||||
if isinstance(chunk, str):
|
||||
response_body += chunk.encode()
|
||||
else:
|
||||
response_body += chunk
|
||||
encrypted_response = encrypt_data(response_body)
|
||||
return Response(
|
||||
content=json.dumps({"data": encrypted_response.decode()}),
|
||||
status_code=response.status_code,
|
||||
headers={"X-Encrypted": "true", "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.body()
|
||||
# Frontend sends {"data": "<encrypted_base64>"}
|
||||
body_json = json.loads(body)
|
||||
encrypted_b64 = body_json.get("data", "")
|
||||
decrypted = decrypt_data(encrypted_b64.encode())
|
||||
|
||||
# Replace request body with decrypted content
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": decrypted, "more_body": False}
|
||||
|
||||
request._receive = receive
|
||||
request._body = decrypted
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Encrypt response
|
||||
response_body = b""
|
||||
async for chunk in response.body_iterator:
|
||||
if isinstance(chunk, str):
|
||||
response_body += chunk.encode()
|
||||
else:
|
||||
response_body += chunk
|
||||
|
||||
encrypted_response = encrypt_data(response_body)
|
||||
# Wrap in {"data": "..."} to match frontend's expected format
|
||||
return Response(
|
||||
content=json.dumps({"data": encrypted_response.decode()}),
|
||||
status_code=response.status_code,
|
||||
headers={"X-Encrypted": "true", "Content-Type": "application/json"},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Request decryption failed")
|
||||
return Response(
|
||||
content=json.dumps({"detail": "解密失败"}, ensure_ascii=False),
|
||||
status_code=400,
|
||||
media_type="application/json",
|
||||
)
|
||||
Reference in New Issue
Block a user