ernestmindres commited on
Commit
6d2815b
·
verified ·
1 Parent(s): 9553894

Upload 12 files

Browse files
Files changed (12) hide show
  1. .dockerignore +20 -0
  2. Dockerfile +57 -0
  3. app.py +135 -0
  4. auth_backend.py +290 -0
  5. billing_routes.py +137 -0
  6. data_committer.py +120 -0
  7. decorators.py +162 -0
  8. email_validator.py +103 -0
  9. entrypoint.sh +32 -0
  10. git_storage.py +125 -0
  11. requirements.txt +10 -0
  12. 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")