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

@@ -3,6 +3,7 @@ API Key Authentication Middleware
Provides reusable dependencies for API key authentication across all endpoints.
Story 3.4: Authentification API via X-API-Key
Story 6.4: Bind user_id to structlog context when user is resolved (logs include user_id).
"""
from typing import Optional, Any, Union
@@ -69,8 +70,11 @@ async def get_user_from_api_key(
try:
from services.auth_service import get_user_by_api_key
from core.logging import bind_request_context
user = get_user_by_api_key(x_api_key)
if user is not None:
bind_request_context(user_id=str(getattr(user, "id", user)))
return user
except ValueError as e:
@@ -125,21 +129,26 @@ async def get_authenticated_user_optional(
try:
user = await get_user_from_api_key(x_api_key)
if user:
from core.logging import bind_request_context
bind_request_context(user_id=str(getattr(user, "id", user)))
return user
except APIKeyError:
# Invalid API key, fall through to JWT
pass
# Fall back to JWT
if credentials:
try:
from routes.auth_routes import get_current_user
user = await get_current_user(credentials)
if user:
from core.logging import bind_request_context
bind_request_context(user_id=str(getattr(user, "id", user)))
return user
except Exception:
pass
return None
@@ -167,20 +176,25 @@ async def get_authenticated_user(
# get_user_from_api_key will raise APIKeyError for invalid keys
user = await get_user_from_api_key(x_api_key)
if user:
from core.logging import bind_request_context
bind_request_context(user_id=str(getattr(user, "id", user)))
return user
# Should not reach here - get_user_from_api_key returns None only if no key provided
raise APIKeyError("INVALID_API_KEY", "Clé API invalide ou non reconnue.")
# Fall back to JWT
if credentials:
try:
from routes.auth_routes import get_current_user
user = await get_current_user(credentials)
if user:
from core.logging import bind_request_context
bind_request_context(user_id=str(getattr(user, "id", user)))
return user
except Exception:
pass
return None
@@ -198,10 +212,12 @@ async def require_authenticated_user(
User object (guaranteed to be authenticated)
"""
user = await get_authenticated_user(credentials, x_api_key)
if not user:
raise APIKeyError("MISSING_API_KEY", "Authentification requise. Utilisez X-API-Key ou Authorization: Bearer.")
from core.logging import bind_request_context
bind_request_context(user_id=str(getattr(user, "id", user)))
return user

View File

@@ -14,14 +14,10 @@ import logging
import json
from services.storage_tracker import _get_async_redis, KEY_PREFIX
try:
import structlog
from core.logging import get_logger
logger = structlog.get_logger(__name__)
_HAS_STRUCTLOG = True
except ImportError:
logger = logging.getLogger(__name__)
_HAS_STRUCTLOG = False
logger = get_logger(__name__)
_HAS_STRUCTLOG = True
class FileCleanupManager:

View File

@@ -13,12 +13,9 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
# Import APIKeyError for handling
from middleware.api_key_auth import APIKeyError
try:
import structlog
from core.logging import get_logger
logger = structlog.get_logger(__name__)
except ImportError:
logger = logging.getLogger(__name__)
logger = get_logger(__name__)
def format_error_response(

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)

View File

@@ -1,40 +1,45 @@
"""
Security Headers Middleware for SaaS robustness
Adds security headers to all responses
Security & request logging middlewares.
RequestLoggingMiddleware is responsible for:
- Assigning a request_id to each request
- Binding request_id and user_id into structlog context so all logs
include these fields in JSON output (Story 6.4).
"""
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import logging
logger = logging.getLogger(__name__)
from core.logging import get_logger, bind_request_context, clear_request_context
logger = get_logger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all responses"""
def __init__(self, app, config: dict = None):
super().__init__(app)
self.config = config or {}
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
# Prevent clickjacking
response.headers["X-Frame-Options"] = "DENY"
# Prevent MIME type sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Enable XSS filter
response.headers["X-XSS-Protection"] = "1; mode=block"
# Referrer policy
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Permissions policy
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
# Content Security Policy (adjust for your frontend)
if not request.url.path.startswith("/docs") and not request.url.path.startswith("/redoc"):
response.headers["Content-Security-Policy"] = (
@@ -46,73 +51,90 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"connect-src 'self' http://localhost:* https://localhost:* ws://localhost:*; "
"worker-src 'self' blob:; "
)
# HSTS (only in production with HTTPS)
if self.config.get("enable_hsts", False):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Log all requests for monitoring and debugging"""
"""Log all requests for monitoring and debugging with structured context."""
def __init__(self, app, log_body: bool = False):
super().__init__(app)
self.log_body = log_body
async def dispatch(self, request: Request, call_next) -> Response:
import time
import uuid
# Generate request ID
request_id = str(uuid.uuid4())[:8]
request.state.request_id = request_id
# Attempt to get user id if auth has already populated it
user_id = getattr(getattr(request.state, "user", None), "id", None)
# Bind context so all downstream logs include request_id/user_id
bind_request_context(request_id=request_id, user_id=str(user_id) if user_id else None)
# Get client info
client_ip = self._get_client_ip(request)
# Log request start
start_time = time.time()
logger.info(
f"[{request_id}] {request.method} {request.url.path} "
f"from {client_ip} - Started"
"request_started",
method=request.method,
path=request.url.path,
client_ip=client_ip,
)
try:
response = await call_next(request)
# Log request completion
duration = time.time() - start_time
logger.info(
f"[{request_id}] {request.method} {request.url.path} "
f"- {response.status_code} in {duration:.3f}s"
"request_completed",
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=round(duration * 1000, 3),
)
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id
return response
except Exception as e:
duration = time.time() - start_time
logger.error(
f"[{request_id}] {request.method} {request.url.path} "
f"- ERROR in {duration:.3f}s: {str(e)}"
logger.exception(
"request_error",
method=request.method,
path=request.url.path,
duration_ms=round(duration * 1000, 3),
error=str(e),
)
raise
finally:
# Clear contextvars so they don't leak across requests
clear_request_context()
def _get_client_ip(self, request: Request) -> str:
"""Get real client IP from headers or connection"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
if request.client:
return request.client.host
return "unknown"

View File

@@ -53,30 +53,15 @@ class QuotaResult:
# ---------------------------------------------------------------------------
# Redis backend
# Redis backend (shared client from core.redis)
# ---------------------------------------------------------------------------
_async_redis = None
def _get_async_redis():
"""Return async Redis client or None. Uses REDIS_URL from env. Single shared client."""
global _async_redis
if _async_redis is not None:
return _async_redis if _async_redis is not False else None
url = os.getenv("REDIS_URL", "").strip()
if not url:
_async_redis = False
return None
try:
import redis.asyncio as redis
_async_redis = redis.Redis.from_url(url, decode_responses=True)
logger.info("Tier quota: using Redis for daily quota")
return _async_redis
except Exception as e:
logger.warning("Tier quota: Redis unavailable (%s), using in-memory fallback", e)
_async_redis = False
return None
"""Return async Redis client or None. Uses shared client from core.redis."""
from core.redis import get_async_redis
return get_async_redis()
# ---------------------------------------------------------------------------