chore: CI/CD + 前端测试 + 安全加固 + 限流完善

- 新增 .gitlab-ci.yml (lint/test/build 三阶段)
- 新增前端测试: taskStageMapper (109), api.ts (36), AuthContext (16)
- 修复旧测试: Sidebar 导航文案、MobileLayout padding 值
- python-jose → PyJWT 消除 ecdsa CVE 漏洞
- 限流中间件增加 5 个敏感端点精细限流 + 标准限流头

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-10 11:25:29 +08:00
co-authored by Claude Opus 4.6
parent 3a444864ac
commit a2f6f82e15
9 changed files with 2100 additions and 22 deletions
+78 -17
View File
@@ -1,6 +1,6 @@
"""
简单的速率限制中间件
基于内存的滑动窗口计数器
速率限制中间件
基于内存的滑动窗口计数器,支持按路径自定义限制和标准响应头。
"""
import time
from collections import defaultdict
@@ -14,50 +14,111 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
速率限制中间件
- 默认: 60 次/分钟 per IP
- 登录/注册: 10 次/分钟 per IP
- 按路径配置不同限制 (path_limits)
- 返回标准 X-RateLimit-* 响应头
"""
def __init__(self, app, default_limit: int = 60, window_seconds: int = 60):
# Path-specific rate limits (requests per window).
# Paths not listed here fall back to ``default_limit``.
DEFAULT_PATH_LIMITS: dict[str, int] = {
# Auth endpoints — prevent brute-force / abuse
"/api/v1/auth/login": 10,
"/api/v1/auth/register": 10,
"/api/v1/auth/send-code": 5,
"/api/v1/auth/reset-password": 5,
# Upload — bandwidth / storage cost
"/api/v1/upload/policy": 30,
# AI review — service cost + compute
"/api/v1/scripts/review": 10,
"/api/v1/videos/review": 5,
}
def __init__(
self,
app,
default_limit: int = 60,
window_seconds: int = 60,
path_limits: dict[str, int] | None = None,
):
super().__init__(app)
self.default_limit = default_limit
self.window_seconds = window_seconds
self.requests: dict[str, list[float]] = defaultdict(list)
# Stricter limits for auth endpoints
self.strict_paths = {"/api/v1/auth/login", "/api/v1/auth/register"}
self.strict_limit = 10
# Merge caller-supplied overrides on top of the built-in defaults.
self.path_limits: dict[str, int] = {**self.DEFAULT_PATH_LIMITS}
if path_limits:
self.path_limits.update(path_limits)
def _get_limit(self, path: str) -> int:
"""Return the rate limit for *path*, falling back to *default_limit*."""
return self.path_limits.get(path, self.default_limit)
def _make_key(self, client_ip: str, path: str) -> str:
"""Build the bucket key.
Paths with a custom limit are bucketed per-IP per-path so that
hitting one endpoint does not consume the quota of another.
Default paths share a single per-IP bucket.
"""
if path in self.path_limits:
return f"{client_ip}:{path}"
return client_ip
async def dispatch(self, request: Request, call_next):
client_ip = request.client.host if request.client else "unknown"
path = request.url.path
now = time.time()
# Determine rate limit
if path in self.strict_paths:
key = f"{client_ip}:{path}"
limit = self.strict_limit
else:
key = client_ip
limit = self.default_limit
limit = self._get_limit(path)
key = self._make_key(client_ip, path)
# Clean old entries
# Clean old entries outside the sliding window
window_start = now - self.window_seconds
self.requests[key] = [t for t in self.requests[key] if t > window_start]
current_count = len(self.requests[key])
remaining = max(0, limit - current_count)
# Seconds until the oldest request in the window expires
if self.requests[key]:
reset_seconds = int(self.requests[key][0] - window_start)
else:
reset_seconds = self.window_seconds
# Build common rate-limit headers
rate_headers = {
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": str(max(0, remaining - 1) if remaining > 0 else 0),
"X-RateLimit-Reset": str(reset_seconds),
}
# Check limit
if len(self.requests[key]) >= limit:
if current_count >= limit:
return JSONResponse(
status_code=429,
content={"detail": "请求过于频繁,请稍后再试"},
headers={
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_seconds),
"Retry-After": str(reset_seconds),
},
)
# Record request
self.requests[key].append(now)
# Periodic cleanup (every 1000 requests to this key)
# Periodic cleanup (keep memory bounded)
if len(self.requests) > 10000:
self._cleanup(now)
response = await call_next(request)
# Attach rate-limit headers to successful responses
response.headers["X-RateLimit-Limit"] = rate_headers["X-RateLimit-Limit"]
response.headers["X-RateLimit-Remaining"] = rate_headers["X-RateLimit-Remaining"]
response.headers["X-RateLimit-Reset"] = rate_headers["X-RateLimit-Reset"]
return response
def _cleanup(self, now: float):
+2 -1
View File
@@ -4,7 +4,8 @@
from datetime import datetime, timedelta
from typing import Optional
import secrets
from jose import jwt, JWTError
import jwt
from jwt.exceptions import PyJWTError as JWTError
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
+2 -1
View File
@@ -14,7 +14,7 @@ dependencies = [
"httpx>=0.26.0",
"pydantic[email]>=2.5.0",
"pydantic-settings>=2.0.0",
"python-jose>=3.3.0",
"PyJWT>=2.8.0",
"passlib>=1.7.4",
"alembic>=1.13.0",
"cryptography>=42.0.0",
@@ -57,6 +57,7 @@ markers = [
]
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::jwt.warnings.InsecureKeyLengthWarning",
]
[tool.coverage.run]