mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
chore(repo): move v1/ → archive/v1/ + add archive/README.md (#430)
The Rust port at v2/ has been the primary codebase since the rename in #427. The Python implementation at v1/ is no longer the active target; the only load-bearing path is the deterministic proof bundle at v1/data/proof/ (per ADR-011 / ADR-028 witness verification). Move the whole Python tree into archive/v1/ and document the policy in archive/README.md: no new features, bug fixes only when they affect a still-load-bearing path (currently just the proof), CI continues to verify the proof on every push and PR. Path references updated in 26 files via path-pattern sed (only matches v1/<known-child> patterns, never bare v1 or API URLs like /api/v1/). Two double-prefix typos (archive/archive/v1/) caught and hand-fixed in verify-pipeline.yml and ADR-011. Validated: - Python proof verify.py imports cleanly at archive/v1/data/proof/ (numpy/scipy still required; CI installs requirements-lock.txt from archive/v1/ now) - cargo test --workspace --no-default-features → 1,539 passed, 0 failed, 8 ignored (unaffected by Python tree relocation) - ESP32-S3 on COM7 untouched (no firmware paths changed) After-merge: contributors should re-run any local `python v1/...` commands as `python archive/v1/...` (CLAUDE.md and CHANGELOG already updated).
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
FastAPI middleware package
|
||||
"""
|
||||
|
||||
from .auth import AuthMiddleware
|
||||
from .rate_limit import RateLimitMiddleware
|
||||
|
||||
__all__ = ["AuthMiddleware", "RateLimitMiddleware"]
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
JWT Authentication middleware for WiFi-DensePose API
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from src.config.settings import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""JWT Authentication middleware."""
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.settings = get_settings()
|
||||
|
||||
# Paths that don't require authentication
|
||||
self.public_paths = {
|
||||
"/",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/health",
|
||||
"/ready",
|
||||
"/live",
|
||||
"/version",
|
||||
"/metrics"
|
||||
}
|
||||
|
||||
# Paths that require authentication
|
||||
self.protected_paths = {
|
||||
"/api/v1/pose/analyze",
|
||||
"/api/v1/pose/calibrate",
|
||||
"/api/v1/pose/historical",
|
||||
"/api/v1/stream/start",
|
||||
"/api/v1/stream/stop",
|
||||
"/api/v1/stream/clients",
|
||||
"/api/v1/stream/broadcast"
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request through authentication middleware."""
|
||||
|
||||
# Skip authentication for public paths
|
||||
if self._is_public_path(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract and validate token
|
||||
token = self._extract_token(request)
|
||||
|
||||
if token:
|
||||
try:
|
||||
# Verify token and add user info to request state
|
||||
user_data = await self._verify_token(token)
|
||||
request.state.user = user_data
|
||||
request.state.authenticated = True
|
||||
|
||||
logger.debug(f"Authenticated user: {user_data.get('id')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
|
||||
# For protected paths, return 401
|
||||
if self._is_protected_path(request.url.path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Invalid or expired token",
|
||||
"type": "authentication_error"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# For other paths, continue without authentication
|
||||
request.state.user = None
|
||||
request.state.authenticated = False
|
||||
else:
|
||||
# No token provided
|
||||
if self._is_protected_path(request.url.path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Authentication required",
|
||||
"type": "authentication_error"
|
||||
}
|
||||
},
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
request.state.user = None
|
||||
request.state.authenticated = False
|
||||
|
||||
# Continue with request processing
|
||||
response = await call_next(request)
|
||||
|
||||
# Add authentication headers to response
|
||||
if hasattr(request.state, 'user') and request.state.user:
|
||||
response.headers["X-User-ID"] = request.state.user.get("id", "")
|
||||
response.headers["X-Authenticated"] = "true"
|
||||
else:
|
||||
response.headers["X-Authenticated"] = "false"
|
||||
|
||||
return response
|
||||
|
||||
def _is_public_path(self, path: str) -> bool:
|
||||
"""Check if path is public (doesn't require authentication)."""
|
||||
# Exact match
|
||||
if path in self.public_paths:
|
||||
return True
|
||||
|
||||
# Pattern matching for public paths
|
||||
public_patterns = [
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/v1/pose/current", # Allow anonymous access to current pose data
|
||||
"/api/v1/pose/zones/", # Allow anonymous access to zone data
|
||||
"/api/v1/pose/activities", # Allow anonymous access to activities
|
||||
"/api/v1/pose/stats", # Allow anonymous access to stats
|
||||
"/api/v1/stream/status" # Allow anonymous access to stream status
|
||||
]
|
||||
|
||||
for pattern in public_patterns:
|
||||
if path.startswith(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_protected_path(self, path: str) -> bool:
|
||||
"""Check if path requires authentication."""
|
||||
# Exact match
|
||||
if path in self.protected_paths:
|
||||
return True
|
||||
|
||||
# Pattern matching for protected paths
|
||||
protected_patterns = [
|
||||
"/api/v1/pose/analyze",
|
||||
"/api/v1/pose/calibrate",
|
||||
"/api/v1/pose/historical",
|
||||
"/api/v1/stream/start",
|
||||
"/api/v1/stream/stop",
|
||||
"/api/v1/stream/clients",
|
||||
"/api/v1/stream/broadcast"
|
||||
]
|
||||
|
||||
for pattern in protected_patterns:
|
||||
if path.startswith(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _extract_token(self, request: Request) -> Optional[str]:
|
||||
"""Extract JWT token from request."""
|
||||
# Check Authorization header
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
return auth_header.split(" ")[1]
|
||||
|
||||
# Check query parameter (for WebSocket connections)
|
||||
token = request.query_params.get("token")
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Check cookie
|
||||
token = request.cookies.get("access_token")
|
||||
if token:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
async def _verify_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Verify JWT token and return user data."""
|
||||
try:
|
||||
# Decode JWT token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.settings.secret_key,
|
||||
algorithms=[self.settings.jwt_algorithm]
|
||||
)
|
||||
|
||||
# Check token blacklist (logout invalidation)
|
||||
if token_blacklist.is_blacklisted(token):
|
||||
raise ValueError("Token has been revoked")
|
||||
|
||||
# Extract user information
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise ValueError("Token missing user ID")
|
||||
|
||||
# Check token expiration
|
||||
exp = payload.get("exp")
|
||||
if exp and datetime.utcnow() > datetime.fromtimestamp(exp):
|
||||
raise ValueError("Token expired")
|
||||
|
||||
# Build user object
|
||||
user_data = {
|
||||
"id": user_id,
|
||||
"username": payload.get("username"),
|
||||
"email": payload.get("email"),
|
||||
"is_admin": payload.get("is_admin", False),
|
||||
"permissions": payload.get("permissions", []),
|
||||
"accessible_zones": payload.get("accessible_zones", []),
|
||||
"token_issued_at": payload.get("iat"),
|
||||
"token_expires_at": payload.get("exp"),
|
||||
"session_id": payload.get("session_id")
|
||||
}
|
||||
|
||||
return user_data
|
||||
|
||||
except JWTError as e:
|
||||
raise ValueError(f"JWT validation failed: {e}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token verification error: {e}")
|
||||
|
||||
# TODO: Wire up authentication event logging in dispatch() for
|
||||
# security monitoring (login failures, token expiry, etc.).
|
||||
|
||||
|
||||
class TokenBlacklist:
|
||||
"""Simple in-memory token blacklist for logout functionality."""
|
||||
|
||||
def __init__(self):
|
||||
self._blacklisted_tokens = set()
|
||||
self._cleanup_interval = 3600 # 1 hour
|
||||
self._last_cleanup = datetime.utcnow()
|
||||
|
||||
def add_token(self, token: str):
|
||||
"""Add token to blacklist."""
|
||||
self._blacklisted_tokens.add(token)
|
||||
self._cleanup_if_needed()
|
||||
|
||||
def is_blacklisted(self, token: str) -> bool:
|
||||
"""Check if token is blacklisted."""
|
||||
self._cleanup_if_needed()
|
||||
return token in self._blacklisted_tokens
|
||||
|
||||
def _cleanup_if_needed(self):
|
||||
"""Clean up expired tokens from blacklist."""
|
||||
now = datetime.utcnow()
|
||||
if (now - self._last_cleanup).total_seconds() > self._cleanup_interval:
|
||||
# In a real implementation, you would check token expiration
|
||||
# For now, we'll just clear old tokens periodically
|
||||
self._blacklisted_tokens.clear()
|
||||
self._last_cleanup = now
|
||||
|
||||
|
||||
# Global token blacklist instance
|
||||
token_blacklist = TokenBlacklist()
|
||||
|
||||
|
||||
class SecurityHeaders:
|
||||
"""Security headers for API responses."""
|
||||
|
||||
@staticmethod
|
||||
def add_security_headers(response: Response) -> Response:
|
||||
"""Add security headers to response."""
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self' ws: wss:;"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class APIKeyAuth:
|
||||
"""Alternative API key authentication for service-to-service communication."""
|
||||
|
||||
def __init__(self, api_keys: Dict[str, Dict[str, Any]] = None):
|
||||
self.api_keys = api_keys or {}
|
||||
|
||||
def verify_api_key(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify API key and return associated service info."""
|
||||
if api_key in self.api_keys:
|
||||
return self.api_keys[api_key]
|
||||
return None
|
||||
|
||||
def add_api_key(self, api_key: str, service_info: Dict[str, Any]):
|
||||
"""Add new API key."""
|
||||
self.api_keys[api_key] = service_info
|
||||
|
||||
def revoke_api_key(self, api_key: str):
|
||||
"""Revoke API key."""
|
||||
if api_key in self.api_keys:
|
||||
del self.api_keys[api_key]
|
||||
|
||||
|
||||
# Global API key auth instance
|
||||
api_key_auth = APIKeyAuth()
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Rate limiting middleware for WiFi-DensePose API
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from src.config.settings import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Rate limiting middleware with sliding window algorithm."""
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.settings = get_settings()
|
||||
|
||||
# Rate limit storage (in production, use Redis)
|
||||
self.request_counts = defaultdict(lambda: deque())
|
||||
self.blocked_clients = {}
|
||||
|
||||
# Rate limit configurations
|
||||
self.rate_limits = {
|
||||
"anonymous": {
|
||||
"requests": self.settings.rate_limit_requests,
|
||||
"window": self.settings.rate_limit_window,
|
||||
"burst": 10 # Allow burst of 10 requests
|
||||
},
|
||||
"authenticated": {
|
||||
"requests": self.settings.rate_limit_authenticated_requests,
|
||||
"window": self.settings.rate_limit_window,
|
||||
"burst": 50
|
||||
},
|
||||
"admin": {
|
||||
"requests": 10000, # Very high limit for admins
|
||||
"window": self.settings.rate_limit_window,
|
||||
"burst": 100
|
||||
}
|
||||
}
|
||||
|
||||
# Path-specific rate limits
|
||||
self.path_limits = {
|
||||
"/api/v1/pose/current": {"requests": 60, "window": 60}, # 1 per second
|
||||
"/api/v1/pose/analyze": {"requests": 10, "window": 60}, # 10 per minute
|
||||
"/api/v1/pose/calibrate": {"requests": 1, "window": 300}, # 1 per 5 minutes
|
||||
"/api/v1/stream/start": {"requests": 5, "window": 60}, # 5 per minute
|
||||
"/api/v1/stream/stop": {"requests": 5, "window": 60}, # 5 per minute
|
||||
}
|
||||
|
||||
# Exempt paths from rate limiting
|
||||
self.exempt_paths = {
|
||||
"/health",
|
||||
"/ready",
|
||||
"/live",
|
||||
"/version",
|
||||
"/metrics"
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request through rate limiting middleware."""
|
||||
|
||||
# Skip rate limiting for exempt paths
|
||||
if self._is_exempt_path(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
# Get client identifier
|
||||
client_id = self._get_client_id(request)
|
||||
|
||||
# Check if client is temporarily blocked
|
||||
if self._is_client_blocked(client_id):
|
||||
return self._create_rate_limit_response(
|
||||
"Client temporarily blocked due to excessive requests"
|
||||
)
|
||||
|
||||
# Get user type for rate limiting
|
||||
user_type = self._get_user_type(request)
|
||||
|
||||
# Check rate limits
|
||||
rate_limit_result = self._check_rate_limits(
|
||||
client_id,
|
||||
request.url.path,
|
||||
user_type
|
||||
)
|
||||
|
||||
if not rate_limit_result["allowed"]:
|
||||
# Log rate limit violation
|
||||
self._log_rate_limit_violation(request, client_id, rate_limit_result)
|
||||
|
||||
# Check if client should be temporarily blocked
|
||||
if rate_limit_result.get("violations", 0) > 5:
|
||||
self._block_client(client_id, duration=300) # 5 minutes
|
||||
|
||||
return self._create_rate_limit_response(
|
||||
rate_limit_result["message"],
|
||||
retry_after=rate_limit_result.get("retry_after", 60)
|
||||
)
|
||||
|
||||
# Record the request
|
||||
self._record_request(client_id, request.url.path)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add rate limit headers
|
||||
self._add_rate_limit_headers(response, client_id, user_type)
|
||||
|
||||
return response
|
||||
|
||||
def _is_exempt_path(self, path: str) -> bool:
|
||||
"""Check if path is exempt from rate limiting."""
|
||||
return path in self.exempt_paths
|
||||
|
||||
def _get_client_id(self, request: Request) -> str:
|
||||
"""Get unique client identifier for rate limiting."""
|
||||
# Try to get user ID from request state (set by auth middleware)
|
||||
if hasattr(request.state, 'user') and request.state.user:
|
||||
return f"user:{request.state.user['id']}"
|
||||
|
||||
# Fall back to IP address
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Include user agent for better identification
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
user_agent_hash = str(hash(user_agent))[:8]
|
||||
|
||||
return f"ip:{client_ip}:{user_agent_hash}"
|
||||
|
||||
def _get_user_type(self, request: Request) -> str:
|
||||
"""Determine user type for rate limiting."""
|
||||
if hasattr(request.state, 'user') and request.state.user:
|
||||
if request.state.user.get("is_admin", False):
|
||||
return "admin"
|
||||
return "authenticated"
|
||||
return "anonymous"
|
||||
|
||||
def _check_rate_limits(self, client_id: str, path: str, user_type: str) -> Dict:
|
||||
"""Check if request is within rate limits."""
|
||||
now = time.time()
|
||||
|
||||
# Get applicable rate limits
|
||||
general_limit = self.rate_limits[user_type]
|
||||
path_limit = self.path_limits.get(path)
|
||||
|
||||
# Check general rate limit
|
||||
general_result = self._check_limit(
|
||||
client_id,
|
||||
"general",
|
||||
general_limit["requests"],
|
||||
general_limit["window"],
|
||||
now
|
||||
)
|
||||
|
||||
if not general_result["allowed"]:
|
||||
return general_result
|
||||
|
||||
# Check path-specific rate limit if exists
|
||||
if path_limit:
|
||||
path_result = self._check_limit(
|
||||
client_id,
|
||||
f"path:{path}",
|
||||
path_limit["requests"],
|
||||
path_limit["window"],
|
||||
now
|
||||
)
|
||||
|
||||
if not path_result["allowed"]:
|
||||
return path_result
|
||||
|
||||
return {"allowed": True}
|
||||
|
||||
def _check_limit(self, client_id: str, limit_type: str, max_requests: int, window: int, now: float) -> Dict:
|
||||
"""Check specific rate limit using sliding window."""
|
||||
key = f"{client_id}:{limit_type}"
|
||||
requests = self.request_counts[key]
|
||||
|
||||
# Remove old requests outside the window
|
||||
cutoff = now - window
|
||||
while requests and requests[0] <= cutoff:
|
||||
requests.popleft()
|
||||
|
||||
# Check if limit exceeded
|
||||
if len(requests) >= max_requests:
|
||||
# Calculate retry after time
|
||||
oldest_request = requests[0] if requests else now
|
||||
retry_after = int(oldest_request + window - now) + 1
|
||||
|
||||
return {
|
||||
"allowed": False,
|
||||
"message": f"Rate limit exceeded: {max_requests} requests per {window} seconds",
|
||||
"retry_after": retry_after,
|
||||
"current_count": len(requests),
|
||||
"limit": max_requests,
|
||||
"window": window
|
||||
}
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"current_count": len(requests),
|
||||
"limit": max_requests,
|
||||
"window": window
|
||||
}
|
||||
|
||||
def _record_request(self, client_id: str, path: str):
|
||||
"""Record a request for rate limiting."""
|
||||
now = time.time()
|
||||
|
||||
# Record general request
|
||||
general_key = f"{client_id}:general"
|
||||
self.request_counts[general_key].append(now)
|
||||
|
||||
# Record path-specific request if path has specific limits
|
||||
if path in self.path_limits:
|
||||
path_key = f"{client_id}:path:{path}"
|
||||
self.request_counts[path_key].append(now)
|
||||
|
||||
def _is_client_blocked(self, client_id: str) -> bool:
|
||||
"""Check if client is temporarily blocked."""
|
||||
if client_id in self.blocked_clients:
|
||||
block_until = self.blocked_clients[client_id]
|
||||
if time.time() < block_until:
|
||||
return True
|
||||
else:
|
||||
# Block expired, remove it
|
||||
del self.blocked_clients[client_id]
|
||||
return False
|
||||
|
||||
def _block_client(self, client_id: str, duration: int):
|
||||
"""Temporarily block a client."""
|
||||
self.blocked_clients[client_id] = time.time() + duration
|
||||
logger.warning(f"Client {client_id} blocked for {duration} seconds due to rate limit violations")
|
||||
|
||||
def _create_rate_limit_response(self, message: str, retry_after: int = 60) -> JSONResponse:
|
||||
"""Create rate limit exceeded response."""
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": message,
|
||||
"type": "rate_limit_exceeded"
|
||||
}
|
||||
},
|
||||
headers={
|
||||
"Retry-After": str(retry_after),
|
||||
"X-RateLimit-Limit": "Exceeded",
|
||||
"X-RateLimit-Remaining": "0"
|
||||
}
|
||||
)
|
||||
|
||||
def _add_rate_limit_headers(self, response: Response, client_id: str, user_type: str):
|
||||
"""Add rate limit headers to response."""
|
||||
try:
|
||||
general_limit = self.rate_limits[user_type]
|
||||
general_key = f"{client_id}:general"
|
||||
current_requests = len(self.request_counts[general_key])
|
||||
|
||||
remaining = max(0, general_limit["requests"] - current_requests)
|
||||
|
||||
response.headers["X-RateLimit-Limit"] = str(general_limit["requests"])
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
response.headers["X-RateLimit-Window"] = str(general_limit["window"])
|
||||
|
||||
# Add reset time
|
||||
if self.request_counts[general_key]:
|
||||
oldest_request = self.request_counts[general_key][0]
|
||||
reset_time = int(oldest_request + general_limit["window"])
|
||||
response.headers["X-RateLimit-Reset"] = str(reset_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding rate limit headers: {e}")
|
||||
|
||||
def _log_rate_limit_violation(self, request: Request, client_id: str, result: Dict):
|
||||
"""Log rate limit violations for monitoring."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent", "unknown")
|
||||
|
||||
log_data = {
|
||||
"event_type": "rate_limit_violation",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"client_id": client_id,
|
||||
"client_ip": client_ip,
|
||||
"user_agent": user_agent,
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"current_count": result.get("current_count"),
|
||||
"limit": result.get("limit"),
|
||||
"window": result.get("window")
|
||||
}
|
||||
|
||||
logger.warning(f"Rate limit violation: {log_data}")
|
||||
|
||||
def cleanup_old_data(self):
|
||||
"""Clean up old rate limiting data (call periodically)."""
|
||||
now = time.time()
|
||||
cutoff = now - 3600 # Keep data for 1 hour
|
||||
|
||||
# Clean up request counts
|
||||
for key in list(self.request_counts.keys()):
|
||||
requests = self.request_counts[key]
|
||||
while requests and requests[0] <= cutoff:
|
||||
requests.popleft()
|
||||
|
||||
# Remove empty deques
|
||||
if not requests:
|
||||
del self.request_counts[key]
|
||||
|
||||
# Clean up expired blocks
|
||||
expired_blocks = [
|
||||
client_id for client_id, block_until in self.blocked_clients.items()
|
||||
if now >= block_until
|
||||
]
|
||||
|
||||
for client_id in expired_blocks:
|
||||
del self.blocked_clients[client_id]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user