feat: production deployment - full update with providers, admin, glossaries, pricing, tests

Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -1,6 +1,8 @@
"""
Rate Limiting Middleware for SaaS robustness
Protects against abuse and ensures fair usage
Protects against abuse and ensures fair usage.
When REDIS_URL is set, uses Redis for sliding-window counters (shared across instances).
Otherwise falls back to in-memory per-process limits.
"""
import time
import asyncio
@@ -39,6 +41,60 @@ class RateLimitConfig:
whitelist_ips: list = field(default_factory=lambda: ["127.0.0.1", "::1"])
KEY_PREFIX_IP = "rate_limit:ip"
KEY_PREFIX_SIZE_HOUR = "rate_limit:size_hour"
async def _redis_sliding_window_is_allowed(redis_client, client_id: str, window_key: str, window_seconds: int, max_requests: int) -> bool:
"""Redis sliding window. Key: rate_limit:ip:{client_id}:{window_key}. Returns True if under limit."""
now = time.time()
key = f"{KEY_PREFIX_IP}:{client_id}:{window_key}"
pipe = redis_client.pipeline()
pipe.zremrangebyscore(key, "-inf", now - window_seconds)
pipe.zcard(key)
results = await pipe.execute()
count_after_cleanup = results[1]
if count_after_cleanup >= max_requests:
return False
pipe2 = redis_client.pipeline()
pipe2.zadd(key, {str(now): now})
pipe2.expire(key, window_seconds + 60)
await pipe2.execute()
return True
async def _check_request_redis(redis_client, client_id: str, config: RateLimitConfig) -> tuple[bool, str]:
"""Check request limits using Redis. Returns (allowed, reason)."""
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "minute", 60, config.requests_per_minute):
return False, f"Rate limit exceeded. Max {config.requests_per_minute} requests per minute."
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "hour", 3600, config.requests_per_hour):
return False, f"Hourly limit exceeded. Max {config.requests_per_hour} requests per hour."
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "day", 86400, config.requests_per_day):
return False, f"Daily limit exceeded. Max {config.requests_per_day} requests per day."
return True, ""
async def _check_translation_redis(redis_client, client_id: str, config: RateLimitConfig, file_size_mb: float = 0) -> tuple[bool, str]:
"""Check translation limits using Redis. Returns (allowed, reason)."""
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "trans_minute", 60, config.translations_per_minute):
return False, f"Translation rate limit exceeded. Max {config.translations_per_minute} translations per minute."
if not await _redis_sliding_window_is_allowed(redis_client, client_id, "trans_hour", 3600, config.translations_per_hour):
return False, f"Hourly translation limit exceeded. Max {config.translations_per_hour} translations per hour."
# Hourly total size (MB) per client — same semantics as in-memory total_size_hour
now = time.time()
hour_ts = int(now // 3600)
size_key = f"{KEY_PREFIX_SIZE_HOUR}:{client_id}:{hour_ts}"
try:
cur_raw = await redis_client.get(size_key)
cur = float(cur_raw or 0)
if cur + file_size_mb > config.max_total_size_per_hour_mb:
return False, f"Hourly data limit exceeded. Max {config.max_total_size_per_hour_mb}MB per hour."
await redis_client.set(size_key, cur + file_size_mb, ex=7200)
except Exception as e:
logger.warning("Redis size-hour check failed: %s; allowing request", e)
return True, ""
class TokenBucket:
"""Token bucket algorithm for rate limiting"""
@@ -215,23 +271,40 @@ class RateLimitManager:
"""Check if request is allowed, return (allowed, reason, client_id)"""
client_id = self.get_client_id(request)
self._total_requests += 1
if self.is_whitelisted(client_id):
return True, "", client_id
try:
from core.redis import get_async_redis
redis_client = get_async_redis()
if redis_client:
allowed, reason = await _check_request_redis(redis_client, client_id, self.config)
return allowed, reason, client_id
except Exception as e:
logger.warning("Redis rate limit check failed, using in-memory: %s", e)
client = self.clients[client_id]
allowed, reason = await client.check_request()
return allowed, reason, client_id
async def check_translation(self, request: Request, file_size_mb: float = 0) -> tuple[bool, str]:
"""Check if translation is allowed"""
client_id = self.get_client_id(request)
self._total_translations += 1
if self.is_whitelisted(client_id):
return True, ""
try:
from core.redis import get_async_redis
redis_client = get_async_redis()
if redis_client:
allowed, reason = await _check_translation_redis(redis_client, client_id, self.config, file_size_mb)
return allowed, reason
except Exception as e:
logger.warning("Redis translation limit check failed, using in-memory: %s", e)
client = self.clients[client_id]
return await client.check_translation(file_size_mb)
@@ -239,7 +312,16 @@ class RateLimitManager:
"""Check if translation is allowed for a specific client ID"""
if self.is_whitelisted(client_id):
return True
try:
from core.redis import get_async_redis
redis_client = get_async_redis()
if redis_client:
allowed, _ = await _check_translation_redis(redis_client, client_id, self.config, file_size_mb)
return allowed
except Exception as e:
logger.warning("Redis translation limit check failed, using in-memory: %s", e)
client = self.clients[client_id]
allowed, _ = await client.check_translation(file_size_mb)
return allowed
@@ -295,6 +377,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.manager = rate_limit_manager
async def dispatch(self, request: Request, call_next):
# CORS preflight must not be rate-limited (no ACAO header → browser blocks the real request)
if request.method == "OPTIONS":
return await call_next(request)
# Skip rate limiting for health checks and static files
if request.url.path in ["/health", "/", "/docs", "/openapi.json", "/redoc"]:
return await call_next(request)