Spaces:
Build error
Build error
Upload 12 files
Browse files- .dockerignore +20 -0
- Dockerfile +57 -0
- app.py +135 -0
- auth_backend.py +290 -0
- billing_routes.py +137 -0
- data_committer.py +120 -0
- decorators.py +162 -0
- email_validator.py +103 -0
- entrypoint.sh +32 -0
- git_storage.py +125 -0
- requirements.txt +10 -0
- user_routes.py +196 -0
.dockerignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fichiers invisibles de versionnement et d'IDE
|
| 2 |
+
.git
|
| 3 |
+
.gitattributes
|
| 4 |
+
.gitignore
|
| 5 |
+
.DS_Store
|
| 6 |
+
|
| 7 |
+
# Exclure le Dockerfile et le .dockerignore lui-même (bonne pratique)
|
| 8 |
+
Dockerfile
|
| 9 |
+
.dockerignore
|
| 10 |
+
|
| 11 |
+
# Exclure les dossiers de cache Python
|
| 12 |
+
__pycache__/
|
| 13 |
+
*.pyc
|
| 14 |
+
|
| 15 |
+
# Exclure les dossiers de développement/log
|
| 16 |
+
venv/ # Si vous avez un environnement virtuel
|
| 17 |
+
env/ # Si vous avez un autre nom d'environnement virtuel
|
| 18 |
+
logs/ # Dossiers de logs
|
| 19 |
+
tmp/ # Dossiers temporaires
|
| 20 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ÉTAPE 1: Image de base
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# ÉTAPE 2: Configuration et Dossier de travail
|
| 5 |
+
# Ligne supprimée (ENV PORT 8080) pour laisser Hugging Face Spaces injecter le port correct ($PORT, généralement 7860).
|
| 6 |
+
ENV FLASK_APP app.py
|
| 7 |
+
ENV GUNICORN_WORKERS 4
|
| 8 |
+
ENV GUNICORN_THREADS 2
|
| 9 |
+
|
| 10 |
+
# Création et utilisation du répertoire /app
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# ÉTAPE 3: Installation des dépendances (OPTIMISATION CACHING)
|
| 14 |
+
# Copie uniquement de requirements.txt pour mettre en cache l'installation
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt \
|
| 17 |
+
&& rm requirements.txt
|
| 18 |
+
|
| 19 |
+
# ÉTAPE 4: Copie de l'Application et des Fichiers
|
| 20 |
+
# Nous copions tous les fichiers de l'application et nous assurons que
|
| 21 |
+
# l'utilisateur 'user' en est le propriétaire.
|
| 22 |
+
|
| 23 |
+
# CORRECTION MAJEURE : Ajout du dossier templates
|
| 24 |
+
# Ceci est l'étape essentielle pour que Flask trouve vos fichiers HTML
|
| 25 |
+
COPY templates /app/templates
|
| 26 |
+
|
| 27 |
+
# Copie des autres fichiers (y compris app.py, votre point d'entrée)
|
| 28 |
+
COPY app.py .
|
| 29 |
+
COPY git_storage.py .
|
| 30 |
+
COPY user_routes.py .
|
| 31 |
+
COPY data_committer.py .
|
| 32 |
+
COPY decorators.py .
|
| 33 |
+
COPY billing_routes.py .
|
| 34 |
+
COPY auth_backend.py .
|
| 35 |
+
COPY email_validator.py .
|
| 36 |
+
|
| 37 |
+
# Copie du script d'entrée
|
| 38 |
+
COPY entrypoint.sh .
|
| 39 |
+
|
| 40 |
+
# NOUVEAU: CORRECTION DES FINS DE LIGNE (Résout 'exec ./entrypoint.sh: no such file or directory')
|
| 41 |
+
# Supprime le caractère de retour chariot (\r)
|
| 42 |
+
RUN sed -i 's/\r$//' entrypoint.sh
|
| 43 |
+
|
| 44 |
+
# Le rendre exécutable
|
| 45 |
+
RUN chmod +x entrypoint.sh
|
| 46 |
+
|
| 47 |
+
# ÉTAPE 5: Sécurité et Exécution
|
| 48 |
+
# Création et bascule vers l'utilisateur non-root ('user') pour la sécurité
|
| 49 |
+
RUN useradd -ms /bin/bash user
|
| 50 |
+
RUN chown -R user:user /app
|
| 51 |
+
USER user
|
| 52 |
+
|
| 53 |
+
# Indique à Docker que le conteneur écoute sur ce port
|
| 54 |
+
EXPOSE $PORT
|
| 55 |
+
|
| 56 |
+
# Lance l'application via le script d'entrée
|
| 57 |
+
CMD ["./entrypoint.sh"]
|
app.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import io
|
| 7 |
+
import uuid
|
| 8 |
+
from functools import wraps
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from flask import Flask, request, jsonify, Response, session
|
| 11 |
+
from flask_cors import CORS
|
| 12 |
+
|
| 13 |
+
# Importation des modules backend
|
| 14 |
+
from auth_backend import (
|
| 15 |
+
register_user,
|
| 16 |
+
login_user,
|
| 17 |
+
get_user_by_id,
|
| 18 |
+
get_plan_limit,
|
| 19 |
+
reset_password_via_security_question,
|
| 20 |
+
generate_password_hash,
|
| 21 |
+
update_user_quota # NOUVEL IMPORT (Phase 1)
|
| 22 |
+
# Suppression des fonctions end_users
|
| 23 |
+
)
|
| 24 |
+
from git_storage import load_users_data
|
| 25 |
+
from decorators import api_key_required, quota_required # <-- IMPORT de quota_required (Phase 2)
|
| 26 |
+
|
| 27 |
+
# Importation du nouveau module de validation
|
| 28 |
+
from email_validator import validate_email # NOUVEL IMPORT (Phase 2)
|
| 29 |
+
|
| 30 |
+
# Importation des Blueprints
|
| 31 |
+
from user_routes import user_bp, web_bp # <--- MISE À JOUR : Importe user_bp ET web_bp
|
| 32 |
+
from billing_routes import billing_bp
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# Valeur par défaut pour la taille max de contenu
|
| 36 |
+
DEFAULT_MAX_CONTENT_LENGTH = 16 * 1024 * 1024
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --- Initialisation de l'Application Flask ---
|
| 40 |
+
app = Flask(__name__)
|
| 41 |
+
|
| 42 |
+
# Configuration
|
| 43 |
+
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "super_secret_default_key") # Clé Secrète pour Flask Session
|
| 44 |
+
app.config['MAX_CONTENT_LENGTH'] = DEFAULT_MAX_CONTENT_LENGTH # Limite de la taille des requêtes
|
| 45 |
+
CORS(app) # Autoriser les appels cross-domain
|
| 46 |
+
|
| 47 |
+
# Enregistrement des Blueprints
|
| 48 |
+
# Le Blueprint web_bp (routes publiques) est maintenant dans user_routes.py
|
| 49 |
+
app.register_blueprint(web_bp)
|
| 50 |
+
app.register_blueprint(user_bp, url_prefix='/user') # Espace utilisateur (ex: /user/dashboard)
|
| 51 |
+
app.register_blueprint(billing_bp, url_prefix='/billing') # Routes Stripe (ex: /billing/webhook)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# --- Route API pour la validation d'e-mail (API) ---
|
| 55 |
+
@app.route("/api/validate", methods=["POST"])
|
| 56 |
+
@api_key_required
|
| 57 |
+
@quota_required
|
| 58 |
+
def api_validate_email(client_user):
|
| 59 |
+
"""
|
| 60 |
+
Route principale de l'API de validation d'e-mail.
|
| 61 |
+
"""
|
| 62 |
+
data = request.get_json()
|
| 63 |
+
email = data.get('email', '').strip()
|
| 64 |
+
|
| 65 |
+
if not email:
|
| 66 |
+
return jsonify({
|
| 67 |
+
"message": "Le champ 'email' est manquant dans le corps de la requête.",
|
| 68 |
+
"status": "Bad Request"
|
| 69 |
+
}), 400
|
| 70 |
+
|
| 71 |
+
# 1. Effectuer la validation
|
| 72 |
+
validation_result = validate_email(email)
|
| 73 |
+
|
| 74 |
+
# 2. Décrémenter le quota si l'e-mail est valide ou s'il s'agit d'une erreur de domaine non jetable
|
| 75 |
+
# On ne décrémente PAS si la syntaxe est invalide ou si c'est un domaine jetable (pour ne pas gaspiller le quota sur des spams)
|
| 76 |
+
should_decrement = (validation_result['is_valid']) or (
|
| 77 |
+
not validation_result['details']['is_disposable'] and
|
| 78 |
+
validation_result['details']['syntax_valid'] and
|
| 79 |
+
not validation_result['details']['domain_valid'] # Erreur MX (domaine réel mais non configuré)
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if should_decrement:
|
| 83 |
+
if not update_user_quota(client_user['user_id']):
|
| 84 |
+
# Logique d'urgence: la mise à jour Git a échoué. Retourner une erreur 500.
|
| 85 |
+
return jsonify({
|
| 86 |
+
"message": "Erreur interne: Échec de la mise à jour du quota. Veuillez réessayer plus tard.",
|
| 87 |
+
"status": "Internal Error"
|
| 88 |
+
}), 500
|
| 89 |
+
|
| 90 |
+
# 3. Retourner la réponse
|
| 91 |
+
response_data = {
|
| 92 |
+
"email": email,
|
| 93 |
+
"is_valid": validation_result['is_valid'],
|
| 94 |
+
"reason": validation_result['reason'],
|
| 95 |
+
"details": validation_result['details'],
|
| 96 |
+
# Ajout du quota restant pour l'utilisateur
|
| 97 |
+
"new_quota": get_user_by_id(client_user['user_id']).get('quota', 0)
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return jsonify({
|
| 101 |
+
"message": "Validation d'e-mail terminée.",
|
| 102 |
+
"status": "Success" if validation_result['is_valid'] else "Failed",
|
| 103 |
+
"result": response_data
|
| 104 |
+
}), 200
|
| 105 |
+
|
| 106 |
+
# --- Route API pour obtenir les infos utilisateur (API) ---
|
| 107 |
+
@app.route("/api/user-info", methods=["GET"])
|
| 108 |
+
@api_key_required
|
| 109 |
+
def api_user_info(client_user):
|
| 110 |
+
"""
|
| 111 |
+
Route API pour récupérer les informations de l'utilisateur principal (client)
|
| 112 |
+
à partir de la clé API fournie, en se concentrant sur le QUOTA.
|
| 113 |
+
"""
|
| 114 |
+
# Sécurité : créer une vue sécurisée des informations
|
| 115 |
+
user_info_safe = {
|
| 116 |
+
"user_id": client_user.get('user_id'),
|
| 117 |
+
"username": client_user.get('username'),
|
| 118 |
+
"plan": client_user.get('plan'),
|
| 119 |
+
"quota": client_user.get('quota'),
|
| 120 |
+
"api_key": client_user.get('api_key')
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return jsonify({
|
| 124 |
+
"message": "Informations de compte et quota récupérées avec succès.",
|
| 125 |
+
"status": "Success",
|
| 126 |
+
"user": user_info_safe
|
| 127 |
+
}), 200
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# --- Route de Vérification de l'État (API - Conservée) ---
|
| 131 |
+
@app.route("/api/health", methods=["GET"])
|
| 132 |
+
def health_check():
|
| 133 |
+
"""Vérifie que le serveur est opérationnel."""
|
| 134 |
+
return jsonify({"status": "ok", "message": "Le service est opérationnel."}), 200
|
| 135 |
+
|
auth_backend.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# auth_backend.py
|
| 2 |
+
# (Contenu complet avec les fonctions existantes non modifiées, et la nouvelle fonction ajoutée)
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import uuid
|
| 6 |
+
import secrets
|
| 7 |
+
import string
|
| 8 |
+
import sys # Nécessaire pour print(..., file=sys.stderr)
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 11 |
+
from git_storage import (
|
| 12 |
+
# Fonctions de lecture/écriture pour les utilisateurs principaux (isolés)
|
| 13 |
+
load_primary_user_data,
|
| 14 |
+
save_primary_user_data,
|
| 15 |
+
# Temporairement conservé pour l'API Key Search sans index et les logins Web.
|
| 16 |
+
load_users_data,
|
| 17 |
+
)
|
| 18 |
+
# Suppression des imports load_end_user_data et save_end_user_data
|
| 19 |
+
from flask import session
|
| 20 |
+
from decorators import PLANS_CONFIG # <--- MISE À JOUR : Importe PLANS_CONFIG depuis decorators
|
| 21 |
+
from typing import Optional, Dict
|
| 22 |
+
|
| 23 |
+
# ----------------------------------------------------------------------
|
| 24 |
+
# --- NOUVELLES FONCTIONS DE GESTION DES CLÉS API (Phase 1 & 2) ---
|
| 25 |
+
# ----------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
API_KEY_PREFIX = "NXS_"
|
| 28 |
+
API_KEY_LENGTH = 48 # Nombre de caractères hexadécimaux (24 octets)
|
| 29 |
+
|
| 30 |
+
def generate_api_key(user_id: str) -> str:
|
| 31 |
+
"""
|
| 32 |
+
Génère une clé API sécurisée avec le préfixe NXS_.
|
| 33 |
+
Utilise secrets.token_hex pour une chaîne longue et aléatoire.
|
| 34 |
+
"""
|
| 35 |
+
# 48 caractères hexadécimaux pour le corps de la clé (équivalent à 24 octets de données aléatoires)
|
| 36 |
+
# On pourrait inclure l'ID utilisateur dans le hachage pour une meilleure unicité, mais secrets.token_hex est suffisant.
|
| 37 |
+
random_part = secrets.token_hex(API_KEY_LENGTH // 2)
|
| 38 |
+
return f"{API_KEY_PREFIX}{random_part}"
|
| 39 |
+
|
| 40 |
+
def generate_initial_api_keys(user_id: str, count: int = 10) -> list[str]:
|
| 41 |
+
"""Génère une liste de 10 nouvelles clés API pour un utilisateur."""
|
| 42 |
+
return [generate_api_key(user_id) for _ in range(count)]
|
| 43 |
+
|
| 44 |
+
def regenerate_api_key(user_id: str, key_index: int) -> tuple[bool, str]:
|
| 45 |
+
"""
|
| 46 |
+
Régénère une clé API spécifique pour un utilisateur donné (index 0 à 9).
|
| 47 |
+
"""
|
| 48 |
+
# L'index doit être entre 0 et 9 (pour 10 clés)
|
| 49 |
+
if not 0 <= key_index < 10:
|
| 50 |
+
return False, "Index de clé invalide. Doit être entre 0 et 9."
|
| 51 |
+
|
| 52 |
+
user_data = load_primary_user_data(user_id)
|
| 53 |
+
|
| 54 |
+
if not user_data:
|
| 55 |
+
return False, "Utilisateur principal introuvable."
|
| 56 |
+
|
| 57 |
+
# S'assurer que la structure existe, sinon initialiser (pour les anciens utilisateurs)
|
| 58 |
+
if 'api_keys' not in user_data or len(user_data['api_keys']) != 10:
|
| 59 |
+
user_data['api_keys'] = generate_initial_api_keys(user_id, count=10)
|
| 60 |
+
|
| 61 |
+
# Générer la nouvelle clé
|
| 62 |
+
new_key = generate_api_key(user_id)
|
| 63 |
+
|
| 64 |
+
# Mise à jour de la clé spécifique dans la liste
|
| 65 |
+
user_data['api_keys'][key_index] = new_key
|
| 66 |
+
|
| 67 |
+
# Sauvegarde en STAGING
|
| 68 |
+
commit_msg = f"feat: Régénération de la Clé API n°{key_index + 1} pour utilisateur {user_id}"
|
| 69 |
+
success = save_primary_user_data(user_id, user_data, commit_msg)
|
| 70 |
+
|
| 71 |
+
if success:
|
| 72 |
+
return True, f"Clé API n°{key_index + 1} régénérée avec succès. Nouvelle clé: {new_key}"
|
| 73 |
+
else:
|
| 74 |
+
return False, "Échec de la sauvegarde des données utilisateur (problème Git)."
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ----------------------------------------------------------------------
|
| 78 |
+
# --- Fonctions Utilitaires et de Configuration ---\
|
| 79 |
+
# ----------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
def get_plan_limit(plan: str) -> float:
|
| 82 |
+
"""Retourne la limite de compte pour un plan donné."""
|
| 83 |
+
return PLANS_CONFIG.get(plan, {}).get("limit", PLANS_CONFIG["free"]["limit"])
|
| 84 |
+
|
| 85 |
+
def get_plan_details(plan_id: str) -> Optional[Dict]:
|
| 86 |
+
"""Retourne les détails complets d'un plan à partir de son ID."""
|
| 87 |
+
return PLANS_CONFIG.get(plan_id)
|
| 88 |
+
|
| 89 |
+
def get_user_by_id(user_id: str) -> Optional[Dict]:
|
| 90 |
+
"""Récupère un utilisateur principal à partir de son ID (UUID)."""
|
| 91 |
+
if not user_id:
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
# Tente de charger l'utilisateur à partir du fichier isolé (méthode standard)
|
| 95 |
+
user_data = load_primary_user_data(user_id)
|
| 96 |
+
|
| 97 |
+
if user_data:
|
| 98 |
+
return user_data
|
| 99 |
+
|
| 100 |
+
# Fallback/Compatibilité : Lecture du fichier monolithique users.json
|
| 101 |
+
try:
|
| 102 |
+
users = load_users_data()
|
| 103 |
+
return users.get(user_id)
|
| 104 |
+
except Exception:
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
def get_user_by_email_or_username(identifier: str) -> Optional[Dict]:
|
| 108 |
+
"""
|
| 109 |
+
Récupère un utilisateur principal à partir de son email ou de son nom d'utilisateur.
|
| 110 |
+
Nécessite la lecture du fichier users.json (lent et temporaire).
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
users = load_users_data()
|
| 114 |
+
for user_data in users.values():
|
| 115 |
+
if user_data['email'].lower() == identifier.lower() or user_data['username'].lower() == identifier.lower():
|
| 116 |
+
return user_data
|
| 117 |
+
except Exception:
|
| 118 |
+
pass
|
| 119 |
+
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
def get_client_user_by_api_key(api_key: str) -> Optional[Dict]:
|
| 123 |
+
"""
|
| 124 |
+
Récupère les données d'un utilisateur (client) à partir de l'une de ses 10 clés API.
|
| 125 |
+
Nécessite la lecture du fichier users.json (lent et temporaire).
|
| 126 |
+
"""
|
| 127 |
+
# 1. Vérification rapide du préfixe
|
| 128 |
+
if not api_key.startswith(API_KEY_PREFIX):
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
# 2. Chargement du fichier monolithique (TEMPORAIRE)
|
| 132 |
+
users = load_users_data()
|
| 133 |
+
|
| 134 |
+
# 3. Recherche de la clé dans la liste 'api_keys' de chaque utilisateur
|
| 135 |
+
for user_data in users.values():
|
| 136 |
+
# Utilise .get('api_keys', []) pour gérer les anciens utilisateurs sans la clé
|
| 137 |
+
if api_key in user_data.get('api_keys', []):
|
| 138 |
+
return user_data
|
| 139 |
+
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def generate_password_hash(password: str) -> str:
|
| 144 |
+
"""Génère le hachage sécurisé du mot de passe."""
|
| 145 |
+
# Utilise l'algorithme par défaut (souvent sha256) avec un salt
|
| 146 |
+
return generate_password_hash(password)
|
| 147 |
+
|
| 148 |
+
# ----------------------------------------------------------------------
|
| 149 |
+
# --- Fonctions d'Authentification WEB (Utilisateurs Principaux) ---
|
| 150 |
+
# ----------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
def register_user(username, email, password, confirm_password, security_question, security_answer) -> tuple[Optional[str], str]:
|
| 153 |
+
"""Tente d'enregistrer un nouvel utilisateur principal et sauvegarde en STAGING."""
|
| 154 |
+
|
| 155 |
+
# 1. Validation de base
|
| 156 |
+
if not all([username, email, password, confirm_password, security_question, security_answer]):
|
| 157 |
+
return None, "Tous les champs sont obligatoires."
|
| 158 |
+
|
| 159 |
+
if password != confirm_password:
|
| 160 |
+
return None, "Les mots de passe ne correspondent pas."
|
| 161 |
+
|
| 162 |
+
if len(password) < 8:
|
| 163 |
+
return None, "Le mot de passe est trop court (min 8 caractères)."
|
| 164 |
+
|
| 165 |
+
# 2. Vérification de l'existence
|
| 166 |
+
if get_user_by_email_or_username(email) or get_user_by_email_or_username(username):
|
| 167 |
+
return None, "Nom d'utilisateur ou email déjà utilisé."
|
| 168 |
+
|
| 169 |
+
# 3. Préparation des données
|
| 170 |
+
user_id = str(uuid.uuid4())
|
| 171 |
+
password_hash = generate_password_hash(password)
|
| 172 |
+
security_answer_hash = generate_password_hash(security_answer)
|
| 173 |
+
|
| 174 |
+
# 4. MODÈLE DE DONNÉES MIS À JOUR (Phase 1.1)
|
| 175 |
+
new_user_data = {
|
| 176 |
+
'id': user_id,
|
| 177 |
+
'username': username,
|
| 178 |
+
'email': email,
|
| 179 |
+
'password_hash': password_hash,
|
| 180 |
+
'security_question': security_question,
|
| 181 |
+
'security_answer_hash': security_answer_hash,
|
| 182 |
+
'registration_date': datetime.now().isoformat(),
|
| 183 |
+
'plan': "free", # Plan par défaut
|
| 184 |
+
'usage_count': 0,
|
| 185 |
+
# NOUVEAU: Liste des 10 clés API (Phase 1.2)
|
| 186 |
+
'api_keys': generate_initial_api_keys(user_id, count=10),
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
# 5. Sauvegarde des données en STAGING
|
| 190 |
+
commit_msg = f"feat: Nouvel utilisateur principal {email} enregistré"
|
| 191 |
+
success = save_primary_user_data(user_id, new_user_data, commit_msg)
|
| 192 |
+
|
| 193 |
+
if success:
|
| 194 |
+
return user_id, "Inscription réussie. Vous pouvez maintenant vous connecter."
|
| 195 |
+
else:
|
| 196 |
+
# En cas d'échec de sauvegarde (très rare, mais possible)
|
| 197 |
+
return None, "Échec critique de l'inscription (problème de sauvegarde Git)."
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def login_user(identifier, password) -> tuple[Optional[str], str]:
|
| 201 |
+
"""Tente de connecter un utilisateur principal par email ou username."""
|
| 202 |
+
|
| 203 |
+
user = get_user_by_email_or_username(identifier)
|
| 204 |
+
|
| 205 |
+
if user and check_password_hash(user['password_hash'], password):
|
| 206 |
+
# Connexion réussie, retourne l'ID
|
| 207 |
+
return user['id'], "Connexion réussie."
|
| 208 |
+
else:
|
| 209 |
+
return None, "Identifiants invalides."
|
| 210 |
+
|
| 211 |
+
def reset_password_via_security_question(username_or_email, security_answer, new_password) -> tuple[bool, str]:
|
| 212 |
+
"""Réinitialise le mot de passe après avoir validé la question de sécurité."""
|
| 213 |
+
|
| 214 |
+
user = get_user_by_email_or_username(username_or_email)
|
| 215 |
+
|
| 216 |
+
if not user:
|
| 217 |
+
return False, "Utilisateur introuvable."
|
| 218 |
+
|
| 219 |
+
# Vérification de la réponse de sécurité
|
| 220 |
+
if check_password_hash(user['security_answer_hash'], security_answer):
|
| 221 |
+
|
| 222 |
+
if len(new_password) < 8:
|
| 223 |
+
return False, "Le nouveau mot de passe est trop court (min 8 caractères)."
|
| 224 |
+
|
| 225 |
+
# Mise à jour du mot de passe
|
| 226 |
+
user['password_hash'] = generate_password_hash(new_password)
|
| 227 |
+
|
| 228 |
+
# Sauvegarde des données en STAGING
|
| 229 |
+
commit_msg = f"feat: Réinitialisation MDP pour {user['email']}"
|
| 230 |
+
success = save_primary_user_data(user['id'], user, commit_msg)
|
| 231 |
+
|
| 232 |
+
if success:
|
| 233 |
+
return True, "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter."
|
| 234 |
+
else:
|
| 235 |
+
return False, "Échec de la sauvegarde des données utilisateur (problème Git)."
|
| 236 |
+
|
| 237 |
+
else:
|
| 238 |
+
return False, "Réponse à la question de sécurité incorrecte."
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def update_user_plan(user_id: str, new_plan: str, subscription_id: str) -> bool:
|
| 242 |
+
"""Met à jour le plan de l'utilisateur principal et enregistre l'ID de souscription Stripe."""
|
| 243 |
+
user = load_primary_user_data(user_id)
|
| 244 |
+
|
| 245 |
+
if not user:
|
| 246 |
+
print(f"ERROR: Tentative de mise à jour du plan pour l'utilisateur inconnu {user_id}", file=sys.stderr)
|
| 247 |
+
return False
|
| 248 |
+
|
| 249 |
+
user['plan'] = new_plan
|
| 250 |
+
user['subscription_id'] = subscription_id # Stocke l'ID de la souscription Stripe
|
| 251 |
+
user['update_date'] = datetime.now().isoformat()
|
| 252 |
+
|
| 253 |
+
commit_msg = f"feat: Mise à jour du plan de l'utilisateur {user_id} vers {new_plan}"
|
| 254 |
+
|
| 255 |
+
return save_primary_user_data(user_id, user, commit_msg)
|
| 256 |
+
|
| 257 |
+
# --- NOUVELLE FONCTION DE GESTION DU QUOTA (Phase 1) ---
|
| 258 |
+
|
| 259 |
+
def update_user_quota(user_id: str, calls_to_decrement: int = 1) -> bool:
|
| 260 |
+
"""
|
| 261 |
+
Décrémente le quota d'appels API de l'utilisateur principal.
|
| 262 |
+
|
| 263 |
+
Args:
|
| 264 |
+
user_id (str): L'ID de l'utilisateur principal dont le quota doit être mis à jour.
|
| 265 |
+
calls_to_decrement (int): Le nombre d'appels à décrémenter (par défaut 1).
|
| 266 |
+
|
| 267 |
+
Returns:
|
| 268 |
+
bool: True si la mise à jour en staging a réussi, False sinon.
|
| 269 |
+
"""
|
| 270 |
+
# 1. Charger les données actuelles de l'utilisateur
|
| 271 |
+
user = load_primary_user_data(user_id)
|
| 272 |
+
|
| 273 |
+
if not user:
|
| 274 |
+
print(f"ERREUR CRITIQUE: Tentative de mise à jour de quota pour utilisateur introuvable: {user_id}", file=sys.stderr)
|
| 275 |
+
return False
|
| 276 |
+
|
| 277 |
+
# 2. Vérifier si le plan est Illimité
|
| 278 |
+
if user['plan'].startswith('illimited'):
|
| 279 |
+
# Ne rien faire pour les plans illimités
|
| 280 |
+
return True
|
| 281 |
+
|
| 282 |
+
# 3. Décrémenter le quota
|
| 283 |
+
# Assurer que le champ 'quota' existe et est un entier.
|
| 284 |
+
# On utilise max(0, ...) pour ne jamais passer en négatif, même si le quota était...
|
| 285 |
+
current_quota = user.get('quota', 0)
|
| 286 |
+
user['quota'] = max(0, current_quota - calls_to_decrement)
|
| 287 |
+
|
| 288 |
+
# 4. Sauvegarder en staging (le committer s'occupera du commit Git)
|
| 289 |
+
commit_msg = f"quota: Décrémentation de {calls_to_decrement} appel(s) pour utilisateur {user_id}. Nouveau quota: {user['quota']}"
|
| 290 |
+
return save_primary_user_data(user_id, user, commit_msg)
|
billing_routes.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# billing_routes.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import stripe
|
| 5 |
+
import json
|
| 6 |
+
from flask import Blueprint, request, jsonify, Response, session, current_app, url_for
|
| 7 |
+
from decorators import login_required
|
| 8 |
+
from auth_backend import get_user_by_id, get_plan_details, update_user_plan
|
| 9 |
+
import traceback
|
| 10 |
+
|
| 11 |
+
# Initialisation de Stripe avec la clé secrète
|
| 12 |
+
# La clé sera lue depuis les variables d'environnement (secrets HF)
|
| 13 |
+
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY")
|
| 14 |
+
|
| 15 |
+
# Création du Blueprint 'billing_bp'
|
| 16 |
+
billing_bp = Blueprint('billing_bp', __name__)
|
| 17 |
+
|
| 18 |
+
# --- Route API pour créer la session de paiement (Phase 3) ---
|
| 19 |
+
@billing_bp.route("/api/create-checkout-session", methods=["POST"])
|
| 20 |
+
@login_required
|
| 21 |
+
def create_checkout_session():
|
| 22 |
+
"""
|
| 23 |
+
Crée une session de checkout Stripe pour un plan donné.
|
| 24 |
+
"""
|
| 25 |
+
data = request.get_json()
|
| 26 |
+
# Le plan_id doit être l'ID complet (ex: 'standard_monthly' ou 'illimited_annual')
|
| 27 |
+
final_plan_id = data.get('plan_id')
|
| 28 |
+
user_id = session.get('user_id')
|
| 29 |
+
|
| 30 |
+
plan_details = get_plan_details(final_plan_id)
|
| 31 |
+
|
| 32 |
+
# Détermination de l'ID de prix Stripe (utilise monthly ou annual selon ce qui est défini)
|
| 33 |
+
price_id = plan_details.get('price_id_monthly') or plan_details.get('price_id_annual')
|
| 34 |
+
|
| 35 |
+
# Vérification de l'existence du plan et de l'ID de prix Stripe
|
| 36 |
+
if not plan_details or not price_id:
|
| 37 |
+
if final_plan_id == 'free':
|
| 38 |
+
return jsonify({"message": "Ce plan est gratuit, pas de session de paiement requise.", "url": url_for('user_bp.dashboard')}), 200
|
| 39 |
+
|
| 40 |
+
# Logique de sécurité/erreur si l'ID de prix est manquant pour un plan payant
|
| 41 |
+
current_app.logger.error(f"Erreur: ID de prix Stripe manquant pour le plan {final_plan_id}.")
|
| 42 |
+
return jsonify({"message": "Erreur de configuration du plan.", "status": "Error"}), 500
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# Création de la session de paiement Stripe
|
| 46 |
+
checkout_session = stripe.checkout.Session.create(
|
| 47 |
+
# Type de paiement pour les abonnements récurrents
|
| 48 |
+
mode='subscription',
|
| 49 |
+
# Les IDs de prix Stripe
|
| 50 |
+
line_items=[
|
| 51 |
+
{
|
| 52 |
+
'price': price_id,
|
| 53 |
+
'quantity': 1
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
# URLs de redirection après paiement/annulation
|
| 57 |
+
# _external=True est crucial pour que Stripe puisse rediriger correctement
|
| 58 |
+
success_url=url_for('user_bp.dashboard', payment='success', _external=True),
|
| 59 |
+
cancel_url=url_for('web_bp.checkout', plan=final_plan_id, payment='cancel', _external=True),
|
| 60 |
+
|
| 61 |
+
# Informations personnalisées CRITIQUES pour le Webhook
|
| 62 |
+
metadata={
|
| 63 |
+
'user_id': user_id,
|
| 64 |
+
'plan_id': final_plan_id, # L'ID de plan complet (e.g. 'standard_monthly')
|
| 65 |
+
},
|
| 66 |
+
# Laisse Stripe pré-remplir l'email du client (si on le souhaite)
|
| 67 |
+
# customer_email=get_user_by_id(user_id).get('email'),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Retourne l'URL de la session Stripe au frontend
|
| 71 |
+
return jsonify({'url': checkout_session.url}), 200
|
| 72 |
+
|
| 73 |
+
except stripe.error.StripeError as e:
|
| 74 |
+
current_app.logger.error(f"Erreur Stripe lors de la création de session: {e}")
|
| 75 |
+
return jsonify({"message": f"Une erreur Stripe est survenue: {e.user_message}", "status": "Error"}), 400
|
| 76 |
+
except Exception as e:
|
| 77 |
+
# Log toutes les autres erreurs pour le diagnostic
|
| 78 |
+
current_app.logger.error(f"Erreur inattendue lors de la création de session: {e}\n{traceback.format_exc()}")
|
| 79 |
+
return jsonify({"message": "Erreur interne du serveur lors de la création de la session.", "status": "Error"}), 500
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# --- Route Webhook Stripe (Phase 4) ---
|
| 83 |
+
@billing_bp.route('/webhook/stripe', methods=['POST'])
|
| 84 |
+
def stripe_webhook():
|
| 85 |
+
"""
|
| 86 |
+
Gère les événements envoyés par Stripe pour mettre à jour l'abonnement de l'utilisateur.
|
| 87 |
+
"""
|
| 88 |
+
payload = request.data
|
| 89 |
+
sig_header = request.headers.get('stripe-signature')
|
| 90 |
+
endpoint_secret = os.environ.get("STRIPE_WEBHOOK_SECRET")
|
| 91 |
+
|
| 92 |
+
# 1. Vérification que le secret est bien configuré
|
| 93 |
+
if not endpoint_secret:
|
| 94 |
+
current_app.logger.error("Erreur de configuration: STRIPE_WEBHOOK_SECRET est manquant.")
|
| 95 |
+
return jsonify({'message': 'Erreur de configuration serveur.'}), 500
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
# 2. Validation de la signature du Webhook
|
| 99 |
+
event = stripe.Webhook.construct_event(
|
| 100 |
+
payload, sig_header, endpoint_secret
|
| 101 |
+
)
|
| 102 |
+
except Exception as e:
|
| 103 |
+
current_app.logger.error(f"Erreur Webhook Stripe (Validation): {e}")
|
| 104 |
+
# Retourne 400 pour que Stripe sache qu'il ne doit pas retenter cet événement
|
| 105 |
+
return 'Invalid payload or signature', 400
|
| 106 |
+
|
| 107 |
+
# 3. Traitement des événements
|
| 108 |
+
if event['type'] == 'checkout.session.completed':
|
| 109 |
+
session_data = event['data']['object']
|
| 110 |
+
|
| 111 |
+
# Récupération des métadonnées CRITIQUES
|
| 112 |
+
user_id = session_data.get('metadata', {}).get('user_id')
|
| 113 |
+
plan_id = session_data.get('metadata', {}).get('plan_id') # L'ID de plan complet (e.g. 'standard_monthly')
|
| 114 |
+
subscription_id = session_data.get('subscription') # L'ID d'abonnement Stripe
|
| 115 |
+
|
| 116 |
+
if user_id and plan_id and subscription_id:
|
| 117 |
+
# Appel à la fonction de mise à jour de la base de données (Phase 4)
|
| 118 |
+
success = update_user_plan(user_id, plan_id, subscription_id)
|
| 119 |
+
|
| 120 |
+
if success:
|
| 121 |
+
current_app.logger.info(f"SUCCESS: Utilisateur {user_id} mis à jour au plan {plan_id} (Sub ID: {subscription_id})")
|
| 122 |
+
else:
|
| 123 |
+
# Le paiement a eu lieu, mais la BDD n'a pas été mise à jour: CRITIQUE
|
| 124 |
+
current_app.logger.error(f"FAILURE: Échec de la mise à jour Git pour l'utilisateur {user_id} après paiement Stripe. Plan: {plan_id}")
|
| 125 |
+
else:
|
| 126 |
+
# Manque d'infos critiques dans le webhook
|
| 127 |
+
current_app.logger.error(f"FAILURE: Données critiques manquantes dans le webhook. Session ID: {session_data.get('id')}. User ID: {user_id}. Plan ID: {plan_id}")
|
| 128 |
+
# Retourne 200 pour éviter une boucle de ré-envoi par Stripe
|
| 129 |
+
return jsonify({'message': 'Données utilisateur critiques manquantes dans la session Stripe.'}), 200
|
| 130 |
+
|
| 131 |
+
# Vous pouvez ajouter d'autres événements si nécessaire (ex: 'customer.subscription.deleted')
|
| 132 |
+
# elif event['type'] == 'customer.subscription.deleted':
|
| 133 |
+
# current_app.logger.info(f"INFO: Abonnement Stripe supprimé. ID de souscription: {event['data']['object'].get('id')}")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# Retourne une réponse pour accuser réception de l'événement (très important)
|
| 137 |
+
return jsonify({'status': 'success'}), 200
|
data_committer.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data_committer.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
import sys
|
| 7 |
+
import traceback
|
| 8 |
+
from os import path, listdir, remove, makedirs
|
| 9 |
+
from huggingface_hub import HfApi, CommitOperationAdd, CommitOperationDelete, hf_hub_download
|
| 10 |
+
from huggingface_hub.errors import HfHubHTTPError
|
| 11 |
+
from git_storage import get_authenticated_api, REPO_ID # Récupérer l'API et le REPO_ID
|
| 12 |
+
|
| 13 |
+
# Configuration
|
| 14 |
+
UNCOMMITTED_DATA_DIR = "uncommitted_data/"
|
| 15 |
+
COMMIT_INTERVAL_SECONDS = 10 # Fréquence de vérification du dossier de staging
|
| 16 |
+
MAX_RETRIES = 3 # Nombre de tentatives pour un commit échoué
|
| 17 |
+
|
| 18 |
+
def get_staged_files():
|
| 19 |
+
"""Retourne la liste complète des fichiers JSON dans le dossier de staging."""
|
| 20 |
+
try:
|
| 21 |
+
if not path.exists(UNCOMMITTED_DATA_DIR):
|
| 22 |
+
return []
|
| 23 |
+
|
| 24 |
+
return [f for f in listdir(UNCOMMITTED_DATA_DIR) if f.endswith('.json')]
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print(f"ERREUR: Échec de la lecture du répertoire de staging: {e}", file=sys.stderr)
|
| 27 |
+
return []
|
| 28 |
+
|
| 29 |
+
def process_staged_file(api: HfApi, filename: str):
|
| 30 |
+
"""Lit un fichier de staging, exécute le commit et supprime le fichier local."""
|
| 31 |
+
local_path = path.join(UNCOMMITTED_DATA_DIR, filename)
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
# 1. Lire le contenu du fichier de staging
|
| 35 |
+
with open(local_path, 'r', encoding='utf-8') as f:
|
| 36 |
+
payload = json.load(f)
|
| 37 |
+
|
| 38 |
+
repo_file_path = payload.get("repo_file_path")
|
| 39 |
+
commit_message = payload.get("commit_message")
|
| 40 |
+
data = payload.get("data")
|
| 41 |
+
|
| 42 |
+
if not repo_file_path or data is None:
|
| 43 |
+
print(f"ERREUR: Fichier de staging corrompu ou incomplet: {filename}. Suppression.", file=sys.stderr)
|
| 44 |
+
remove(local_path)
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
# 2. Préparer le fichier pour le commit (écrire dans un buffer/temp file)
|
| 48 |
+
data_to_commit = json.dumps(data, indent=4).encode('utf-8')
|
| 49 |
+
|
| 50 |
+
# 3. Exécuter le commit atomique d'UN SEUL fichier
|
| 51 |
+
# On utilise une liste d'opérations pour le commit
|
| 52 |
+
operations = [
|
| 53 |
+
CommitOperationAdd(
|
| 54 |
+
path_in_repo=repo_file_path,
|
| 55 |
+
path_or_fileobj=data_to_commit
|
| 56 |
+
)
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
print(f"INFO: Committing de {repo_file_path} avec message: {commit_message}")
|
| 60 |
+
|
| 61 |
+
# Appel API bloquant (assure la sérialisation)
|
| 62 |
+
api.create_commit(
|
| 63 |
+
repo_id=REPO_ID,
|
| 64 |
+
operations=operations,
|
| 65 |
+
commit_message=commit_message,
|
| 66 |
+
repo_type="dataset",
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
print(f"SUCCESS: Commit de {repo_file_path} réussi.")
|
| 70 |
+
|
| 71 |
+
# 4. Supprimer le fichier de staging local après le succès
|
| 72 |
+
remove(local_path)
|
| 73 |
+
print(f"INFO: Fichier local {filename} supprimé.")
|
| 74 |
+
|
| 75 |
+
except HfHubHTTPError as e:
|
| 76 |
+
print(f"ERREUR HF HUB lors du commit de {filename} vers {repo_file_path}: {e}", file=sys.stderr)
|
| 77 |
+
# Ne pas supprimer le fichier, il sera réessayé au prochain cycle
|
| 78 |
+
|
| 79 |
+
except Exception as e:
|
| 80 |
+
print(f"ERREUR INATTENDUE lors du traitement du fichier {filename}: {e}", file=sys.stderr)
|
| 81 |
+
traceback.print_exc()
|
| 82 |
+
# Supprimer si l'erreur n'est pas liée à Git (fichier corrompu, etc.)
|
| 83 |
+
if path.exists(local_path):
|
| 84 |
+
remove(local_path)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def data_committer_loop():
|
| 88 |
+
"""Boucle principale du processus de commit séquentiel."""
|
| 89 |
+
print("--- Processus Data Committer démarré ---")
|
| 90 |
+
try:
|
| 91 |
+
api = get_authenticated_api()
|
| 92 |
+
except ValueError as e:
|
| 93 |
+
print(f"ERREUR FATALE: {e}. Arrêt du committer.", file=sys.stderr)
|
| 94 |
+
return
|
| 95 |
+
|
| 96 |
+
while True:
|
| 97 |
+
try:
|
| 98 |
+
staged_files = get_staged_files()
|
| 99 |
+
if staged_files:
|
| 100 |
+
print(f"INFO: {len(staged_files)} fichiers en attente de commit. Début du traitement séquentiel.")
|
| 101 |
+
|
| 102 |
+
# Traiter UN PAR UN pour garantir la sérialisation
|
| 103 |
+
for filename in staged_files:
|
| 104 |
+
process_staged_file(api, filename)
|
| 105 |
+
|
| 106 |
+
else:
|
| 107 |
+
print("INFO: Aucun fichier en attente de commit.")
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"ERREUR MAJEURE dans la boucle principale du committer: {e}", file=sys.stderr)
|
| 111 |
+
traceback.print_exc()
|
| 112 |
+
|
| 113 |
+
# Pause avant la prochaine vérification
|
| 114 |
+
time.sleep(COMMIT_INTERVAL_SECONDS)
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
# Assurer que le dossier de staging existe au démarrage
|
| 118 |
+
if not path.exists(UNCOMMITTED_DATA_DIR):
|
| 119 |
+
makedirs(UNCOMMITTED_DATA_DIR)
|
| 120 |
+
data_committer_loop()
|
decorators.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# decorators.py (Contient maintenant la configuration du projet)
|
| 2 |
+
|
| 3 |
+
# --- Contenu de config.py ---
|
| 4 |
+
import os
|
| 5 |
+
from functools import wraps
|
| 6 |
+
from flask import session, redirect, url_for, flash, request, jsonify
|
| 7 |
+
|
| 8 |
+
# --- Configuration Stripe (Paiement) ---
|
| 9 |
+
# Clés à définir dans les secrets d'environnement Hugging Face Space
|
| 10 |
+
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
|
| 11 |
+
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET")
|
| 12 |
+
# Clé Publique (utilisée par le Frontend, mais stockée ici pour référence)
|
| 13 |
+
STRIPE_PUBLIC_KEY = os.environ.get("STRIPE_PUBLIC_KEY")
|
| 14 |
+
|
| 15 |
+
# --- Dictionnaire de Prix Central (Phase 1) ---
|
| 16 |
+
# Contient toutes les informations nécessaires pour le Frontend et le Backend.
|
| 17 |
+
# Les 'price_id' doivent correspondre aux IDs créés dans Stripe.
|
| 18 |
+
PLANS_CONFIG = {
|
| 19 |
+
# Plan Gratuit
|
| 20 |
+
"free": {
|
| 21 |
+
"title": "Gratuit",
|
| 22 |
+
"description": "500 appels à l'API de validation d'e-mail par mois. Idéal pour les tests.", # MODIFIÉ
|
| 23 |
+
"limit": 500,
|
| 24 |
+
"price_monthly": 0.0,
|
| 25 |
+
"price_annual": 0.0,
|
| 26 |
+
"price_id_monthly": None,
|
| 27 |
+
"price_id_annual": None,
|
| 28 |
+
"currency": "EUR"
|
| 29 |
+
},
|
| 30 |
+
# Plan Standard - Mensuel
|
| 31 |
+
"standard_monthly": {
|
| 32 |
+
"title": "Standard (Mensuel)",
|
| 33 |
+
"description": "10,000 appels à l'API de validation d'e-mail par mois. Paiement mensuel.",
|
| 34 |
+
"limit": 10000,
|
| 35 |
+
"price_monthly": 49.99,
|
| 36 |
+
"price_annual": 0.0,
|
| 37 |
+
"price_id_monthly": "price_1OxXXXXXXX", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 38 |
+
"price_id_annual": None,
|
| 39 |
+
"currency": "EUR"
|
| 40 |
+
},
|
| 41 |
+
# Plan Standard - Annuel
|
| 42 |
+
"standard_annual": {
|
| 43 |
+
"title": "Standard (Annuel)",
|
| 44 |
+
"description": "10,000 appels à l'API de validation d'e-mail par mois. Économisez 20% en payant à l'année.", # MODIFIÉ
|
| 45 |
+
"limit": 10000,
|
| 46 |
+
"price_monthly": 0.0,
|
| 47 |
+
"price_annual": 499.90,
|
| 48 |
+
"price_id_monthly": None,
|
| 49 |
+
"price_id_annual": "price_1OyAAAAAAA", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 50 |
+
"currency": "EUR"
|
| 51 |
+
},
|
| 52 |
+
# Plan Illimité - Mensuel
|
| 53 |
+
"illimited_monthly": {
|
| 54 |
+
"title": "Illimité (Mensuel)",
|
| 55 |
+
"description": "Nombre d'appels illimité à l'API de validation d'e-mail. Paiement mensuel.", # MODIFIÉ
|
| 56 |
+
"limit": float('inf'),
|
| 57 |
+
"price_monthly": 99.99,
|
| 58 |
+
"price_annual": 0.0,
|
| 59 |
+
"price_id_monthly": "price_1OzBBBBBBB", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 60 |
+
"price_id_annual": None,
|
| 61 |
+
"currency": "EUR"
|
| 62 |
+
},
|
| 63 |
+
# Plan Illimité - Annuel
|
| 64 |
+
"illimited_annual": {
|
| 65 |
+
"title": "Illimité (Annuel)",
|
| 66 |
+
"description": "Nombre d'appels illimité à l'API de validation d'e-mail. Économisez 20% en payant à l'année.", # MODIFIÉ
|
| 67 |
+
"limit": float('inf'),
|
| 68 |
+
"price_monthly": 0.0,
|
| 69 |
+
"price_annual": 999.90,
|
| 70 |
+
"price_id_monthly": None,
|
| 71 |
+
"price_id_annual": "price_1OzCCCCCCC", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 72 |
+
"currency": "EUR"
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
# --- Contenu original de decorators.py (Imports ajustés en haut) ---
|
| 77 |
+
|
| 78 |
+
# Import nécessaire pour le décorateur API (Importation paresseuse pour éviter les problèmes d'importation circulaire)
|
| 79 |
+
import auth_backend
|
| 80 |
+
|
| 81 |
+
def login_required(f):
|
| 82 |
+
"""
|
| 83 |
+
Décorateur pour les routes nécessitant une connexion (Web UI).
|
| 84 |
+
"""
|
| 85 |
+
@wraps(f)
|
| 86 |
+
def decorated_function(*args, **kwargs):
|
| 87 |
+
# Vérifie si l'ID utilisateur est dans la session
|
| 88 |
+
if session.get('user_id') is None:
|
| 89 |
+
flash("Vous devez être connecté pour accéder à cette page.", "error")
|
| 90 |
+
# Rediriger vers la route de connexion (qui est dans user_bp)
|
| 91 |
+
return redirect(url_for('user_bp.connexion'))
|
| 92 |
+
return f(*args, **kwargs)
|
| 93 |
+
return decorated_function
|
| 94 |
+
|
| 95 |
+
def api_key_required(f):
|
| 96 |
+
"""
|
| 97 |
+
Décorateur pour les routes d'API nécessitant une clé API valide.
|
| 98 |
+
Récupère la clé API de l'en-tête 'X-API-Key' ou du paramètre de requête 'api_key'.
|
| 99 |
+
Injecte les données de l'utilisateur principal (client) dans la fonction décorée.
|
| 100 |
+
"""
|
| 101 |
+
@wraps(f)
|
| 102 |
+
def decorated_function(*args, **kwargs):
|
| 103 |
+
# 1. Récupérer la clé depuis l'en-tête ou les paramètres de requête
|
| 104 |
+
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
|
| 105 |
+
|
| 106 |
+
if not api_key:
|
| 107 |
+
return jsonify({
|
| 108 |
+
"message": "Clé API manquante. Fournissez 'X-API-Key' en en-tête ou 'api_key' en paramètre.",
|
| 109 |
+
"status": "Unauthorized"
|
| 110 |
+
}), 401
|
| 111 |
+
|
| 112 |
+
# 2. Rechercher l'utilisateur associé à la clé
|
| 113 |
+
# Utilisation de la fonction load_user_by_api_key du module auth_backend
|
| 114 |
+
client_user = auth_backend.load_user_by_api_key(api_key)
|
| 115 |
+
|
| 116 |
+
if not client_user:
|
| 117 |
+
return jsonify({
|
| 118 |
+
"message": "Clé API invalide ou inactive.",
|
| 119 |
+
"status": "Unauthorized"
|
| 120 |
+
}), 401
|
| 121 |
+
|
| 122 |
+
# 3. Injecter l'objet utilisateur pour les décorateurs suivants (comme quota_required) et la fonction cible
|
| 123 |
+
kwargs['client_user'] = client_user
|
| 124 |
+
|
| 125 |
+
return f(*args, **kwargs)
|
| 126 |
+
return decorated_function
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def quota_required(f):
|
| 130 |
+
"""
|
| 131 |
+
Décorateur pour les routes API nécessitant un quota d'appels positif.
|
| 132 |
+
Doit être placé APRÈS @api_key_required pour que client_user soit déjà injecté.
|
| 133 |
+
"""
|
| 134 |
+
@wraps(f)
|
| 135 |
+
def decorated_function(*args, **kwargs):
|
| 136 |
+
client_user = kwargs.get('client_user')
|
| 137 |
+
|
| 138 |
+
# Le décorateur @api_key_required garantit la présence de client_user
|
| 139 |
+
if not client_user:
|
| 140 |
+
return jsonify({
|
| 141 |
+
"message": "Erreur interne: Impossible de vérifier le quota (utilisateur manquant).",
|
| 142 |
+
"status": "Internal Error"
|
| 143 |
+
}), 500
|
| 144 |
+
|
| 145 |
+
# Vérifier si le plan est illimité (float('inf'))
|
| 146 |
+
if client_user['plan'].startswith('illimited'):
|
| 147 |
+
return f(*args, **kwargs)
|
| 148 |
+
|
| 149 |
+
# Vérifier le quota standard
|
| 150 |
+
current_quota = client_user.get('quota', 0)
|
| 151 |
+
|
| 152 |
+
if current_quota > 0:
|
| 153 |
+
return f(*args, **kwargs)
|
| 154 |
+
else:
|
| 155 |
+
# Plan non-illimité et quota épuisé
|
| 156 |
+
return jsonify({
|
| 157 |
+
"message": "Quota d'appels API épuisé. Veuillez mettre à jour votre plan.",
|
| 158 |
+
"status": "Forbidden",
|
| 159 |
+
"current_quota": current_quota
|
| 160 |
+
}), 403
|
| 161 |
+
|
| 162 |
+
return decorated_function
|
email_validator.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# email_validator.py
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import dns.resolver
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
|
| 7 |
+
# NOTE: Pour que dns.resolver fonctionne, vous devez vous assurer que la librairie 'dnspython'
|
| 8 |
+
# est installée dans votre environnement (par exemple, dans requirements.txt).
|
| 9 |
+
|
| 10 |
+
# Liste statique de quelques domaines jetables courants (exemple)
|
| 11 |
+
DISPOSABLE_DOMAINS = {
|
| 12 |
+
"mailinator.com",
|
| 13 |
+
"yopmail.com",
|
| 14 |
+
"temp-mail.org",
|
| 15 |
+
"trash-mail.com",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
# Regex pour la vérification de la syntaxe de base d'un e-mail
|
| 19 |
+
# (Bon compromis pour une validation web/API)
|
| 20 |
+
EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def check_syntax(email: str) -> bool:
|
| 24 |
+
"""Vérifie la syntaxe de l'e-mail à l'aide d'une expression régulière."""
|
| 25 |
+
return bool(EMAIL_REGEX.fullmatch(email))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def check_mx_record(domain: str) -> bool:
|
| 29 |
+
"""
|
| 30 |
+
Vérifie l'existence d'un enregistrement MX (Mail Exchange) pour le domaine.
|
| 31 |
+
Indique si le domaine est configuré pour recevoir des e-mails.
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
# Tente de résoudre l'enregistrement MX
|
| 35 |
+
answers = dns.resolver.resolve(domain, 'MX')
|
| 36 |
+
# S'il y a des réponses, le domaine peut recevoir des e-mails
|
| 37 |
+
return len(answers) > 0
|
| 38 |
+
except dns.resolver.NXDOMAIN:
|
| 39 |
+
# Le domaine n'existe pas
|
| 40 |
+
return False
|
| 41 |
+
except dns.resolver.NoAnswer:
|
| 42 |
+
# Le domaine existe, mais n'a pas d'enregistrement MX
|
| 43 |
+
return False
|
| 44 |
+
except Exception as e:
|
| 45 |
+
# Autres erreurs (timeout, etc.) - On assume un échec pour la sécurité
|
| 46 |
+
print(f"Erreur DNS lors de la vérification MX pour {domain}: {e}")
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def is_disposable(domain: str) -> bool:
|
| 51 |
+
"""Vérifie si le domaine est dans la liste des domaines jetables."""
|
| 52 |
+
return domain.lower() in DISPOSABLE_DOMAINS
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def validate_email(email: str) -> Dict[str, Any]:
|
| 56 |
+
"""
|
| 57 |
+
Moteur principal de validation d'e-mail.
|
| 58 |
+
"""
|
| 59 |
+
email = email.strip()
|
| 60 |
+
|
| 61 |
+
# 1. Vérification de la syntaxe
|
| 62 |
+
if not check_syntax(email):
|
| 63 |
+
return {
|
| 64 |
+
"is_valid": False,
|
| 65 |
+
"reason": "Syntaxe invalide.",
|
| 66 |
+
"details": {"syntax_valid": False, "domain_valid": False, "is_disposable": False}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
# Séparer le nom d'utilisateur et le domaine
|
| 70 |
+
try:
|
| 71 |
+
_, domain = email.split('@', 1)
|
| 72 |
+
except ValueError:
|
| 73 |
+
return {
|
| 74 |
+
"is_valid": False,
|
| 75 |
+
"reason": "Format d'e-mail incorrect.",
|
| 76 |
+
"details": {"syntax_valid": False, "domain_valid": False, "is_disposable": False}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
# 2. Vérification des domaines jetables
|
| 80 |
+
is_disposable_domain = is_disposable(domain)
|
| 81 |
+
if is_disposable_domain:
|
| 82 |
+
return {
|
| 83 |
+
"is_valid": False,
|
| 84 |
+
"reason": "Domaine jetable détecté.",
|
| 85 |
+
"details": {"syntax_valid": True, "domain_valid": True, "is_disposable": True}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
# 3. Vérification de l'enregistrement MX
|
| 89 |
+
is_domain_valid = check_mx_record(domain)
|
| 90 |
+
|
| 91 |
+
if not is_domain_valid:
|
| 92 |
+
return {
|
| 93 |
+
"is_valid": False,
|
| 94 |
+
"reason": "Domaine invalide (pas d'enregistrement MX trouvé).",
|
| 95 |
+
"details": {"syntax_valid": True, "domain_valid": False, "is_disposable": False}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# 4. Succès (e-mail valide)
|
| 99 |
+
return {
|
| 100 |
+
"is_valid": True,
|
| 101 |
+
"reason": "E-mail valide.",
|
| 102 |
+
"details": {"syntax_valid": True, "domain_valid": True, "is_disposable": False}
|
| 103 |
+
}
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# entrypoint.sh
|
| 3 |
+
|
| 4 |
+
# Afficher les commandes exécutées
|
| 5 |
+
set -e
|
| 6 |
+
|
| 7 |
+
echo "--- Démarrage de l'Application Gunicorn et du Data Committer ---"
|
| 8 |
+
|
| 9 |
+
# Définir le port par défaut de Hugging Face si $PORT est vide
|
| 10 |
+
# Utiliser 7860 qui est le port par défaut de Hugging Face Spaces
|
| 11 |
+
export APP_PORT=${PORT:-7860}
|
| 12 |
+
|
| 13 |
+
# 1. Démarrer le serveur Flask/Gunicorn en arrière-plan
|
| 14 |
+
# Le paramètre $APP_PORT est maintenant garanti d'avoir une valeur
|
| 15 |
+
echo "Démarrage du serveur Gunicorn sur le port $APP_PORT..."
|
| 16 |
+
gunicorn --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS app:app -b 0.0.0.0:$APP_PORT &
|
| 17 |
+
|
| 18 |
+
# Capturer le PID du processus Gunicorn
|
| 19 |
+
GUNICORN_PID=$!
|
| 20 |
+
echo "Gunicorn démarré avec PID: $GUNICORN_PID"
|
| 21 |
+
|
| 22 |
+
# 2. Démarrer le committer en arrière-plan
|
| 23 |
+
echo "Démarrage du Data Committer..."
|
| 24 |
+
python data_committer.py &
|
| 25 |
+
|
| 26 |
+
# Capturer le PID du processus Committer
|
| 27 |
+
COMMITTER_PID=$!
|
| 28 |
+
echo "Data Committer démarré avec PID: $COMMITTER_PID"
|
| 29 |
+
|
| 30 |
+
# 3. Attendre que l'un des processus en arrière-plan se termine
|
| 31 |
+
# Si l'un des processus meurt, le container doit s'arrêter pour éviter un état zombie.
|
| 32 |
+
wait -n $GUNICORN_PID $COMMITTER_PID
|
git_storage.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# git_storage.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import traceback
|
| 5 |
+
from huggingface_hub import HfApi, CommitOperationAdd, hf_hub_download
|
| 6 |
+
from os import environ, path, makedirs
|
| 7 |
+
from huggingface_hub.errors import HfHubHTTPError
|
| 8 |
+
import sys
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import io
|
| 11 |
+
import uuid # Ajout pour la génération de noms de fichiers uniques
|
| 12 |
+
|
| 13 |
+
# Configuration du dépôt
|
| 14 |
+
REPO_ID = "ernestmindres/database_mailix" # Assurez-vous que c'est votre Repo ID
|
| 15 |
+
|
| 16 |
+
# --- NOUVELLE STRUCTURE DE DONNÉES ISOLÉE ET DE STAGING ---
|
| 17 |
+
# Dossiers de destination dans le Dataset après commit
|
| 18 |
+
PRIMARY_USERS_DIR = "primary_users/"
|
| 19 |
+
# END_USERS_DIR est supprimé (logique utilisateur final)
|
| 20 |
+
|
| 21 |
+
# Dossier de staging local pour les fichiers non committés
|
| 22 |
+
UNCOMMITTED_DATA_DIR = "uncommitted_data/"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# --- Fonctions Utilitaires Générales ---
|
| 26 |
+
def get_authenticated_api():
|
| 27 |
+
"""Vérifie le jeton et retourne un objet HfApi."""
|
| 28 |
+
current_token = environ.get("HF_TOKEN")
|
| 29 |
+
if not current_token:
|
| 30 |
+
raise ValueError("Erreur d'authentification: Le secret HF_TOKEN est manquant ou non chargé.")
|
| 31 |
+
return HfApi(token=current_token)
|
| 32 |
+
|
| 33 |
+
def load_file_from_repo(repo_file_path: str, default_content=None) -> dict:
|
| 34 |
+
"""Tente de télécharger et lire un fichier JSON à partir du Dataset Hugging Face."""
|
| 35 |
+
local_filename = path.basename(repo_file_path)
|
| 36 |
+
try:
|
| 37 |
+
# Télécharger le fichier de manière paresseuse (lazy)
|
| 38 |
+
download_path = hf_hub_download(
|
| 39 |
+
repo_id=REPO_ID,
|
| 40 |
+
filename=repo_file_path,
|
| 41 |
+
repo_type="dataset"
|
| 42 |
+
)
|
| 43 |
+
with open(download_path, 'r', encoding='utf-8') as f:
|
| 44 |
+
return json.load(f)
|
| 45 |
+
except HfHubHTTPError as e:
|
| 46 |
+
# 404 est normal pour les nouveaux fichiers/utilisateurs
|
| 47 |
+
if '404' in str(e):
|
| 48 |
+
return default_content or {}
|
| 49 |
+
print(f"ERREUR HTTP lors du chargement de {repo_file_path}: {e}", file=sys.stderr)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"ERREUR lors du chargement de {repo_file_path}: {e}", file=sys.stderr)
|
| 52 |
+
traceback.print_exc(file=sys.stderr)
|
| 53 |
+
return default_content or {}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _save_to_staging(repo_file_path: str, data: dict, commit_message: str):
|
| 57 |
+
"""
|
| 58 |
+
Écrit un fichier JSON en local dans le dossier de staging (uncommitted_data/)
|
| 59 |
+
et inclut les métadonnées de commit.
|
| 60 |
+
"""
|
| 61 |
+
try:
|
| 62 |
+
# Assurer que le dossier de staging existe
|
| 63 |
+
if not path.exists(UNCOMMITTED_DATA_DIR):
|
| 64 |
+
makedirs(UNCOMMITTED_DATA_DIR)
|
| 65 |
+
|
| 66 |
+
# Créer un nom de fichier unique pour le staging
|
| 67 |
+
# Format: {timestamp}_{uuid}.json (pour éviter les collisions)
|
| 68 |
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
| 69 |
+
unique_filename = f"{timestamp}_{uuid.uuid4().hex}.json"
|
| 70 |
+
local_staging_path = path.join(UNCOMMITTED_DATA_DIR, unique_filename)
|
| 71 |
+
|
| 72 |
+
# Les métadonnées de commit sont stockées avec les données
|
| 73 |
+
staging_data = {
|
| 74 |
+
"repo_file_path": repo_file_path,
|
| 75 |
+
"commit_message": commit_message,
|
| 76 |
+
"content": data
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
with open(local_staging_path, 'w', encoding='utf-8') as f:
|
| 80 |
+
json.dump(staging_data, f, indent=4)
|
| 81 |
+
|
| 82 |
+
print(f"INFO: Fichier mis en staging pour commit: {repo_file_path}")
|
| 83 |
+
return True
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"ERREUR lors de la mise en staging de {repo_file_path}: {e}", file=sys.stderr)
|
| 87 |
+
traceback.print_exc(file=sys.stderr)
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# --- Fonctions Spécifiques aux Utilisateurs Principaux (Clients) ---
|
| 92 |
+
|
| 93 |
+
def _get_primary_user_file_path(user_id):
|
| 94 |
+
"""Retourne le chemin complet du fichier JSON d'un utilisateur principal dans le Dataset."""
|
| 95 |
+
# Chemin: primary_users/{user_id}.json
|
| 96 |
+
return path.join(PRIMARY_USERS_DIR, f"{user_id}.json")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def load_primary_user_data(user_id: str) -> dict:
|
| 100 |
+
"""Charge les données d'un seul utilisateur principal à partir de son fichier dédié."""
|
| 101 |
+
file_path = _get_primary_user_file_path(user_id)
|
| 102 |
+
return load_file_from_repo(file_path, default_content=None)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def save_primary_user_data(user_id: str, user_data: dict, commit_message: str = "feat: Mise à jour utilisateur principal via API/Web") -> bool:
|
| 106 |
+
"""Enregistre les données d'un utilisateur principal en staging pour un commit ultérieur."""
|
| 107 |
+
repo_file_path = _get_primary_user_file_path(user_id)
|
| 108 |
+
return _save_to_staging(repo_file_path, user_data, commit_message)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# --- Fonction Temporairement Conservée (Lecture Lente) ---
|
| 112 |
+
# Nécessaire pour les logins Web et la recherche de clé API sans index/cache
|
| 113 |
+
def load_users_data() -> dict:
|
| 114 |
+
"""
|
| 115 |
+
Charge l'ancienne structure monolithique pour la recherche d'API Key/Login Web.
|
| 116 |
+
À migrer vers un index/cache de tous les fichiers primary_users/{user_id}.json.
|
| 117 |
+
"""
|
| 118 |
+
# Ce code est conservé pour la rétrocompatibilité (lecture lente)
|
| 119 |
+
file_path = "users.json"
|
| 120 |
+
data = load_file_from_repo(file_path, default_content={"users": {}})
|
| 121 |
+
# Avertissement dans la console
|
| 122 |
+
if not data:
|
| 123 |
+
print("ATTENTION: Chargement de l'ancien fichier users.json (lecture lente). Migrer vers index/cache.", file=sys.stderr)
|
| 124 |
+
return data
|
| 125 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# requirements.txt
|
| 2 |
+
flask
|
| 3 |
+
huggingface-hub
|
| 4 |
+
bcrypt
|
| 5 |
+
requests
|
| 6 |
+
gunicorn
|
| 7 |
+
Flask-CORS
|
| 8 |
+
python-dotenv
|
| 9 |
+
stripe
|
| 10 |
+
dnspython
|
user_routes.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# user_routes.py (Contient maintenant toutes les routes web/UI)
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify
|
| 4 |
+
from auth_backend import (
|
| 5 |
+
register_user,
|
| 6 |
+
login_user,
|
| 7 |
+
reset_password_via_security_question,
|
| 8 |
+
get_user_by_id,
|
| 9 |
+
get_plan_details, # <--- AJOUT pour les routes web publiques (ex: /checkout)
|
| 10 |
+
)
|
| 11 |
+
from decorators import login_required
|
| 12 |
+
|
| 13 |
+
# Création du Blueprint 'user_bp' (Routes utilisateur: inscription, connexion, dashboard, etc.)
|
| 14 |
+
user_bp = Blueprint('user_bp', __name__)
|
| 15 |
+
|
| 16 |
+
@user_bp.route("/inscription", methods=['GET', 'POST'])
|
| 17 |
+
def inscription():
|
| 18 |
+
# ... (Contenu inchangé des routes /inscription, /connexion, /deconnexion, /mot-de-passe-oublie)
|
| 19 |
+
if request.method == 'POST':
|
| 20 |
+
# Traitement du formulaire d'inscription
|
| 21 |
+
username = request.form.get("username")
|
| 22 |
+
email = request.form.get("email")
|
| 23 |
+
password = request.form.get("password")
|
| 24 |
+
confirm_password = request.form.get("confirm_password")
|
| 25 |
+
security_question = request.form.get("security_question")
|
| 26 |
+
security_answer = request.form.get("security_answer")
|
| 27 |
+
|
| 28 |
+
# Appel à register_user mis à jour pour inclure la génération de clé API
|
| 29 |
+
user_id, message = register_user(username, email, password, confirm_password, security_question, security_answer)
|
| 30 |
+
|
| 31 |
+
if user_id:
|
| 32 |
+
flash(message, "success")
|
| 33 |
+
# Rediriger vers la page de connexion après l'inscription
|
| 34 |
+
return redirect(url_for('user_bp.connexion'))
|
| 35 |
+
else:
|
| 36 |
+
flash(message, "error")
|
| 37 |
+
return render_template("inscription.html", username=username, email=email, security_question=security_question, security_answer=security_answer)
|
| 38 |
+
|
| 39 |
+
return render_template("inscription.html")
|
| 40 |
+
|
| 41 |
+
@user_bp.route("/connexion", methods=['GET', 'POST'])
|
| 42 |
+
def connexion():
|
| 43 |
+
if request.method == 'POST':
|
| 44 |
+
username_or_email = request.form.get("username_or_email")
|
| 45 |
+
password = request.form.get("password")
|
| 46 |
+
|
| 47 |
+
user_id, message = login_user(username_or_email, password)
|
| 48 |
+
|
| 49 |
+
if user_id:
|
| 50 |
+
# Stocker l'ID utilisateur dans la session
|
| 51 |
+
session['user_id'] = user_id
|
| 52 |
+
flash(message, "success")
|
| 53 |
+
return redirect(url_for('user_bp.dashboard'))
|
| 54 |
+
else:
|
| 55 |
+
flash(message, "error")
|
| 56 |
+
return render_template("connexion.html", username_or_email=username_or_email)
|
| 57 |
+
|
| 58 |
+
return render_template("connexion.html")
|
| 59 |
+
|
| 60 |
+
@user_bp.route("/deconnexion")
|
| 61 |
+
def deconnexion():
|
| 62 |
+
# Nettoyer la session (supprimer l'ID utilisateur)
|
| 63 |
+
session.pop('user_id', None)
|
| 64 |
+
flash("Vous avez été déconnecté avec succès.", "success")
|
| 65 |
+
# Rediriger vers la page d'accueil ou de connexion
|
| 66 |
+
return redirect(url_for('web_bp.index')) # Assurez-vous d'utiliser le nom du Blueprint public
|
| 67 |
+
|
| 68 |
+
@user_bp.route("/mot-de-passe-oublie", methods=['GET', 'POST'])
|
| 69 |
+
def mot_de_passe_oublie():
|
| 70 |
+
if request.method == 'POST':
|
| 71 |
+
username_or_email = request.form.get("username_or_email")
|
| 72 |
+
new_password = request.form.get("new_password")
|
| 73 |
+
confirm_password = request.form.get("confirm_password")
|
| 74 |
+
security_answer = request.form.get("security_answer")
|
| 75 |
+
|
| 76 |
+
success, message = reset_password_via_security_question(username_or_email, new_password, confirm_password, security_answer)
|
| 77 |
+
|
| 78 |
+
if success:
|
| 79 |
+
flash(message, "success")
|
| 80 |
+
return redirect(url_for('user_bp.connexion'))
|
| 81 |
+
else:
|
| 82 |
+
flash(message, "error")
|
| 83 |
+
return render_template("mot_de_passe_oublie.html", username_or_email=username_or_email)
|
| 84 |
+
|
| 85 |
+
return render_template("mot_de_passe_oublie.html")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# --- Routes du Dashboard ---
|
| 89 |
+
|
| 90 |
+
@user_bp.route("/dashboard")
|
| 91 |
+
@login_required
|
| 92 |
+
def dashboard():
|
| 93 |
+
"""
|
| 94 |
+
Page du tableau de bord. Gère le message de succès de paiement (Phase 4).
|
| 95 |
+
"""
|
| 96 |
+
user = get_user_by_id(session.get('user_id'))
|
| 97 |
+
|
| 98 |
+
# Logique pour le succès de paiement
|
| 99 |
+
payment_status = request.args.get('payment')
|
| 100 |
+
|
| 101 |
+
if payment_status == 'success':
|
| 102 |
+
# On flashe un message pour l'afficher via Jinja dans le dashboard.html
|
| 103 |
+
flash("Félicitations ! Votre abonnement a été activé avec succès.", "success")
|
| 104 |
+
# Redirection pour nettoyer l'URL du paramètre de paiement
|
| 105 |
+
return redirect(url_for('user_bp.dashboard'))
|
| 106 |
+
|
| 107 |
+
return render_template("dashboard.html", user=user)
|
| 108 |
+
|
| 109 |
+
@user_bp.route("/profile")
|
| 110 |
+
@login_required
|
| 111 |
+
def profile():
|
| 112 |
+
user = get_user_by_id(session.get('user_id'))
|
| 113 |
+
return render_template("profile.html", user=user)
|
| 114 |
+
|
| 115 |
+
@user_bp.route("/api-key-logs")
|
| 116 |
+
@login_required
|
| 117 |
+
def api_key_logs():
|
| 118 |
+
user = get_user_by_id(session.get('user_id'))
|
| 119 |
+
# NOTE: L'implémentation de la lecture des logs d'API n'est pas détaillée ici,
|
| 120 |
+
# mais cette route sert de point d'entrée pour l'interface.
|
| 121 |
+
return render_template("api_logs.html", user=user)
|
| 122 |
+
|
| 123 |
+
@user_bp.route("/api-key-management")
|
| 124 |
+
@login_required
|
| 125 |
+
def api_key_management():
|
| 126 |
+
"""
|
| 127 |
+
Page de gestion de la clé API.
|
| 128 |
+
"""
|
| 129 |
+
user = get_user_by_id(session.get('user_id'))
|
| 130 |
+
# NOTE: Les fonctions de révocation/régénération de clé ne sont pas détaillées ici,
|
| 131 |
+
# mais la clé peut être affichée.
|
| 132 |
+
return render_template("api_key.html", user=user)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ----------------------------------------------------------------------
|
| 136 |
+
# --- ROUTES WEB PUBLIQUES (Contenu de web_routes.py) ---
|
| 137 |
+
# ----------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
# Création du Blueprint 'web_bp' pour les routes publiques
|
| 140 |
+
web_bp = Blueprint('web_bp', __name__)
|
| 141 |
+
|
| 142 |
+
@web_bp.route("/")
|
| 143 |
+
def index():
|
| 144 |
+
"""Page d'accueil."""
|
| 145 |
+
return render_template("index.html")
|
| 146 |
+
|
| 147 |
+
@web_bp.route("/a-propos")
|
| 148 |
+
def a_propos():
|
| 149 |
+
"""Page À Propos."""
|
| 150 |
+
return render_template("a_propos.html")
|
| 151 |
+
|
| 152 |
+
@web_bp.route("/documentation")
|
| 153 |
+
def documentation():
|
| 154 |
+
"""Page Documentation."""
|
| 155 |
+
return render_template("documentation.html")
|
| 156 |
+
|
| 157 |
+
@web_bp.route("/tarifs")
|
| 158 |
+
def tarifs():
|
| 159 |
+
"""Page Tarifs."""
|
| 160 |
+
return render_template("tarifs.html")
|
| 161 |
+
|
| 162 |
+
@web_bp.route("/checkout")
|
| 163 |
+
def checkout():
|
| 164 |
+
"""
|
| 165 |
+
Page de paiement. Récupère le plan ID de l'URL pour l'afficher.
|
| 166 |
+
"""
|
| 167 |
+
# Récupère 'plan' du paramètre d'URL /checkout?plan=...
|
| 168 |
+
plan_id = request.args.get('plan')
|
| 169 |
+
plan_details = get_plan_details(plan_id)
|
| 170 |
+
|
| 171 |
+
# Si le plan n'existe pas, ou si le plan est 'free', rediriger vers la page des tarifs
|
| 172 |
+
if not plan_details or plan_id == 'free':
|
| 173 |
+
# Note: L'appel url_for utilise 'web_bp.tarifs' car cette fonction est dans le blueprint web_bp
|
| 174 |
+
return redirect(url_for('web_bp.tarifs'))
|
| 175 |
+
|
| 176 |
+
return render_template("checkout.html", plan_id=plan_id, plan=plan_details)
|
| 177 |
+
|
| 178 |
+
@web_bp.route("/support")
|
| 179 |
+
def support():
|
| 180 |
+
"""Page Support."""
|
| 181 |
+
return render_template("support.html")
|
| 182 |
+
|
| 183 |
+
@web_bp.route("/mentions-legales")
|
| 184 |
+
def mentions_legales():
|
| 185 |
+
"""Page Mentions Légales."""
|
| 186 |
+
return render_template("mentions_legales.html")
|
| 187 |
+
|
| 188 |
+
@web_bp.route("/conditions-utilisation")
|
| 189 |
+
def conditions_utilisation():
|
| 190 |
+
"""Page Conditions d'utilisation."""
|
| 191 |
+
return render_template("conditions_utilisation.html")
|
| 192 |
+
|
| 193 |
+
@web_bp.route("/politique-confidentialite")
|
| 194 |
+
def politique_confidentialite():
|
| 195 |
+
"""Page Politique de Confidentialité."""
|
| 196 |
+
return render_template("politique_confidentialite.html")
|