This commit is contained in:
2026-05-25 17:08:18 +08:00
parent df501f6151
commit f1259b71b5
9178 changed files with 1626125 additions and 0 deletions
@@ -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)
+71
View File
@@ -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",
)