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

@@ -2,8 +2,9 @@
Authentication service with JWT tokens and password hashing
This service provides user authentication with automatic backend selection:
- If DATABASE_URL is configured: Uses PostgreSQL database
- Otherwise: Falls back to JSON file storage (development mode)
- When SQLAlchemy models are available: uses the same SQLite/PostgreSQL DB as the app.
- Otherwise: falls back to JSON file storage (data/users.json).
- If the DB is enabled but a user exists only in the legacy JSON file, lookups fall back to JSON.
"""
import os
@@ -37,27 +38,25 @@ except ImportError:
PASSLIB_AVAILABLE = False
logger.warning("passlib not installed. Using SHA256 fallback for password hashing.")
# Check if database is configured
# User storage: same SQLAlchemy DB as the rest of the app (SQLite by default, or PostgreSQL).
# Previously only DATABASE_URL=postgresql* enabled the DB, so dev (SQLite) used data/users.json
# while registrations could end up in translate.db — logins then failed to find users.
DATABASE_URL = os.getenv("DATABASE_URL", "")
USE_DATABASE = bool(DATABASE_URL and DATABASE_URL.startswith("postgresql"))
USE_DATABASE = False
DATABASE_AVAILABLE = False
if USE_DATABASE:
try:
from database.repositories import UserRepository
from database.connection import get_sync_session, init_db as _init_db
from database import models as db_models
try:
from database.repositories import UserRepository
from database.connection import get_sync_session, init_db as _init_db
from database import models as db_models
DATABASE_AVAILABLE = True
logger.info("Database backend enabled for authentication")
except ImportError as e:
DATABASE_AVAILABLE = False
USE_DATABASE = False
logger.warning(f"Database modules not available: {e}. Using JSON storage.")
else:
DATABASE_AVAILABLE = False
DATABASE_AVAILABLE = True
USE_DATABASE = True
logger.info(
"Using JSON file storage for authentication (DATABASE_URL not configured)"
"Database backend enabled for authentication (SQLite or PostgreSQL, same as DATABASE_URL / SQLITE_PATH)"
)
except ImportError as e:
logger.warning(f"Database modules not available: {e}. Using JSON storage.")
from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
@@ -84,29 +83,16 @@ USERS_FILE.parent.mkdir(exist_ok=True)
# Token blocklist: jti → expiry timestamp (Unix).
# Uses Redis when available (persistent across restarts), falls back to in-memory.
_revoked_jtis: dict[str, float] = {}
_redis_blocklist_client = None
def _get_blocklist_redis():
"""Return Redis client for token blocklist, or None if unavailable."""
global _redis_blocklist_client
if _redis_blocklist_client is not None:
return _redis_blocklist_client if _redis_blocklist_client is not False else None
redis_url = os.getenv("REDIS_URL", "")
if not redis_url:
_redis_blocklist_client = False
return None
try:
import redis as redis_lib
client = redis_lib.from_url(redis_url, decode_responses=True)
client.ping()
_redis_blocklist_client = client
"""Return Redis client for token blocklist from core.redis, or None if unavailable."""
from core.redis import get_sync_redis
client = get_sync_redis()
if client:
logger.info("Token blocklist using Redis (persistent across restarts)")
return client
except Exception as e:
logger.warning(f"Redis unavailable for token blocklist, using in-memory: {e}")
_redis_blocklist_client = False
return None
return client
def revoke_token_jti(jti: str, expires_at: float) -> None:
@@ -267,6 +253,22 @@ def save_users(users: Dict[str, Dict]):
json.dump(users, f, indent=2, default=str)
def _get_user_from_json_file_by_email(email: str) -> Optional[User]:
"""Legacy users.json when DB is primary but account was never migrated."""
users = load_users()
for user_data in users.values():
if user_data.get("email", "").lower() == email.lower():
return User(**user_data)
return None
def _get_user_from_json_file_by_id(user_id: str) -> Optional[User]:
users = load_users()
if user_id in users:
return User(**users[user_id])
return None
def _db_user_to_model(db_user) -> User:
"""Convert database user model to Pydantic User model"""
return User(
@@ -300,13 +302,21 @@ def get_user_by_email(email: str) -> Optional[User]:
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.repositories import UserRepository
from sqlalchemy.exc import OperationalError
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.get_by_email(email)
if db_user:
return _db_user_to_model(db_user)
return None
try:
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.get_by_email(email)
if db_user:
return _db_user_to_model(db_user)
return _get_user_from_json_file_by_email(email)
except OperationalError as e:
logger.warning(
"User lookup by email failed (DB): %s; trying users.json",
e,
)
return _get_user_from_json_file_by_email(email)
else:
users = load_users()
for user_data in users.values():
@@ -320,13 +330,21 @@ def get_user_by_id(user_id: str) -> Optional[User]:
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.repositories import UserRepository
from sqlalchemy.exc import OperationalError
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.get_by_id(user_id)
if db_user:
return _db_user_to_model(db_user)
return None
try:
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.get_by_id(user_id)
if db_user:
return _db_user_to_model(db_user)
return _get_user_from_json_file_by_id(user_id)
except OperationalError as e:
logger.warning(
"User lookup by id failed (DB): %s; trying users.json",
e,
)
return _get_user_from_json_file_by_id(user_id)
else:
users = load_users()
if user_id in users:
@@ -386,6 +404,32 @@ def authenticate_user(email: str, password: str) -> Optional[User]:
return user
def admin_set_user_password(user_id: str, plain_password: str) -> Optional[User]:
"""
Définit un nouveau mot de passe (haché) pour un utilisateur.
Couvre la base SQLAlchemy et le fichier legacy data/users.json.
"""
if len(plain_password) < 8:
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
hp = hash_password(plain_password)
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.repositories import UserRepository
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.update(user_id, hashed_password=hp)
if db_user:
return _db_user_to_model(db_user)
users = load_users()
if user_id in users:
users[user_id]["password_hash"] = hp
users[user_id]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_users(users)
return User(**users[user_id])
return None
def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
"""Update a user's data"""
if USE_DATABASE and DATABASE_AVAILABLE:
@@ -397,7 +441,13 @@ def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
db_user = repo.update(user_id, **updates)
if db_user:
return _db_user_to_model(db_user)
return None
users = load_users()
if user_id not in users:
return None
users[user_id].update(updates)
users[user_id]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_users(users)
return User(**users[user_id])
else:
users = load_users()
if user_id not in users:
@@ -562,6 +612,40 @@ def get_user_by_api_key(api_key: str) -> Optional[User]:
return None
def get_or_create_google_user(
email: str, name: str, avatar_url: Optional[str] = None
) -> User:
"""Find or create a user via Google OAuth (email as identifier)."""
if USE_DATABASE and DATABASE_AVAILABLE:
from services.auth_service_db import get_or_create_google_user as _db_impl
db_user = _db_impl(email=email, name=name, avatar_url=avatar_url)
return _db_user_to_model(db_user)
existing = get_user_by_email(email)
if existing:
updates: Dict[str, Any] = {}
if avatar_url and not existing.avatar_url:
updates["avatar_url"] = avatar_url
if not existing.email_verified:
updates["email_verified"] = True
if updates:
updated = update_user(existing.id, updates)
return updated if updated else existing
return existing
# Password must satisfy UserCreate validators (length, upper, lower, digit)
pw = f"Aa1{secrets.token_urlsafe(24)}"
user_create = UserCreate(email=email, name=name, password=pw)
user = create_user(user_create)
update_user(
user.id,
{"email_verified": True, "avatar_url": avatar_url},
)
refreshed = get_user_by_id(user.id)
return refreshed if refreshed else user
def init_database():
"""Initialize the database (call on application startup)"""
if USE_DATABASE and DATABASE_AVAILABLE: