- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1146 lines
36 KiB
Python
1146 lines
36 KiB
Python
"""
|
|
Authentication and User API routes
|
|
Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
|
|
"""
|
|
|
|
import os
|
|
import secrets
|
|
import logging
|
|
from datetime import timedelta, datetime, timezone
|
|
from fastapi import APIRouter, HTTPException, Depends, Header, Request, Query
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from pydantic import BaseModel, EmailStr, ValidationError as PydanticValidationError
|
|
from typing import Optional, Dict, Any
|
|
|
|
from models.subscription import (
|
|
UserCreate,
|
|
UserLogin,
|
|
UserResponse,
|
|
PlanType,
|
|
PLANS,
|
|
CREDIT_PACKAGES,
|
|
)
|
|
from services.auth_service import (
|
|
create_user,
|
|
authenticate_user,
|
|
get_user_by_id,
|
|
create_access_token,
|
|
create_refresh_token,
|
|
verify_token,
|
|
revoke_token_jti,
|
|
check_usage_limits,
|
|
update_user,
|
|
get_user_by_email,
|
|
verify_password,
|
|
get_or_create_google_user,
|
|
PASSLIB_AVAILABLE,
|
|
)
|
|
from services.payment_service import (
|
|
create_checkout_session,
|
|
sync_checkout_session,
|
|
create_credits_checkout,
|
|
cancel_subscription,
|
|
get_billing_portal_url,
|
|
handle_webhook,
|
|
is_stripe_configured,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
# Request/Response models
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
user: UserResponse
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class GoogleAuthRequest(BaseModel):
|
|
credential: Optional[str] = None # Google ID token (from GoogleLogin component)
|
|
access_token: Optional[str] = None # Google OAuth2 access token (from useGoogleLogin)
|
|
|
|
|
|
class CheckoutRequest(BaseModel):
|
|
plan: PlanType
|
|
billing_period: str = "monthly"
|
|
|
|
|
|
class CreditsCheckoutRequest(BaseModel):
|
|
package_index: int
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
):
|
|
if not credentials:
|
|
return None
|
|
payload = verify_token(credentials.credentials)
|
|
if not payload:
|
|
return None
|
|
return get_user_by_id(payload.get("sub"))
|
|
|
|
|
|
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
payload = verify_token(credentials.credentials)
|
|
if not payload:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
user = get_user_by_id(payload.get("sub"))
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
return user
|
|
|
|
|
|
def user_to_response(user) -> UserResponse:
|
|
"""Convert User to UserResponse with plan limits"""
|
|
plan_limits = PLANS[user.plan]
|
|
return UserResponse(
|
|
id=user.id,
|
|
email=user.email,
|
|
name=user.name,
|
|
avatar_url=user.avatar_url,
|
|
plan=user.plan,
|
|
tier=user.plan,
|
|
subscription_status=user.subscription_status,
|
|
subscription_ends_at=getattr(user, 'subscription_ends_at', None),
|
|
cancel_at_period_end=getattr(user, 'cancel_at_period_end', False),
|
|
docs_translated_this_month=user.docs_translated_this_month,
|
|
pages_translated_this_month=user.pages_translated_this_month,
|
|
api_calls_this_month=user.api_calls_this_month,
|
|
extra_credits=user.extra_credits,
|
|
created_at=user.created_at,
|
|
plan_limits={
|
|
"docs_per_month": plan_limits["docs_per_month"],
|
|
"max_pages_per_doc": plan_limits["max_pages_per_doc"],
|
|
"max_file_size_mb": plan_limits["max_file_size_mb"],
|
|
"providers": plan_limits["providers"],
|
|
"features": plan_limits["features"],
|
|
"api_access": plan_limits.get("api_access", False),
|
|
},
|
|
)
|
|
|
|
|
|
# ============== API v1 Router ==============
|
|
|
|
router_v1 = APIRouter(prefix="/api/v1/auth", tags=["Authentication v1"])
|
|
|
|
|
|
def _has_email_validation_error(exc: PydanticValidationError) -> bool:
|
|
"""Return True when pydantic validation errors include the email field."""
|
|
for err in exc.errors():
|
|
loc = err.get("loc", ())
|
|
if isinstance(loc, (list, tuple)) and "email" in loc:
|
|
return True
|
|
return False
|
|
|
|
|
|
@router_v1.post(
|
|
"/register",
|
|
status_code=201,
|
|
summary="Inscription d'un nouvel utilisateur",
|
|
description="""
|
|
Créer un nouveau compte utilisateur.
|
|
|
|
**Paramètres requis:**
|
|
- `email`: Adresse email valide (sera utilisée pour la connexion)
|
|
- `password`: Mot de passe (minimum 8 caractères)
|
|
- `name`: Nom complet (optionnel)
|
|
|
|
**Réponse:**
|
|
- HTTP 201 avec les données de l'utilisateur créé
|
|
- Le `tier` par défaut est "free"
|
|
|
|
**Codes d'erreur:**
|
|
- `INVALID_EMAIL`: Invalid email format
|
|
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email
|
|
- `INVALID_REQUEST`: Données d'inscription invalides
|
|
""",
|
|
responses={
|
|
201: {
|
|
"description": "Utilisateur créé avec succès",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"data": {
|
|
"id": "usr_abc123def456",
|
|
"email": "utilisateur@exemple.com",
|
|
"tier": "free",
|
|
},
|
|
"meta": {},
|
|
}
|
|
}
|
|
},
|
|
},
|
|
400: {
|
|
"description": "Erreur de validation",
|
|
"content": {
|
|
"application/json": {
|
|
"examples": {
|
|
"INVALID_EMAIL": {
|
|
"summary": "Invalid email format",
|
|
"value": {
|
|
"error": "INVALID_EMAIL",
|
|
"message": "Invalid email format",
|
|
},
|
|
},
|
|
"EMAIL_EXISTS": {
|
|
"summary": "Email already used",
|
|
"value": {
|
|
"error": "EMAIL_EXISTS",
|
|
"message": "An account already exists with this email address",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def register_v1(request: Request):
|
|
"""Inscription d'un nouvel utilisateur (API v1) — retourne 201 avec données utilisateur"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid JSON request body",
|
|
},
|
|
)
|
|
|
|
try:
|
|
user_create = UserCreate.model_validate(body)
|
|
except PydanticValidationError as exc:
|
|
if _has_email_validation_error(exc):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_EMAIL",
|
|
"message": "Invalid email format",
|
|
},
|
|
)
|
|
# Check if it's a password validation error
|
|
for error in exc.errors():
|
|
loc = error.get("loc", ())
|
|
if "password" in loc:
|
|
msg = error.get("msg", "")
|
|
# If password is missing entirely, return INVALID_REQUEST
|
|
if "Field required" in msg or "required" in msg.lower():
|
|
break
|
|
# Otherwise it's a weak password
|
|
# Translate common pydantic messages to French
|
|
if "at least 8 characters" in msg.lower() or "8 caractères" in msg:
|
|
msg = "Password must be at least 8 characters long"
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "WEAK_PASSWORD",
|
|
"message": msg,
|
|
},
|
|
)
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid registration data",
|
|
},
|
|
)
|
|
|
|
# In production, registration must rely on passlib[bcrypt] hashing.
|
|
if (
|
|
os.getenv("ENVIRONMENT", "development").lower() == "production"
|
|
and not PASSLIB_AVAILABLE
|
|
):
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={
|
|
"error": "AUTH_HASHING_UNAVAILABLE",
|
|
"message": "Registration service temporarily unavailable",
|
|
},
|
|
)
|
|
|
|
try:
|
|
user = create_user(user_create)
|
|
except ValueError as exc:
|
|
msg = str(exc).strip().lower()
|
|
if "email already registered" in msg or "email already" in msg:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "EMAIL_EXISTS",
|
|
"message": "An account already exists with this email address",
|
|
},
|
|
)
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "REGISTRATION_FAILED",
|
|
"message": "Unable to create account with the provided data",
|
|
},
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=201,
|
|
content={
|
|
"data": {
|
|
"id": user.id,
|
|
"email": user.email,
|
|
"tier": user.plan.value,
|
|
},
|
|
"meta": {},
|
|
},
|
|
)
|
|
|
|
|
|
@router_v1.post(
|
|
"/logout",
|
|
summary="Déconnexion utilisateur",
|
|
description="""
|
|
Déconnecte l'utilisateur en révoquant son token d'accès.
|
|
|
|
**Authentification requise:** Bearer token dans le header Authorization
|
|
|
|
**Paramètres optionnels:**
|
|
- `refresh_token`: Peut être fourni pour révoquer également le refresh token
|
|
|
|
**Réponse:**
|
|
- HTTP 200 avec message de confirmation
|
|
|
|
**Codes d'erreur:**
|
|
- `TOKEN_MISSING`: Authentication token missing
|
|
- `TOKEN_INVALID`: Token invalide ou expiré
|
|
""",
|
|
responses={
|
|
200: {
|
|
"description": "Déconnexion réussie",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {"data": {"message": "Logged out successfully"}, "meta": {}}
|
|
}
|
|
},
|
|
},
|
|
401: {
|
|
"description": "Erreur d'authentification",
|
|
"content": {
|
|
"application/json": {
|
|
"examples": {
|
|
"TOKEN_MISSING": {
|
|
"summary": "Missing token",
|
|
"value": {
|
|
"error": "TOKEN_MISSING",
|
|
"message": "Authentication token required",
|
|
},
|
|
},
|
|
"TOKEN_INVALID": {
|
|
"summary": "Invalid token",
|
|
"value": {
|
|
"error": "TOKEN_INVALID",
|
|
"message": "Invalid or expired token",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def logout_v1(request: Request):
|
|
"""Logout utilisateur (API v1) — révoque l'access token et optionnellement le refresh token"""
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "TOKEN_MISSING",
|
|
"message": "Authentication token required",
|
|
},
|
|
)
|
|
access_token = auth_header[7:]
|
|
|
|
payload = verify_token(access_token)
|
|
if not payload:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "TOKEN_INVALID",
|
|
"message": "Invalid or expired token",
|
|
},
|
|
)
|
|
|
|
jti = payload.get("jti")
|
|
if jti:
|
|
revoke_token_jti(jti, float(payload.get("exp", 0)))
|
|
|
|
try:
|
|
body = await request.json()
|
|
refresh_token = body.get("refresh_token")
|
|
if refresh_token:
|
|
refresh_payload = verify_token(refresh_token)
|
|
if refresh_payload and refresh_payload.get("jti"):
|
|
revoke_token_jti(
|
|
refresh_payload["jti"],
|
|
float(refresh_payload.get("exp", 0)),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={"data": {"message": "Logged out successfully"}, "meta": {}},
|
|
)
|
|
|
|
|
|
@router_v1.post(
|
|
"/login",
|
|
summary="Connexion utilisateur",
|
|
description="""
|
|
Authentifie un utilisateur et retourne les tokens JWT.
|
|
|
|
**Paramètres requis:**
|
|
- `email`: Adresse email de l'utilisateur
|
|
- `password`: Mot de passe
|
|
|
|
**Réponse:**
|
|
- HTTP 200 avec `access_token` (expire dans 15 min) et `refresh_token` (expire dans 7 jours)
|
|
|
|
**Codes d'erreur:**
|
|
- `INVALID_REQUEST`: Corps de requête JSON invalide
|
|
- `INVALID_EMAIL`: Invalid email format
|
|
- `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
|
|
""",
|
|
responses={
|
|
200: {
|
|
"description": "Connexion réussie",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"data": {
|
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
"token_type": "bearer",
|
|
},
|
|
"meta": {},
|
|
}
|
|
}
|
|
},
|
|
},
|
|
400: {
|
|
"description": "Erreur de validation",
|
|
"content": {
|
|
"application/json": {
|
|
"examples": {
|
|
"INVALID_REQUEST": {
|
|
"summary": "Invalid body",
|
|
"value": {
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid JSON request body",
|
|
},
|
|
},
|
|
"INVALID_EMAIL": {
|
|
"summary": "Invalid email",
|
|
"value": {
|
|
"error": "INVALID_EMAIL",
|
|
"message": "Invalid email format",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
},
|
|
},
|
|
401: {
|
|
"description": "Identifiants invalides",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"error": "INVALID_CREDENTIALS",
|
|
"message": "Incorrect email or password",
|
|
}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def login_v1(request: Request):
|
|
"""Login utilisateur (API v1) — retourne access_token (15min) et refresh_token (7j)"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid JSON request body",
|
|
},
|
|
)
|
|
|
|
try:
|
|
user_login = UserLogin.model_validate(body)
|
|
except PydanticValidationError as exc:
|
|
if _has_email_validation_error(exc):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_EMAIL",
|
|
"message": "Invalid email format",
|
|
},
|
|
)
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid registration data",
|
|
},
|
|
)
|
|
|
|
user = get_user_by_email(user_login.email)
|
|
if not user:
|
|
# Constant-time dummy verify to prevent user enumeration via response time
|
|
verify_password("__dummy__", "$2b$12$mBw4RxPJBaaS1FtEZcT/E.E35YUCk1Zx0ICzIzNUSdzHmQmko1.WW")
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "INVALID_CREDENTIALS",
|
|
"message": "Incorrect email or password",
|
|
},
|
|
)
|
|
|
|
if not verify_password(user_login.password, user.password_hash):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "INVALID_CREDENTIALS",
|
|
"message": "Incorrect email or password",
|
|
},
|
|
)
|
|
|
|
access_token = create_access_token(
|
|
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
|
|
)
|
|
refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
|
|
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
"data": {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"token_type": "bearer",
|
|
},
|
|
"meta": {},
|
|
},
|
|
)
|
|
|
|
|
|
@router_v1.post("/google")
|
|
async def google_auth_v1(body: GoogleAuthRequest):
|
|
"""Authentification via Google OAuth.
|
|
Accepte soit un credential (ID token) soit un access_token Google,
|
|
vérifie avec l'API Google, puis crée ou connecte l'utilisateur."""
|
|
import httpx as _httpx
|
|
|
|
if not body.credential and not body.access_token:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "MISSING_TOKEN", "message": "credential or access_token required."},
|
|
)
|
|
|
|
google_client_id = os.getenv("GOOGLE_CLIENT_ID", "")
|
|
|
|
try:
|
|
async with _httpx.AsyncClient(timeout=10.0) as client:
|
|
if body.credential:
|
|
# Verify ID token via tokeninfo
|
|
resp = await client.get(
|
|
"https://oauth2.googleapis.com/tokeninfo",
|
|
params={"id_token": body.credential},
|
|
)
|
|
else:
|
|
# Verify access token via userinfo
|
|
resp = await client.get(
|
|
"https://www.googleapis.com/oauth2/v3/userinfo",
|
|
headers={"Authorization": f"Bearer {body.access_token}"},
|
|
)
|
|
except Exception:
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={"error": "GOOGLE_UNREACHABLE", "message": "Unable to contact Google servers."},
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Invalid or expired Google token."},
|
|
)
|
|
|
|
google_data = resp.json()
|
|
|
|
# Validate audience only for ID tokens (not for access_token flow)
|
|
if body.credential and google_client_id and google_data.get("aud") != google_client_id:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"error": "INVALID_TOKEN_AUDIENCE", "message": "Google token not intended for this application."},
|
|
)
|
|
|
|
email = google_data.get("email")
|
|
if not email:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "MISSING_EMAIL", "message": "Email not provided by Google."},
|
|
)
|
|
|
|
name = google_data.get("name") or google_data.get("given_name") or email.split("@")[0]
|
|
avatar_url = google_data.get("picture")
|
|
|
|
try:
|
|
user = get_or_create_google_user(email=email, name=name, avatar_url=avatar_url)
|
|
except Exception as exc:
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"error": "USER_CREATE_FAILED", "message": str(exc)},
|
|
)
|
|
|
|
access_token = create_access_token(user.id)
|
|
refresh_token = create_refresh_token(user.id)
|
|
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
"data": {
|
|
"access_token": access_token,
|
|
"refresh_token": refresh_token,
|
|
"token_type": "bearer",
|
|
},
|
|
"meta": {},
|
|
},
|
|
)
|
|
|
|
|
|
@router_v1.post("/refresh")
|
|
async def refresh_v1(request: Request):
|
|
"""Refresh tokens (API v1) — accepte refresh_token en corps, retourne nouvel access_token et refresh_token."""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid JSON request body",
|
|
},
|
|
)
|
|
|
|
if not isinstance(body, dict):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid JSON request body",
|
|
},
|
|
)
|
|
|
|
refresh_token = body.get("refresh_token")
|
|
if (
|
|
not refresh_token
|
|
or not isinstance(refresh_token, str)
|
|
or not refresh_token.strip()
|
|
):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Refresh token required",
|
|
},
|
|
)
|
|
|
|
payload = verify_token(refresh_token)
|
|
if not payload:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "TOKEN_EXPIRED",
|
|
"message": "Invalid or expired token",
|
|
},
|
|
)
|
|
|
|
if payload.get("type") != "refresh":
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "TOKEN_EXPIRED",
|
|
"message": "Invalid or expired token",
|
|
},
|
|
)
|
|
|
|
user = get_user_by_id(payload.get("sub"))
|
|
if not user:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "TOKEN_EXPIRED",
|
|
"message": "Invalid or expired token",
|
|
},
|
|
)
|
|
|
|
access_token = create_access_token(
|
|
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
|
|
)
|
|
new_refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
|
|
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
"data": {
|
|
"access_token": access_token,
|
|
"refresh_token": new_refresh_token,
|
|
"token_type": "bearer",
|
|
},
|
|
"meta": {},
|
|
},
|
|
)
|
|
|
|
|
|
@router_v1.get(
|
|
"/me",
|
|
summary="Informations utilisateur",
|
|
description="Retourne les informations de l'utilisateur authentifié.",
|
|
)
|
|
async def get_me_v1(user=Depends(require_user)):
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={"data": user_to_response(user).model_dump(mode="json"), "meta": {}},
|
|
)
|
|
|
|
|
|
@router_v1.get(
|
|
"/plans",
|
|
summary="Liste des forfaits disponibles",
|
|
description="Retourne tous les forfaits et packs de crédits disponibles (endpoint public).",
|
|
)
|
|
async def get_plans_v1():
|
|
from models.subscription import PLANS, CREDIT_PACKAGES
|
|
from services import pricing_config as pricing_cfg
|
|
|
|
plans_list = []
|
|
for plan_type, plan in PLANS.items():
|
|
plan_id = plan_type.value
|
|
monthly, yearly = pricing_cfg.get_effective_monthly_yearly(plan_id)
|
|
plans_list.append(
|
|
{
|
|
"id": plan_id,
|
|
"name": plan["name"],
|
|
"price_monthly": monthly,
|
|
"price_yearly": yearly,
|
|
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
|
"docs_per_month": plan["docs_per_month"],
|
|
"max_pages_per_doc": plan["max_pages_per_doc"],
|
|
"max_file_size_mb": plan["max_file_size_mb"],
|
|
"max_chars_per_month": plan.get("max_chars_per_month", -1),
|
|
"providers": plan["providers"],
|
|
"features": plan["features"],
|
|
"ai_translation": plan.get("ai_translation", False),
|
|
"ai_tier": plan.get("ai_tier"),
|
|
"api_access": plan.get("api_access", False),
|
|
"priority_processing": plan.get("priority_processing", False),
|
|
"team_seats": plan.get("team_seats"),
|
|
"highlight": plan.get("highlight"),
|
|
"description": plan.get("description"),
|
|
"badge": plan.get("badge"),
|
|
"popular": plan.get("badge") == "POPULAIRE",
|
|
}
|
|
)
|
|
|
|
packages_list = []
|
|
for pkg in CREDIT_PACKAGES:
|
|
packages_list.append(
|
|
{
|
|
"credits": pkg["credits"],
|
|
"price": pkg["price"],
|
|
"price_per_credit": round(pkg["price"] / pkg["credits"], 4),
|
|
"popular": pkg.get("popular", False),
|
|
}
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=200,
|
|
headers={
|
|
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
|
},
|
|
content={
|
|
"data": {"plans": plans_list, "credit_packages": packages_list},
|
|
"meta": {
|
|
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
|
"yearly_discount_factor": pricing_cfg.YEARLY_DISCOUNT_FACTOR,
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
|
|
@router_v1.get(
|
|
"/usage",
|
|
summary="Utilisation et limites",
|
|
description="Retourne l'utilisation actuelle et les limites du plan de l'utilisateur.",
|
|
)
|
|
async def get_usage_v1(user=Depends(require_user)):
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={"data": check_usage_limits(user), "meta": {}},
|
|
)
|
|
|
|
|
|
@router_v1.post(
|
|
"/create-checkout",
|
|
summary="Créer une session de paiement",
|
|
description="Crée une session Stripe Checkout pour souscrire à un forfait.",
|
|
)
|
|
async def create_checkout_v1(request: CheckoutRequest, user=Depends(require_user)):
|
|
result = await create_checkout_session(
|
|
user_id=user.id,
|
|
plan=request.plan,
|
|
billing_period=request.billing_period,
|
|
)
|
|
if "error" in result and not result.get("demo_mode"):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "CHECKOUT_FAILED",
|
|
"message": result["error"],
|
|
},
|
|
)
|
|
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
|
|
|
|
|
@router_v1.get(
|
|
"/checkout/sync",
|
|
summary="Synchroniser un checkout Stripe",
|
|
description=(
|
|
"Synchronise un paiement Stripe finalisé via session_id "
|
|
"(utile en dev/local quand le webhook n'est pas reçu)."
|
|
),
|
|
)
|
|
async def sync_checkout_v1(
|
|
session_id: str = Query(..., min_length=5),
|
|
user=Depends(require_user),
|
|
):
|
|
result = await sync_checkout_session(user_id=user.id, session_id=session_id)
|
|
if "error" in result:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "CHECKOUT_SYNC_FAILED",
|
|
"message": result["error"],
|
|
},
|
|
)
|
|
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
|
|
|
|
|
@router_v1.post(
|
|
"/create-credits-checkout",
|
|
summary="Créer une session de paiement pour crédits",
|
|
description="Crée une session Stripe Checkout pour l'achat de crédits supplémentaires.",
|
|
)
|
|
async def create_credits_checkout_v1(request: CreditsCheckoutRequest, user=Depends(require_user)):
|
|
result = await create_credits_checkout(
|
|
user_id=user.id,
|
|
package_index=request.package_index,
|
|
)
|
|
if "error" in result and not result.get("demo_mode"):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "CHECKOUT_FAILED",
|
|
"message": result["error"],
|
|
},
|
|
)
|
|
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
|
|
|
|
|
@router_v1.post(
|
|
"/cancel-subscription",
|
|
summary="Annuler l'abonnement",
|
|
description="Annule l'abonnement Stripe de l'utilisateur à la fin de la période en cours.",
|
|
)
|
|
async def cancel_subscription_v1(user=Depends(require_user)):
|
|
result = await cancel_subscription(user.id)
|
|
if "error" in result:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "CANCEL_FAILED", "message": result["error"]},
|
|
)
|
|
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
|
|
|
|
|
@router_v1.get(
|
|
"/billing-portal",
|
|
summary="Portail de facturation",
|
|
description="Retourne l'URL du portail de facturation Stripe.",
|
|
)
|
|
async def get_billing_portal_v1(user=Depends(require_user)):
|
|
url = await get_billing_portal_url(user.id)
|
|
if not url:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "BILLING_UNAVAILABLE",
|
|
"message": "Billing portal unavailable",
|
|
},
|
|
)
|
|
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
|
|
|
|
|
|
# ============== Forgot / Reset password ==============
|
|
|
|
|
|
class ForgotPasswordRequest(BaseModel):
|
|
email: EmailStr
|
|
|
|
|
|
class ResetPasswordRequest(BaseModel):
|
|
token: str
|
|
password: str
|
|
|
|
|
|
@router_v1.post(
|
|
"/forgot-password",
|
|
summary="Demander une reinitialisation de mot de passe",
|
|
description="""
|
|
Envoie un email de reinitialisation si un compte existe avec cette adresse.
|
|
|
|
**Securite:** Retourne toujours 200 pour ne pas reveler si l'email existe.
|
|
""",
|
|
)
|
|
async def forgot_password(request: Request):
|
|
from services.email_service import is_smtp_configured, send_email_async
|
|
|
|
if not is_smtp_configured():
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={
|
|
"error": "EMAIL_SERVICE_UNAVAILABLE",
|
|
"message": "Email service not configured",
|
|
},
|
|
)
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid JSON request body",
|
|
},
|
|
)
|
|
|
|
email = body.get("email", "").strip()
|
|
if not email:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Email required",
|
|
},
|
|
)
|
|
|
|
# Always return 200 to avoid email enumeration
|
|
user = get_user_by_email(email)
|
|
|
|
if user and user.id:
|
|
token = secrets.token_urlsafe(32)
|
|
expires = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
|
|
# Store token in database
|
|
try:
|
|
from database.connection import get_sync_session
|
|
from database.repositories import UserRepository
|
|
from database.models import User as DBUser
|
|
|
|
with get_sync_session() as session:
|
|
repo = UserRepository(session)
|
|
|
|
# Check if user exists in DB; if not (legacy JSON-only user), create DB record
|
|
db_user = repo.get_by_email(email)
|
|
if not db_user:
|
|
db_user = repo.create(
|
|
email=user.email,
|
|
name=user.name,
|
|
hashed_password=user.password_hash or "",
|
|
tier="free",
|
|
)
|
|
|
|
repo.update(
|
|
db_user.id,
|
|
reset_token=token,
|
|
reset_token_expires=expires,
|
|
)
|
|
except Exception as exc:
|
|
logger.error(f"Failed to store reset token: {exc}")
|
|
# Still return 200 for security
|
|
|
|
# Send email with reset link
|
|
frontend_url = os.getenv("FRONTEND_URL", os.getenv("NEXT_PUBLIC_APP_URL", "http://localhost:3000"))
|
|
reset_link = f"{frontend_url}/auth/reset-password?token={token}"
|
|
|
|
html_body = f"""
|
|
<html>
|
|
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
|
<h2>Reinitialisation de votre mot de passe</h2>
|
|
<p>Vous avez demande la reinitialisation de votre mot de passe.</p>
|
|
<p>Cliquez sur le lien ci-dessous pour definir un nouveau mot de passe :</p>
|
|
<p><a href="{reset_link}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Reinitialiser mon mot de passe</a></p>
|
|
<p>Ce lien expire dans 1 heure.</p>
|
|
<p>Si vous n'avez pas fait cette demande, ignorez cet email.</p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
await send_email_async(
|
|
to=email,
|
|
subject="Reinitialisation de votre mot de passe - Office Translator",
|
|
body=html_body,
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
"data": {
|
|
"message": "If an account exists with this email, a reset email has been sent."
|
|
},
|
|
"meta": {},
|
|
},
|
|
)
|
|
|
|
|
|
@router_v1.post(
|
|
"/reset-password",
|
|
summary="Reinitialiser le mot de passe",
|
|
description="""
|
|
Reinitialise le mot de passe a l'aide d'un token valide.
|
|
|
|
**Parametres requis:**
|
|
- `token`: Le token recu par email
|
|
- `password`: Le nouveau mot de passe (min 8 caracteres, 1 majuscule, 1 minuscule, 1 chiffre)
|
|
""",
|
|
)
|
|
async def reset_password(request: Request):
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Invalid JSON request body",
|
|
},
|
|
)
|
|
|
|
token = body.get("token", "").strip()
|
|
password = body.get("password", "")
|
|
|
|
if not token or not password:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_REQUEST",
|
|
"message": "Token and password required",
|
|
},
|
|
)
|
|
|
|
# Validate password strength
|
|
if len(password) < 8 or not any(c.isupper() for c in password) or not any(c.islower() for c in password) or not any(c.isdigit() for c in password):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "WEAK_PASSWORD",
|
|
"message": "Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit",
|
|
},
|
|
)
|
|
|
|
# Find user by reset token
|
|
try:
|
|
from database.connection import get_sync_session
|
|
from database.models import User as DBUser
|
|
from services.auth_service import hash_password
|
|
|
|
with get_sync_session() as session:
|
|
db_user = (
|
|
session.query(DBUser)
|
|
.filter(DBUser.reset_token == token)
|
|
.first()
|
|
)
|
|
|
|
if not db_user:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "INVALID_TOKEN",
|
|
"message": "Invalid or expired token",
|
|
},
|
|
)
|
|
|
|
# Check token expiry
|
|
if db_user.reset_token_expires:
|
|
expires = db_user.reset_token_expires
|
|
if expires.tzinfo is None:
|
|
expires = expires.replace(tzinfo=timezone.utc)
|
|
if expires < datetime.now(timezone.utc):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "TOKEN_EXPIRED",
|
|
"message": "Token expired, please request a new reset",
|
|
},
|
|
)
|
|
|
|
# Update password and invalidate token
|
|
db_user.hashed_password = hash_password(password)
|
|
db_user.reset_token = None
|
|
db_user.reset_token_expires = None
|
|
db_user.updated_at = datetime.utcnow()
|
|
session.commit()
|
|
|
|
except Exception as exc:
|
|
logger.error(f"Reset password failed: {exc}")
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error": "RESET_FAILED",
|
|
"message": "Error during password reset",
|
|
},
|
|
)
|
|
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
"data": {"message": "Password reset successfully"},
|
|
"meta": {},
|
|
},
|
|
)
|
|
|
|
|
|
# ============== Stripe webhook (versioned) ==============
|
|
|
|
|
|
@router_v1.post("/webhook/stripe")
|
|
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
|
|
"""Handle Stripe webhooks"""
|
|
payload = await request.body()
|
|
|
|
result = await handle_webhook(payload, stripe_signature or "")
|
|
|
|
if "error" in result:
|
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
|
|
return result
|