ernestmindres commited on
Commit
883d8a2
·
verified ·
1 Parent(s): 5cd3cfa

Upload 11 files

Browse files
Files changed (11) hide show
  1. Dockerfile +57 -0
  2. app.py +458 -0
  3. auth_backend.py +262 -0
  4. baserow_storage.py +287 -0
  5. billing_routes.py +137 -0
  6. config.py +113 -0
  7. decorators.py +57 -0
  8. entrypoint.sh +15 -0
  9. requirements.txt +9 -0
  10. user_routes.py +114 -0
  11. web_routes.py +62 -0
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 config.py .
30
+ COPY user_routes.py .
31
+ COPY web_routes.py .
32
+ COPY decorators.py .
33
+ COPY billing_routes.py .
34
+ COPY auth_backend.py .
35
+ COPY baserow_storage.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,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Nouvelles fonctions pour la gestion des utilisateurs finaux
22
+ register_end_user,
23
+ login_end_user,
24
+ reset_end_user_password_by_client
25
+ )
26
+ from decorators import api_key_required # <-- NOUVEL IMPORT
27
+
28
+ # Importation des Blueprints
29
+ from web_routes import web_bp
30
+ from user_routes import user_bp
31
+ from billing_routes import billing_bp
32
+
33
+
34
+ # Valeur par défaut pour la taille max de contenu
35
+ DEFAULT_MAX_CONTENT_LENGTH = 16 * 1024 * 1024
36
+
37
+
38
+ # --- Initialisation de l'Application Flask ---
39
+ app = Flask(__name__)
40
+
41
+ # Configuration
42
+ app.secret_key = os.environ.get("FLASK_SECRET_KEY", "super_secret_dev_key")
43
+ app.config['MAX_CONTENT_LENGTH'] = DEFAULT_MAX_CONTENT_LENGTH
44
+
45
+ # Permettre les requêtes cross-origin (CORS)
46
+ CORS(app, supports_credentials=True, origins="*", allow_headers=["Content-Type", "X-User-API-Key"])
47
+
48
+
49
+ # Permettre les requêtes cross-origin pour l'API
50
+ CORS(app)
51
+
52
+ # --- Enregistrement des Blueprints (Nouveau) ---
53
+ app.register_blueprint(web_bp)
54
+ app.register_blueprint(user_bp)
55
+ app.register_blueprint(billing_bp) # <-- NOUVEL ENREGISTREMENT
56
+
57
+
58
+ # --- Décorateurs d'Authentification (Conservés) ---
59
+ def login_required(f):
60
+ @wraps(f)
61
+ def decorated_function(*args, **kwargs):
62
+ if 'user_id' not in session:
63
+ # Redirection HTTP 302 vers la page de connexion pour les requêtes non-API
64
+ if not request.path.startswith('/api/'):
65
+ from flask import redirect, url_for
66
+ return redirect(url_for('user_bp.connexion'))
67
+ # Réponse JSON pour les API
68
+ return jsonify({"status": "Error", "message": "Accès non autorisé. Veuillez vous connecter.", "code": "AUTH_REQUIRED"}), 401
69
+ return f(*args, **kwargs)
70
+ return decorated_function
71
+
72
+ def user_api_key_required(f):
73
+ """Décorateur pour exiger la clé API dynamique via URL ('api_key') ou en-tête ('X-User-API-Key')."""
74
+ @wraps(f)
75
+ def decorated_function(*args, **kwargs):
76
+ api_key = request.args.get('api_key') or request.headers.get('X-User-API-Key')
77
+
78
+ if not api_key:
79
+ return jsonify({
80
+ "status": "Error",
81
+ "message": "Clé API utilisateur ('api_key' dans l'URL ou 'X-User-API-Key' dans l'en-tête) manquante.",
82
+ "code": "USER_API_KEY_MISSING"
83
+ }), 403
84
+
85
+ user = get_user_by_api_key(api_key)
86
+
87
+ if not user:
88
+ return jsonify({
89
+ "status": "Error",
90
+ "message": "Clé API utilisateur invalide.",
91
+ "code": "USER_API_KEY_INVALID"
92
+ }), 403
93
+
94
+ request.user_client_id = user['user_id']
95
+ request.user_client_data = user
96
+
97
+ return f(*args, **kwargs)
98
+ return decorated_function
99
+
100
+
101
+ # --- Routes d'Authentification (API - Conservées) ---
102
+
103
+ @app.route("/api/register", methods=["POST"])
104
+ def register():
105
+ data = request.get_json()
106
+ username = data.get("username")
107
+ email = data.get("email")
108
+ password = data.get("password")
109
+ confirm_password = data.get("confirm_password")
110
+ security_question = data.get("security_question")
111
+ security_answer = data.get("security_answer")
112
+
113
+ # Nouvelle ligne (pour recevoir le dictionnaire utilisateur):
114
+ user_id, message, new_user_data = register_user(username, email, password, confirm_password, security_question, security_answer)
115
+ # Note: new_user_data sera None en cas d'échec
116
+
117
+ if user_id:
118
+ session['user_id'] = user_id
119
+ # Supprimer l'appel à get_user_by_id qui échoue:
120
+ # user_data = get_user_by_id(user_id) <--- Ligne à retirer ou commenter
121
+
122
+ # Utiliser les données utilisateur que register_user a créées et nous a retournées
123
+ if new_user_data: # Vérifier si les données sont présentes (devrait l'être si user_id est présent)
124
+ user_data = new_user_data
125
+ else:
126
+ # En cas d'erreur de logique inattendue, user_data sera vide ou vous pouvez lever une erreur ici
127
+ # Pour le moment, utilisez un dictionnaire vide pour ne pas faire planter .get()
128
+ user_data = {}
129
+
130
+ return jsonify({
131
+ "message": message,
132
+ "status": "Success",
133
+ "user_id": user_id,
134
+ # user_data est maintenant soit new_user_data, soit {}
135
+ "api_key": user_data.get("api_key")
136
+ }), 201
137
+ else: # Ce 'else' est maintenant correctement aligné avec le 'if user_id:'
138
+ return jsonify({"message": message, "status": "Error"}), 400
139
+
140
+
141
+
142
+ @app.route("/api/login", methods=["POST"])
143
+ def login():
144
+ data = request.get_json()
145
+ username = data.get("username")
146
+ password = data.get("password")
147
+
148
+ # Mise à jour pour accepter les données utilisateur en plus de l'ID et du message
149
+ user_id, message, user_data = login_user(username, password)
150
+
151
+ # Note: user_data est maintenant la 3ème valeur retournée (ou None)
152
+ if user_id and user_data: # Vérifier user_data au lieu de le chercher
153
+ # Pas besoin d'appeler get_user_by_id(user_id) ici !
154
+ session['user_id'] = user_id
155
+ return jsonify({
156
+ "message": message,
157
+ "status": "Success",
158
+ "user_id": user_id,
159
+ # Maintenant user_data n'est plus None
160
+ "api_key": user_data.get("api_key")
161
+ }), 200
162
+ else:
163
+ # L'ancienne logique de retour d'erreur est correcte
164
+ return jsonify({"message": message, "status": "Error"}), 401
165
+
166
+ @app.route("/api/logout", methods=["POST"])
167
+ def logout():
168
+ session.pop('user_id', None)
169
+ return jsonify({"message": "Déconnexion réussie.", "status": "Success"}), 200
170
+
171
+ @app.route("/api/forgot-password", methods=["POST"])
172
+ def forgot_password_api(): # Renommée pour éviter conflit avec la route HTML
173
+ data = request.get_json()
174
+ username_or_email = data.get("username_or_email")
175
+ security_answer = data.get("security_answer")
176
+ new_password = data.get("new_password")
177
+
178
+ if not username_or_email or not security_answer or not new_password:
179
+ return jsonify({"message": "Champs manquants.", "status": "Error"}), 400
180
+
181
+ success, message = reset_password_via_security_question(username_or_email, security_answer, new_password)
182
+
183
+ if success:
184
+ return jsonify({
185
+ "message": message,
186
+ "status": "Success"
187
+ }), 200
188
+ else:
189
+ return jsonify({
190
+ "message": message,
191
+ "status": "Error"
192
+ }), 400
193
+
194
+
195
+ # --- Routes de Gestion de Compte (API - Conservées) ---
196
+
197
+ @app.route("/api/user/generate-key", methods=["POST"])
198
+ @login_required
199
+ def generate_user_api_key():
200
+ user_id = session.get('user_id')
201
+
202
+ new_api_key = create_dynamic_api_key()
203
+
204
+ success, message = update_user_data(user_id, {"api_key": new_api_key})
205
+
206
+ if success:
207
+ return jsonify({
208
+ "message": "Clé API utilisateur générée et sauvegardée. Conservez-la en lieu sûr. **Utilisez-la via URL simple ('api_key=...') ou en-tête 'X-User-API-Key'.**",
209
+ "status": "Success",
210
+ "api_key": new_api_key
211
+ }), 200
212
+ else:
213
+ return jsonify({
214
+ "message": f"Erreur lors de la génération de la clé : {message}",
215
+ "status": "Error"
216
+ }), 500
217
+
218
+ @app.route("/api/user/update-info", methods=["POST"])
219
+ @login_required
220
+ def update_user_info():
221
+ user_id = session.get('user_id')
222
+ data = request.get_json()
223
+
224
+ updates = {}
225
+ if 'username' in data:
226
+ updates['username'] = data['username']
227
+ if 'email' in data:
228
+ updates['email'] = data['email']
229
+ if 'plan' in data:
230
+ updates['plan'] = data['plan']
231
+
232
+ if not updates:
233
+ return jsonify({
234
+ "message": "Aucune information à mettre à jour fournie.",
235
+ "status": "Error"
236
+ }), 400
237
+
238
+ success, message = update_user_data(user_id, updates)
239
+
240
+ if success:
241
+ return jsonify({
242
+ "message": message,
243
+ "status": "Success"
244
+ }), 200
245
+ else:
246
+ return jsonify({
247
+ "message": f"Échec de la mise à jour : {message}",
248
+ "status": "Error"
249
+ }), 400
250
+
251
+
252
+ # --- Nouvelle Route pour les Clients (API - Conservée) ---
253
+
254
+ @app.route("/api/user-register", methods=["POST"])
255
+ @user_api_key_required
256
+ def user_register_via_api_key():
257
+ client_user_id = request.user_client_id
258
+ client_data = request.user_client_data
259
+
260
+ data = request.get_json()
261
+ username = data.get("username")
262
+ email = data.get("email")
263
+ password = data.get("password")
264
+
265
+ current_count = client_data.get("created_accounts_count", 0)
266
+ plan_limit = get_plan_limit(client_data.get("plan", "free"))
267
+
268
+ if current_count >= plan_limit:
269
+
270
+ if client_data.get("plan", "free") == "free" and plan_limit == 500:
271
+ return jsonify({
272
+ "message": "Erreur: Le plan gratuit ne peut pas prendre plus de 500 comptes utilisateur.",
273
+ "status": "Error",
274
+ "code": "PLAN_LIMIT_EXCEEDED"
275
+ }), 402
276
+
277
+ return jsonify({
278
+ "message": f"Erreur: Votre plan actuel ({client_data.get('plan').upper()}) limite la création de comptes à {plan_limit}. Veuillez passer à un plan supérieur.",
279
+ "status": "Error",
280
+ "code": "PLAN_LIMIT_EXCEEDED"
281
+ }), 402
282
+
283
+ if not username or not email or len(password) < 8:
284
+ return jsonify({"message": "Nom d'utilisateur, email ou mot de passe invalide (min 8 caractères).", "status": "Error"}), 400
285
+
286
+ users = load_users_data()
287
+ if any(u.get("email") == email for u in users.values()) or any(u.get("username") == username for u in users.values()):
288
+ return jsonify({"message": "Cet email ou nom d'utilisateur est déjà enregistré.", "status": "Error"}), 400
289
+
290
+ end_user_id = str(uuid.uuid4())
291
+ hashed_password = generate_password_hash(password)
292
+
293
+ new_end_user = {
294
+ "username": username,
295
+ "email": email,
296
+ "password_hash": hashed_password,
297
+ "user_id": end_user_id,
298
+ "created_at": datetime.now().isoformat(),
299
+ "api_key": None,
300
+ "plan": "end_user",
301
+ "created_accounts_count": 0,
302
+ "security_question": None,
303
+ "security_answer": None
304
+ }
305
+ users[end_user_id] = new_end_user
306
+
307
+ new_count = current_count + 1
308
+ client_data["created_accounts_count"] = new_count
309
+ users[client_user_id] = client_data
310
+
311
+ commit_msg = f"feat: End-user registration via API Key. Client: {client_data.get('username')}. New count: {new_count}"
312
+ success_save = save_users_data(users, commit_message=commit_msg)
313
+
314
+ if not success_save:
315
+ return jsonify({"message": "Erreur critique lors de la sauvegarde (Git).", "status": "Error"}), 500
316
+
317
+ return jsonify({
318
+ "message": "Inscription de l'utilisateur final réussie.",
319
+ "status": "Success",
320
+ "user_id": end_user_id,
321
+ "accounts_remaining": plan_limit - new_count
322
+ }), 201
323
+
324
+
325
+ # ----------------------------------------------------------------------
326
+ # --- NOUVELLES ROUTES API POUR LA GESTION DES UTILISATEURS FINAUX ---
327
+ # ----------------------------------------------------------------------
328
+
329
+ @app.route("/api/enduser/register", methods=["POST"])
330
+ @api_key_required
331
+ def api_enduser_register(client_user):
332
+ """
333
+ Route API pour l'inscription d'un utilisateur final par un client (via sa clé API).
334
+ Le 'client_user' est injecté par le décorateur api_key_required.
335
+ """
336
+ data = request.get_json()
337
+ username = data.get("username")
338
+ email = data.get("email")
339
+ password = data.get("password")
340
+
341
+ client_user_id = client_user['user_id']
342
+
343
+ end_user_id, success, message = register_end_user(
344
+ client_user_id,
345
+ username,
346
+ email,
347
+ password
348
+ )
349
+
350
+ if success:
351
+ return jsonify({
352
+ "message": message,
353
+ "status": "Success",
354
+ "end_user_id": end_user_id,
355
+ }), 201
356
+ else:
357
+ return jsonify({"message": message, "status": "Error"}), 400
358
+
359
+ @app.route("/api/enduser/login", methods=["POST"])
360
+ @api_key_required
361
+ def api_enduser_login(client_user):
362
+ """
363
+ Route API pour la connexion d'un utilisateur final par un client (via sa clé API).
364
+ Le 'client_user' est injecté par le décorateur api_key_required.
365
+ """
366
+ data = request.get_json()
367
+ username_or_email = data.get("username_or_email") # Accepte username ou email
368
+ password = data.get("password")
369
+
370
+ client_user_id = client_user['user_id']
371
+
372
+ if not all([username_or_email, password]):
373
+ return jsonify({"message": "L'identifiant de l'utilisateur final et le mot de passe sont requis.", "status": "Error"}), 400
374
+
375
+ # CORRECTION : La fonction retourne (end_user_id, message, user_info)
376
+ end_user_id, message, user_info = login_end_user(
377
+ client_user_id,
378
+ username_or_email,
379
+ password
380
+ )
381
+
382
+ if end_user_id and user_info: # Si l'ID est présent (succès)
383
+ return jsonify({
384
+ "message": message,
385
+ "status": "Success",
386
+ "user": user_info
387
+ }), 200
388
+ else:
389
+ return jsonify({"message": message, "status": "Error"}), 401
390
+
391
+ @app.route("/api/enduser/recover-password", methods=["POST"])
392
+ @api_key_required
393
+ def api_enduser_recover_password(client_user):
394
+ """
395
+ Route API pour la récupération/réinitialisation du mot de passe d'un utilisateur final
396
+ par le client (via sa clé API).
397
+ Le 'client_user' est injecté par le décorateur api_key_required.
398
+ """
399
+ data = request.get_json()
400
+ end_user_identifier = data.get("username_or_email") # Identifiant de l'utilisateur final à réinitialiser
401
+ new_password = data.get("new_password")
402
+
403
+ client_user_id = client_user['user_id']
404
+
405
+ if not all([end_user_identifier, new_password]):
406
+ return jsonify({"message": "L'identifiant de l'utilisateur final et le nouveau mot de passe sont requis.", "status": "Error"}), 400
407
+
408
+ success, message = reset_end_user_password_by_client(
409
+ client_user_id,
410
+ end_user_identifier,
411
+ new_password
412
+ )
413
+
414
+ if success:
415
+ return jsonify({"message": message, "status": "Success"}), 200
416
+ else:
417
+ return jsonify({"message": message, "status": "Error"}), 400
418
+
419
+ # app.py
420
+
421
+
422
+ @app.route("/api/user-info", methods=["GET"])
423
+ @api_key_required
424
+ def api_user_info(client_user):
425
+ """
426
+ Route API pour récupérer les informations de l'utilisateur principal (client)
427
+ à partir de la clé API fournie.
428
+ Le 'client_user' est injecté par le décorateur api_key_required.
429
+ """
430
+ # client_user est l'objet utilisateur complet injecté par le décorateur
431
+
432
+ # Sécurité : créer une copie et supprimer les données sensibles avant l'envoi
433
+ user_info_safe = client_user.copy()
434
+ user_info_safe.pop('password_hash', None)
435
+ user_info_safe.pop('security_answer_hash', None)
436
+
437
+ return jsonify({
438
+ "message": "Informations utilisateur récupérées avec succès.",
439
+ "status": "Success",
440
+ "user": user_info_safe
441
+ }), 200
442
+
443
+
444
+
445
+ # --- Route de Vérification de l'État (API - Conservée) ---
446
+ @app.route("/api/health", methods=["GET"])
447
+ def health_check():
448
+ """Vérifie l'état du service (Git)."""
449
+ try:
450
+ load_users_data() # Tente de charger les données
451
+ git_status = "Ready"
452
+ except Exception:
453
+ git_status = "Failed (Vérifier HF_TOKEN)"
454
+
455
+ return jsonify({
456
+ "status": "Online",
457
+ "data_storage": git_status
458
+ }), 200
auth_backend.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # auth_backend.py
2
+
3
+ import json
4
+ import uuid
5
+ import secrets
6
+ import string
7
+ import sys # Nécessaire pour print(..., file=sys.stderr)
8
+ from datetime import datetime
9
+ from werkzeug.security import generate_password_hash, check_password_hash
10
+ # --- NOUVEL IMPORT: Migration vers Baserow ---
11
+ # Ces fonctions sont maintenant implémentées dans baserow_storage.py
12
+ from baserow_storage import (
13
+ # Fonctions de lecture/écriture pour les utilisateurs principaux (simulant l'ancienne API)
14
+ load_primary_user_data,
15
+ save_primary_user_data,
16
+ # Fonctions de recherche indexées (nouvelles et plus efficaces)
17
+ get_user_by_email,
18
+ get_client_user_by_api_key,
19
+ # Fonctions de lecture/écriture pour les utilisateurs finaux (simulant l'ancienne API)
20
+ load_end_user_data,
21
+ save_end_user_data,
22
+ # load_users_data est désormais obsolète et retiré.
23
+ )
24
+ from flask import session
25
+ from config import PLANS_CONFIG
26
+ from typing import Optional, Dict
27
+
28
+ # ----------------------------------------------------------------------
29
+ # --- Fonctions Utilitaires et de Configuration ---
30
+ # ----------------------------------------------------------------------
31
+
32
+ def get_plan_limit(plan: str) -> float:
33
+ """Retourne la limite de compte pour un plan donné."""
34
+ return PLANS_CONFIG.get(plan, {}).get("limit", PLANS_CONFIG["free"]["limit"])
35
+
36
+ def get_plan_details(plan_id: str) -> Optional[Dict]:
37
+ """Retourne les détails complets d'un plan à partir de son ID."""
38
+ return PLANS_CONFIG.get(plan_id)
39
+
40
+ def generate_api_key(length: int = 32) -> str:
41
+ """Génère une clé API sécurisée."""
42
+ chars = string.ascii_letters + string.digits
43
+ return ''.join(secrets.choice(chars) for _ in range(length))
44
+
45
+ # ----------------------------------------------------------------------
46
+ # --- Fonctions d'Authentification WEB (Primary Users) ---
47
+ # ----------------------------------------------------------------------
48
+
49
+ def register_user(username: str, email: str, password: str, confirm_password: str, security_question: str, security_answer: str) -> tuple[Optional[str], str]:
50
+ """
51
+ Tente d'enregistrer un nouvel utilisateur principal.
52
+ Retourne l'ID utilisateur et un message.
53
+ """
54
+ email = email.lower().strip()
55
+
56
+ # Validation du formulaire
57
+ if not all([username, email, password, confirm_password, security_question, security_answer]):
58
+ return None, "Tous les champs sont requis."
59
+
60
+ if password != confirm_password:
61
+ return None, "Les mots de passe ne correspondent pas."
62
+
63
+ if len(password) < 8:
64
+ return None, "Le mot de passe est trop court (min 8 caractères)."
65
+
66
+ # 1. Vérification de l'existence de l'utilisateur par email (Utilisation de la nouvelle fonction rapide Baserow)
67
+ if get_user_by_email(email):
68
+ return None, "Un compte avec cette adresse e-mail existe déjà."
69
+
70
+ # 2. Hachage des données
71
+ password_hash = generate_password_hash(password)
72
+ security_answer_hash = generate_password_hash(security_answer.lower())
73
+
74
+ # 3. Création des données utilisateur
75
+ user_id = str(uuid.uuid4())
76
+ api_key = generate_api_key()
77
+
78
+ new_user = {
79
+ 'user_id': user_id,
80
+ 'username': username,
81
+ 'email': email,
82
+ 'password_hash': password_hash,
83
+ 'api_key': api_key,
84
+ 'security_question': security_question,
85
+ 'security_answer_hash': security_answer_hash,
86
+ 'plan_id': 'free', # Plan par défaut
87
+ 'stripe_subscription_id': None, # Pas d'abonnement Stripe au début
88
+ 'date_creation': datetime.now().isoformat(),
89
+ 'date_plan_start': datetime.now().isoformat(),
90
+ 'baserow_row_id': None, # Sera rempli par la fonction save_primary_user_data si c'est une création
91
+ # Note: D'autres champs pourraient être nécessaires ici, selon les besoins non-vus
92
+ }
93
+
94
+ # 4. Sauvegarde dans Baserow
95
+ success = save_primary_user_data(new_user, commit_msg=f"feat: Création de l'utilisateur {user_id} ({email})")
96
+
97
+ if success:
98
+ return user_id, "Inscription réussie. Vous pouvez maintenant vous connecter."
99
+ else:
100
+ # Échec de l'écriture dans la BDD (Baserow)
101
+ return None, "Erreur interne lors de l'enregistrement de l'utilisateur."
102
+
103
+
104
+ def login_user(username_or_email: str, password: str) -> tuple[Optional[str], str]:
105
+ """
106
+ Tente de connecter un utilisateur principal.
107
+ Retourne l'ID utilisateur et un message.
108
+ """
109
+ username_or_email = username_or_email.lower().strip()
110
+
111
+ # 1. Recherche de l'utilisateur par email (Utilisation de la nouvelle fonction rapide Baserow)
112
+ user = get_user_by_email(username_or_email)
113
+
114
+ if user:
115
+ # 2. Vérification du mot de passe
116
+ if check_password_hash(user['password_hash'], password):
117
+ session['user_id'] = user['user_id']
118
+ return user['user_id'], "Connexion réussie."
119
+
120
+ return None, "Email/Nom d'utilisateur ou mot de passe invalide."
121
+
122
+
123
+ def get_user_by_id(user_id: str) -> Optional[Dict]:
124
+ """
125
+ Récupère un utilisateur principal par son ID.
126
+ Utilise la fonction load_primary_user_data (qui est get_user_by_id dans Baserow).
127
+ """
128
+ return load_primary_user_data(user_id)
129
+
130
+ def get_user_by_api_key(api_key: str) -> Optional[Dict]:
131
+ """
132
+ Récupère un utilisateur principal par sa Clé API (utilisé par le décorateur).
133
+ Utilise la nouvelle fonction indexée et rapide de Baserow.
134
+ """
135
+ return get_client_user_by_api_key(api_key)
136
+
137
+
138
+ def reset_password_via_security_question(username_or_email: str, question: str, answer: str, new_password: str) -> tuple[bool, str]:
139
+ """Réinitialise le mot de passe via la question de sécurité."""
140
+ username_or_email = username_or_email.lower().strip()
141
+
142
+ # Validation du mot de passe
143
+ if len(new_password) < 8:
144
+ return False, "Le nouveau mot de passe est trop court (min 8 caractères)."
145
+
146
+ # 1. Recherche de l'utilisateur
147
+ user = get_user_by_email(username_or_email)
148
+
149
+ if not user:
150
+ return False, "Utilisateur introuvable."
151
+
152
+ # 2. Vérification de la question/réponse
153
+ if user.get('security_question') != question:
154
+ return False, "Question de sécurité incorrecte."
155
+
156
+ if not check_password_hash(user.get('security_answer_hash', ''), answer.lower()):
157
+ return False, "Réponse de sécurité incorrecte."
158
+
159
+ # 3. Hachage du nouveau mot de passe
160
+ new_hashed_password = generate_password_hash(new_password)
161
+
162
+ # 4. Mise à jour et sauvegarde en BDD (Baserow)
163
+ user['password_hash'] = new_hashed_password
164
+
165
+ success = save_primary_user_data(user, commit_msg=f"feat: Réinitialisation MDP utilisateur {user['user_id']}")
166
+
167
+ if success:
168
+ return True, "Mot de passe réinitialisé avec succès."
169
+ else:
170
+ return False, "Erreur interne lors de la mise à jour du mot de passe."
171
+
172
+
173
+ def update_user_plan(user_id: str, new_plan_id: str, stripe_subscription_id: Optional[str]) -> bool:
174
+ """Met à jour le plan et l'ID d'abonnement Stripe pour un utilisateur."""
175
+
176
+ user = get_user_by_id(user_id)
177
+
178
+ if not user:
179
+ print(f"Erreur: Utilisateur {user_id} non trouvé pour la mise à jour du plan.", file=sys.stderr)
180
+ return False
181
+
182
+ user['plan_id'] = new_plan_id
183
+ user['stripe_subscription_id'] = stripe_subscription_id
184
+ user['date_plan_start'] = datetime.now().isoformat()
185
+
186
+ success = save_primary_user_data(user, commit_msg=f"feat: Mise à jour du plan pour {user_id} vers {new_plan_id}")
187
+
188
+ return success
189
+
190
+ # ----------------------------------------------------------------------
191
+ # --- Fonctions API (End Users) ---
192
+ # ----------------------------------------------------------------------
193
+
194
+ def register_end_user(client_user_id: str, identifier: str, password: str, metadata: Optional[Dict] = None) -> tuple[Optional[str], str]:
195
+ """
196
+ Crée un nouvel utilisateur final pour le client API.
197
+ Retourne l'ID utilisateur final et un message.
198
+ """
199
+ if len(password) < 8:
200
+ return None, "Le mot de passe de l'utilisateur final est trop court (min 8 caractères)."
201
+
202
+ # 1. Hachage
203
+ password_hash = generate_password_hash(password)
204
+
205
+ # 2. Création des données utilisateur final
206
+ end_user_id = str(uuid.uuid4())
207
+
208
+ new_end_user = {
209
+ 'end_user_id': end_user_id,
210
+ 'client_user_id': client_user_id, # Lien crucial vers le client principal
211
+ 'identifier': identifier,
212
+ 'password_hash': password_hash,
213
+ 'metadata': json.dumps(metadata) if metadata else "{}", # Stockage JSON
214
+ 'date_creation': datetime.now().isoformat(),
215
+ 'baserow_row_id': None, # Sera rempli lors de la création
216
+ }
217
+
218
+ # 3. Sauvegarde dans Baserow. save_end_user_data doit gérer l'insertion.
219
+ success = save_end_user_data(new_end_user, commit_msg=f"feat: Création utilisateur final {end_user_id} pour client {client_user_id}")
220
+
221
+ if success:
222
+ return end_user_id, "Utilisateur final créé avec succès."
223
+ else:
224
+ return None, "Erreur interne lors de la création de l'utilisateur final."
225
+
226
+ def login_end_user(client_user_id: str, identifier: str, password: str) -> tuple[Optional[str], str]:
227
+ """Tente de connecter un utilisateur final."""
228
+ identifier = identifier.strip()
229
+
230
+ # 1. Chargement de l'utilisateur final par ID + Client ID (Recherche indexée Baserow)
231
+ try:
232
+ end_user = load_end_user_data(client_user_id, identifier) # 'identifier' peut être l'UUID de l'utilisateur
233
+ if end_user and check_password_hash(end_user['password_hash'], password):
234
+ return identifier, "Connexion réussie."
235
+ except ValueError:
236
+ # Ce n'est pas un UUID. On retourne une erreur car la recherche par username/email n'est pas implémentée de manière performante.
237
+ pass
238
+
239
+ return None, "Identifiants utilisateur final invalides."
240
+
241
+ def reset_end_user_password_by_client(client_user_id: str, end_user_id: str, new_password: str) -> tuple[bool, str]:
242
+ # Validation du mot de passe
243
+ if len(new_password) < 8:
244
+ return False, "Le nouveau mot de passe est trop court (min 8 caractères). Maintient l'ancienne limite."
245
+
246
+ # Chargement de l'utilisateur final unique par ID
247
+ end_user = load_end_user_data(client_user_id, end_user_id)
248
+
249
+ if not end_user:
250
+ return False, "Utilisateur final introuvable pour ce client."
251
+
252
+ # Hachage du nouveau mot de passe
253
+ new_hashed_password = generate_password_hash(new_password)
254
+
255
+ # Mise à jour et sauvegarde en BDD (Baserow)
256
+ end_user['password_hash'] = new_hashed_password
257
+
258
+ commit_msg = f"feat: Réinitialisation forcée MDP utilisateur final {end_user_id} par client {client_user_id}"
259
+ if save_end_user_data(end_user, commit_msg=commit_msg):
260
+ return True, "Mot de passe utilisateur final réinitialisé avec succès."
261
+ else:
262
+ return False, "Erreur interne lors de la réinitialisation du mot de passe utilisateur final."
baserow_storage.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # baserow_storage.py
2
+
3
+ import os
4
+ import requests
5
+ import json
6
+ import sys
7
+ from typing import Optional, Dict
8
+
9
+ # --- Configuration Baserow (Doit être défini dans les secrets) ---
10
+ BASE_URL = "https://api.baserow.io/api/database/rows/" # Endpoint spécifique
11
+ API_TOKEN = os.environ.get("BASEROW_API_TOKEN")
12
+
13
+ # Les IDs de table seront récupérés depuis les variables d'environnement
14
+ PRIMARY_USERS_TABLE_ID = os.environ.get("PRIMARY_USERS_TABLE_ID")
15
+ END_USERS_TABLE_ID = os.environ.get("END_USERS_TABLE_ID")
16
+
17
+ # Headers pour l'authentification
18
+ HEADERS = {
19
+ "Authorization": f"Token {API_TOKEN}",
20
+ "Content-Type": "application/json"
21
+ }
22
+
23
+ # Noms de Colonnes (Utilisés dans Baserow - basés sur la discussion précédente)
24
+ FIELD_ID = 'ID' # Correspond à 'user_id' dans le code
25
+ FIELD_EMAIL = 'Email' # Correspond à 'email'
26
+ FIELD_USERNAME = 'Nom d\'utilisateur' # Correspond à 'username'
27
+ FIELD_PASSWORD_HASH = 'Hachage Mot de Passe' # Correspond à 'password_hash'
28
+ FIELD_API_KEY = 'Clé API' # Correspond à 'api_key'
29
+ FIELD_SECURITY_Q = 'Question de Sécurité'
30
+ FIELD_SECURITY_A_HASH = 'Hachage Réponse Secrète'
31
+ FIELD_PLAN_ID = 'Plan ID'
32
+ FIELD_STRIPE_SUB_ID = 'ID Abonnement Stripe'
33
+ FIELD_DATE_CREATION = 'Date Création'
34
+ FIELD_DATE_PLAN_START = 'Date Début Plan'
35
+ FIELD_END_USER_ID = 'ID Utilisateur Final' # Correspond à 'end_user_id'
36
+ FIELD_END_USER_IDENTIFIER = 'Identifiant' # Correspond à 'identifier'
37
+ FIELD_END_USER_METADATA = 'Métadonnées' # Correspond à 'metadata'
38
+ FIELD_CLIENT_ID_LINK = 'ID Client Principal' # Lien vers Primary_Users
39
+
40
+
41
+ def _get_table_url(table_id: str) -> str:
42
+ """Construit l'URL d'API pour une table donnée (avec le bon endpoint)."""
43
+ return f"{BASE_URL}table/{table_id}/"
44
+
45
+ def _baserow_record_to_user(record: Dict, is_end_user: bool) -> Dict:
46
+ """
47
+ Convertit un enregistrement Baserow (avec noms de champs utilisateur)
48
+ en format de dictionnaire Python attendu par le backend.
49
+ """
50
+ user_data = {
51
+ # Champs communs / Primary Users
52
+ 'baserow_row_id': record['id'], # ID interne de la ligne Baserow (pour les mises à jour)
53
+ 'date_creation': record.get(FIELD_DATE_CREATION),
54
+
55
+ # Primary User specific fields
56
+ 'user_id': record.get(FIELD_ID),
57
+ 'email': record.get(FIELD_EMAIL),
58
+ 'username': record.get(FIELD_USERNAME),
59
+ 'password_hash': record.get(FIELD_PASSWORD_HASH),
60
+ 'api_key': record.get(FIELD_API_KEY),
61
+ 'security_question': record.get(FIELD_SECURITY_Q),
62
+ 'security_answer_hash': record.get(FIELD_SECURITY_A_HASH),
63
+ 'plan_id': record.get(FIELD_PLAN_ID),
64
+ 'stripe_subscription_id': record.get(FIELD_STRIPE_SUB_ID),
65
+ 'date_plan_start': record.get(FIELD_DATE_PLAN_START),
66
+ }
67
+
68
+ if is_end_user:
69
+ # End User specific fields
70
+ user_data['end_user_id'] = record.get(FIELD_END_USER_ID)
71
+ user_data['identifier'] = record.get(FIELD_END_USER_IDENTIFIER)
72
+ # Le lien est un tableau, on extrait la valeur 'user_id' du client principal
73
+ client_link = record.get(FIELD_CLIENT_ID_LINK)
74
+ # NOTE: Le format du lien renvoyé est [{... 'value': 'ID du client' ...}]
75
+ user_data['client_user_id'] = client_link[0]['value'] if client_link and client_link[0]['value'] else None
76
+ # Les métadonnées sont stockées comme une chaîne JSON ou Texte Long
77
+ user_data['metadata'] = record.get(FIELD_END_USER_METADATA)
78
+ user_data['password_hash'] = record.get(FIELD_PASSWORD_HASH) # S'assurer qu'il est là pour l'utilisateur final
79
+
80
+ # Nettoyage des clés None ou non-pertinentes
81
+ return {k: v for k, v in user_data.items() if v is not None}
82
+
83
+
84
+ def _user_to_baserow_data(user_data: Dict, is_end_user: bool) -> Dict:
85
+ """
86
+ Convertit le format de dictionnaire Python du backend en format
87
+ JSON attendu par l'API Baserow (avec noms de champs utilisateur).
88
+ """
89
+ if is_end_user:
90
+ # End User fields
91
+ baserow_data = {
92
+ FIELD_END_USER_ID: user_data.get('end_user_id'),
93
+ FIELD_END_USER_IDENTIFIER: user_data.get('identifier'),
94
+ FIELD_PASSWORD_HASH: user_data.get('password_hash'),
95
+ FIELD_END_USER_METADATA: user_data.get('metadata'),
96
+ FIELD_DATE_CREATION: user_data.get('date_creation'),
97
+ # Le lien vers Primary_Users est géré dans save_end_user_data
98
+ }
99
+ else:
100
+ # Primary User fields
101
+ baserow_data = {
102
+ FIELD_ID: user_data.get('user_id'),
103
+ FIELD_EMAIL: user_data.get('email'),
104
+ FIELD_USERNAME: user_data.get('username'),
105
+ FIELD_PASSWORD_HASH: user_data.get('password_hash'),
106
+ FIELD_API_KEY: user_data.get('api_key'),
107
+ FIELD_SECURITY_Q: user_data.get('security_question'),
108
+ FIELD_SECURITY_A_HASH: user_data.get('security_answer_hash'),
109
+ FIELD_PLAN_ID: user_data.get('plan_id'),
110
+ FIELD_STRIPE_SUB_ID: user_data.get('stripe_subscription_id'),
111
+ FIELD_DATE_CREATION: user_data.get('date_creation'),
112
+ FIELD_DATE_PLAN_START: user_data.get('date_plan_start'),
113
+ }
114
+
115
+ # Suppression des clés non-valorisées (None)
116
+ return {k: v for k, v in baserow_data.items() if v is not None}
117
+
118
+
119
+ def _get_single_user_record(table_id: str, field_name: str, value: str, is_end_user: bool) -> Optional[Dict]:
120
+ """Fonction générique pour rechercher un seul enregistrement par un champ (filtrage Baserow)."""
121
+ url = _get_table_url(table_id)
122
+ # Utilisation du paramètre de filtre de Baserow pour une recherche indexée (plus rapide)
123
+ filter_param = f"filter__{field_name}__equal={value}"
124
+
125
+ try:
126
+ response = requests.get(
127
+ f"{url}?user_field_names=true&{filter_param}",
128
+ headers=HEADERS
129
+ )
130
+ response.raise_for_status()
131
+
132
+ data = response.json()
133
+ if data and data.get('results'):
134
+ # On ne prend que le premier résultat (car ID/Email/API Key sont uniques)
135
+ return _baserow_record_to_user(data['results'][0], is_end_user)
136
+ return None
137
+
138
+ except requests.exceptions.RequestException as e:
139
+ print(f"Erreur de Baserow lors de la recherche par filtre {field_name}: {e}", file=sys.stderr)
140
+ return None
141
+
142
+ # ----------------------------------------------------------------------
143
+ # --- Fonctions CRUD Primary_Users (Nouveau et Remplacement) ---
144
+ # ----------------------------------------------------------------------
145
+
146
+ def get_user_by_email(email: str) -> Optional[Dict]:
147
+ """Recherche un utilisateur principal par son adresse Email."""
148
+ return _get_single_user_record(PRIMARY_USERS_TABLE_ID, FIELD_EMAIL, email, is_end_user=False)
149
+
150
+ def get_client_user_by_api_key(api_key: str) -> Optional[Dict]:
151
+ """Recherche un utilisateur principal par sa Clé API."""
152
+ return _get_single_user_record(PRIMARY_USERS_TABLE_ID, FIELD_API_KEY, api_key, is_end_user=False)
153
+
154
+ # Remplacement de l'ancien load_primary_user_data(user_id)
155
+ def load_primary_user_data(user_id: str) -> Optional[Dict]:
156
+ """Recherche un utilisateur principal par son ID (user_id)."""
157
+ return _get_single_user_record(PRIMARY_USERS_TABLE_ID, FIELD_ID, user_id, is_end_user=False)
158
+
159
+
160
+ def save_primary_user_data(user_data: Dict, commit_msg: str = "") -> bool:
161
+ """Crée ou met à jour un utilisateur principal."""
162
+ row_id = user_data.get('baserow_row_id')
163
+ url = _get_table_url(PRIMARY_USERS_TABLE_ID)
164
+
165
+ # 1. Conversion des données
166
+ baserow_data = _user_to_baserow_data(user_data, is_end_user=False)
167
+
168
+ try:
169
+ if row_id:
170
+ # MISE À JOUR (PATCH)
171
+ response = requests.patch(
172
+ f"{url}{row_id}/?user_field_names=true",
173
+ headers=HEADERS,
174
+ json=baserow_data
175
+ )
176
+ else:
177
+ # CRÉATION (POST)
178
+ response = requests.post(
179
+ f"{url}?user_field_names=true",
180
+ headers=HEADERS,
181
+ json=baserow_data
182
+ )
183
+
184
+ response.raise_for_status()
185
+
186
+ # NOTE: Pour une création, il faut mettre à jour le baserow_row_id
187
+ if not row_id and response.status_code == 200:
188
+ new_record = response.json()
189
+ user_data['baserow_row_id'] = new_record.get('id')
190
+
191
+ print(f"DEBUG: Baserow Primary User action réussie. Row ID: {row_id or new_record.get('id')}. Message: {commit_msg}", file=sys.stderr)
192
+ return True
193
+
194
+ except requests.exceptions.RequestException as e:
195
+ print(f"Erreur lors de la sauvegarde/mise à jour du Primary User dans Baserow: {e}", file=sys.stderr)
196
+ return False
197
+
198
+
199
+ # ----------------------------------------------------------------------
200
+ # --- Fonctions CRUD End_Users (Remplacement) ---
201
+ # ----------------------------------------------------------------------
202
+
203
+ def _get_client_baserow_row_id(client_user_id: str) -> Optional[int]:
204
+ """Récupère l'ID de ligne interne Baserow du client principal pour le lien."""
205
+ client_user = load_primary_user_data(client_user_id) # utilise la fonction déjà créée
206
+ return client_user.get('baserow_row_id') if client_user else None
207
+
208
+
209
+ # Remplacement de l'ancien load_end_user_data(client_user_id, end_user_id)
210
+ def load_end_user_data(client_user_id: str, end_user_id: str) -> Optional[Dict]:
211
+ """Recherche un utilisateur final par ID, lié à un client principal (recherche indexée)."""
212
+ client_row_id = _get_client_baserow_row_id(client_user_id)
213
+ if not client_row_id:
214
+ return None
215
+
216
+ url = _get_table_url(END_USERS_TABLE_ID)
217
+
218
+ # Filtre 1: ID Utilisateur Final = end_user_id
219
+ # Filtre 2: ID Client Principal (Lien) = client_row_id (Lien vers une autre table)
220
+ filter_params = f"filter__{FIELD_END_USER_ID}__equal={end_user_id}&filter__{FIELD_CLIENT_ID_LINK}__link_row_id={client_row_id}"
221
+
222
+ try:
223
+ response = requests.get(
224
+ f"{url}?user_field_names=true&{filter_params}",
225
+ headers=HEADERS
226
+ )
227
+ response.raise_for_status()
228
+
229
+ data = response.json()
230
+ if data and data.get('results'):
231
+ return _baserow_record_to_user(data['results'][0], is_end_user=True)
232
+ return None
233
+
234
+ except requests.exceptions.RequestException as e:
235
+ print(f"Erreur Baserow lors de la recherche End User: {e}", file=sys.stderr)
236
+ return None
237
+
238
+
239
+ # Remplacement de l'ancien save_end_user_data(end_user_data)
240
+ def save_end_user_data(end_user_data: Dict, commit_msg: str = "") -> bool:
241
+ """Crée ou met à jour un utilisateur final."""
242
+ row_id = end_user_data.get('baserow_row_id')
243
+ client_user_id = end_user_data.get('client_user_id')
244
+
245
+ # Étape cruciale: Trouver l'ID de ligne Baserow du client principal
246
+ client_row_id = _get_client_baserow_row_id(client_user_id)
247
+ if not client_row_id:
248
+ print(f"Erreur: Client Principal {client_user_id} introuvable pour la sauvegarde de l'utilisateur final.", file=sys.stderr)
249
+ return False
250
+
251
+ url = _get_table_url(END_USERS_TABLE_ID)
252
+
253
+ # 1. Conversion des données
254
+ baserow_data = _user_to_baserow_data(end_user_data, is_end_user=True)
255
+
256
+ # Ajout du lien vers le client principal (obligatoire pour la création/mise à jour)
257
+ # L'API Baserow pour les champs de type 'Lien vers une autre table' attend une liste d'ID de ligne.
258
+ baserow_data[FIELD_CLIENT_ID_LINK] = [client_row_id]
259
+
260
+ try:
261
+ if row_id:
262
+ # MISE À JOUR (PATCH)
263
+ response = requests.patch(
264
+ f"{url}{row_id}/?user_field_names=true",
265
+ headers=HEADERS,
266
+ json=baserow_data
267
+ )
268
+ else:
269
+ # CRÉATION (POST)
270
+ response = requests.post(
271
+ f"{url}?user_field_names=true",
272
+ headers=HEADERS,
273
+ json=baserow_data
274
+ )
275
+
276
+ response.raise_for_status()
277
+
278
+ if not row_id and response.status_code == 200:
279
+ new_record = response.json()
280
+ end_user_data['baserow_row_id'] = new_record.get('id')
281
+
282
+ print(f"DEBUG: Baserow End User action réussie. Row ID: {row_id or new_record.get('id')}. Message: {commit_msg}", file=sys.stderr)
283
+ return True
284
+
285
+ except requests.exceptions.RequestException as e:
286
+ print(f"Erreur lors de la sauvegarde/mise à jour du End User dans Baserow: {e}", file=sys.stderr)
287
+ return False
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
config.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # config.py
2
+
3
+ # Fichier de configuration pour le backend ErnestMind 2.5
4
+ import os
5
+
6
+ # --- Configuration Baserow (Nouveau) ---
7
+ # Clés à définir dans les secrets d'environnement Hugging Face Space
8
+ BASEROW_DATABASE_ID = os.environ.get("BASEROW_DATABASE_ID")
9
+ PRIMARY_USERS_TABLE_ID = os.environ.get("PRIMARY_USERS_TABLE_ID")
10
+ END_USERS_TABLE_ID = os.environ.get("END_USERS_TABLE_ID")
11
+ # Le token d'accès API
12
+ BASEROW_API_TOKEN = os.environ.get("BASEROW_API_TOKEN")
13
+
14
+ # --- Configuration Stripe (Paiement) ---
15
+ # Clés à définir dans les secrets d'environnement Hugging Face Space
16
+ STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
17
+ STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET")
18
+ # Clé Publique (utilisée par le Frontend, mais stockée ici pour référence)
19
+ STRIPE_PUBLIC_KEY = os.environ.get("STRIPE_PUBLIC_KEY")
20
+
21
+ # --- Dictionnaire de Prix Central (Phase 1) ---
22
+ # Contient toutes les informations nécessaires pour le Frontend et le Backend.
23
+ # Les 'price_id' doivent correspondre aux IDs créés dans Stripe.
24
+ PLANS_CONFIG = {
25
+ # Plan Gratuit
26
+ "free": {
27
+ "title": "Gratuit",
28
+ "description": "Idéal pour les tests et les petits projets.",
29
+ "limit": 500,
30
+ "price_monthly": 0.0,
31
+ "price_annual": 0.0,
32
+ "price_id_monthly": None,
33
+ "price_id_annual": None,
34
+ "currency": "EUR"
35
+ },
36
+ # Plan Standard - Mensuel
37
+ "standard_monthly": {
38
+ "title": "Standard (Mensuel)",
39
+ "description": "Pour les utilisateurs réguliers. Paiement mensuel.",
40
+ "limit": 1000,
41
+ "price_monthly": 19.99,
42
+ "price_annual": 0.0,
43
+ "price_id_monthly": "price_1OvXXXXXXX", # REMPLACER PAR VOTRE VRAI ID STRIPE
44
+ "price_id_annual": None,
45
+ "currency": "EUR"
46
+ },
47
+ # Plan Standard - Annuel
48
+ "standard_annual": {
49
+ "title": "Standard (Annuel)",
50
+ "description": "Pour les utilisateurs réguliers. Économisez 20% en payant à l'année.",
51
+ "limit": 1000,
52
+ "price_monthly": 0.0,
53
+ "price_annual": 199.90,
54
+ "price_id_monthly": None,
55
+ "price_id_annual": "price_1OwYYYYYYY", # REMPLACER PAR VOTRE VRAI ID STRIPE
56
+ "currency": "EUR"
57
+ },
58
+ # Plan Pro - Mensuel
59
+ "pro_monthly": {
60
+ "title": "Pro (Mensuel)",
61
+ "description": "Pour les professionnels et les projets importants. Paiement mensuel.",
62
+ "limit": 2000,
63
+ "price_monthly": 49.99,
64
+ "price_annual": 0.0,
65
+ "price_id_monthly": "price_1OxZZZZZZZ", # REMPLACER PAR VOTRE VRAI ID STRIPE
66
+ "price_id_annual": None,
67
+ "currency": "EUR"
68
+ },
69
+ # Plan Pro - Annuel
70
+ "pro_annual": {
71
+ "title": "Pro (Annuel)",
72
+ "description": "Pour les professionnels et les projets importants. Économisez 20% en payant à l'année.",
73
+ "limit": 2000,
74
+ "price_monthly": 0.0,
75
+ "price_annual": 499.90,
76
+ "price_id_monthly": None,
77
+ "price_id_annual": "price_1OyAAAAAAA", # REMPLACER PAR VOTRE VRAI ID STRIPE
78
+ "currency": "EUR"
79
+ },
80
+ # Plan Illimité - Mensuel
81
+ "illimited_monthly": {
82
+ "title": "Illimité (Mensuel)",
83
+ "description": "Sans aucune restriction, pour les grandes entreprises. Paiement mensuel.",
84
+ "limit": float('inf'),
85
+ "price_monthly": 99.99,
86
+ "price_annual": 0.0,
87
+ "price_id_monthly": "price_1OzBBBBBBB", # REMPLACER PAR VOTRE VRAI ID STRIPE
88
+ "price_id_annual": None,
89
+ "currency": "EUR"
90
+ },
91
+ # Plan Illimité - Annuel
92
+ "illimited_annual": {
93
+ "title": "Illimité (Annuel)",
94
+ "description": "Sans aucune restriction, pour les grandes entreprises. Économisez 20% en payant à l'année.",
95
+ "limit": float('inf'),
96
+ "price_monthly": 0.0,
97
+ "price_annual": 999.90,
98
+ "price_id_monthly": None,
99
+ "price_id_annual": "price_1OzAACCCCC", # REMPLACER PAR VOTRE VRAI ID STRIPE
100
+ "currency": "EUR"
101
+ },
102
+ # Plan Spécial pour les End-users (pas listé sur la page de prix)
103
+ "end_user": {
104
+ "title": "End-User",
105
+ "description": "Compte utilisateur créé par un client.",
106
+ "limit": 0,
107
+ "price_monthly": 0.0,
108
+ "price_annual": 0.0,
109
+ "price_id_monthly": None,
110
+ "price_id_annual": None,
111
+ "currency": "EUR"
112
+ },
113
+ }
decorators.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # decorators.py
2
+
3
+ from functools import wraps
4
+ from flask import session, redirect, url_for, flash, request, jsonify # <-- AJOUT de request, jsonify
5
+
6
+ # Import nécessaire pour le décorateur API (Importation paresseuse pour éviter les problèmes d'importation circulaire)
7
+ import auth_backend
8
+
9
+ def login_required(f):
10
+ """
11
+ Décorateur pour les routes nécessitant une connexion (Web UI).
12
+ """
13
+ @wraps(f)
14
+ def decorated_function(*args, **kwargs):
15
+ # Vérifie si l'ID utilisateur est dans la session
16
+ if session.get('user_id') is None:
17
+ flash("Vous devez être connecté pour accéder à cette page.", "error")
18
+ # Rediriger vers la route de connexion (qui est dans user_bp)
19
+ return redirect(url_for('user_bp.connexion'))
20
+ return f(*args, **kwargs)
21
+ return decorated_function
22
+
23
+ def api_key_required(f):
24
+ """
25
+ Décorateur pour les routes d'API nécessitant une clé API valide.
26
+ Récupère la clé API de l'en-tête 'X-API-Key' ou du paramètre de requête 'api_key'.
27
+ Injecte les données de l'utilisateur principal (client) dans la fonction décorée.
28
+ """
29
+ @wraps(f)
30
+ def decorated_function(*args, **kwargs):
31
+ # 1. Récupérer la clé depuis l'en-tête ou les paramètres de requête
32
+ api_key = request.headers.get('X-API-Key')
33
+ if not api_key:
34
+ api_key = request.args.get('api_key')
35
+
36
+ if not api_key:
37
+ return jsonify({
38
+ "message": "Authentification requise. Clé API manquante dans l'en-tête X-API-Key ou le paramètre api_key.",
39
+ "status": "Unauthorized"
40
+ }), 401
41
+
42
+ # 2. Valider la clé et récupérer l'utilisateur principal
43
+ # NOTE: Utilise l'import paresseux 'auth_backend.get_client_user_by_api_key'
44
+ client_user = auth_backend.get_client_user_by_api_key(api_key)
45
+
46
+ if not client_user:
47
+ return jsonify({
48
+ "message": "Clé API invalide ou non reconnue.",
49
+ "status": "Forbidden"
50
+ }), 403
51
+
52
+ # 3. Injecter les données de l'utilisateur principal (le client)
53
+ kwargs['client_user'] = client_user
54
+
55
+ return f(*args, **kwargs)
56
+
57
+ return decorated_function
entrypoint.sh ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (Base de Données Baserow) ---"
8
+
9
+ # Définir le port par défaut de Hugging Face si $PORT est vide
10
+ export APP_PORT=${PORT:-7860}
11
+
12
+ # 1. Démarrer le serveur Flask/Gunicorn en premier plan (Plus besoin de process en arrière-plan)
13
+ echo "Démarrage du serveur Gunicorn sur le port $APP_PORT..."
14
+ # Utilisation de 'exec' pour que Gunicorn soit le PID 1, bonne pratique Docker.
15
+ exec gunicorn --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS app:app -b 0.0.0.0:$APP_PORT
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # requirements.txt
2
+ flask
3
+ bcrypt
4
+ requests
5
+ gunicorn
6
+ Flask-CORS
7
+ python-dotenv
8
+ stripe
9
+ dnspython
user_routes.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # user_routes.py
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
+ )
10
+ from decorators import login_required
11
+
12
+ # Création du Blueprint 'user_bp'
13
+ user_bp = Blueprint('user_bp', __name__)
14
+
15
+ @user_bp.route("/inscription", methods=['GET', 'POST'])
16
+ def inscription():
17
+ if request.method == 'POST':
18
+ # Traitement du formulaire d'inscription
19
+ username = request.form.get("username")
20
+ email = request.form.get("email")
21
+ password = request.form.get("password")
22
+ confirm_password = request.form.get("confirm_password")
23
+ security_question = request.form.get("security_question")
24
+ security_answer = request.form.get("security_answer")
25
+
26
+ # Appel à register_user mis à jour pour inclure la génération de clé API
27
+ user_id, message = register_user(username, email, password, confirm_password, security_question, security_answer)
28
+
29
+ if user_id:
30
+ flash(message, "success")
31
+ # Rediriger vers la page de connexion après l'inscription
32
+ return redirect(url_for('user_bp.connexion'))
33
+ else:
34
+ flash(message, "error")
35
+ return render_template("inscription.html",
36
+ username=username,
37
+ email=email,
38
+ security_question=security_question,
39
+ security_answer=security_answer)
40
+
41
+ return render_template("inscription.html")
42
+
43
+ @user_bp.route("/connexion")
44
+ def connexion():
45
+ return render_template("connexion.html")
46
+
47
+ @user_bp.route("/deconnexion")
48
+ @login_required
49
+ def deconnexion():
50
+ # Déconnexion en vidant la session
51
+ session.pop('user_id', None)
52
+ flash("Vous êtes déconnecté avec succès.", "success")
53
+ # Redirection vers la page d'accueil ou de connexion
54
+ return redirect(url_for('web_bp.index'))
55
+
56
+ @user_bp.route("/mot-de-passe-oublie", methods=['GET', 'POST'])
57
+ def mot_de_passe_oublie():
58
+ if request.method == 'POST':
59
+ username_or_email = request.form.get("username_or_email")
60
+ security_answer = request.form.get("security_answer")
61
+ new_password = request.form.get("new_password")
62
+ confirm_password = request.form.get("confirm_password")
63
+
64
+ # Validation rapide
65
+ if new_password != confirm_password:
66
+ flash("Les nouveaux mots de passe ne correspondent pas.", "error")
67
+ return render_template("mot_de_passe_oublie.html", username_or_email=username_or_email)
68
+
69
+ success, message = reset_password_via_security_question(username_or_email, security_answer, new_password)
70
+
71
+ if success:
72
+ flash(message, "success")
73
+ return redirect(url_for('user_bp.connexion'))
74
+ else:
75
+ flash(message, "error")
76
+ return render_template("mot_de_passe_oublie.html", username_or_email=username_or_email)
77
+
78
+ return render_template("mot_de_passe_oublie.html")
79
+
80
+
81
+ # --- Routes du Dashboard ---
82
+
83
+ @user_bp.route("/dashboard")
84
+ @login_required
85
+ def dashboard():
86
+ """
87
+ Page du tableau de bord. Gère le message de succès de paiement (Phase 4).
88
+ """
89
+ user = get_user_by_id(session.get('user_id'))
90
+
91
+ # Logique pour le succès de paiement
92
+ payment_status = request.args.get('payment')
93
+
94
+ if payment_status == 'success':
95
+ # On flashe un message pour l'afficher via Jinja dans le dashboard.html
96
+ flash("Félicitations ! Votre abonnement a été activé avec succès.", "success")
97
+ # Redirection pour nettoyer l'URL du paramètre de paiement
98
+ return redirect(url_for('user_bp.dashboard'))
99
+
100
+ return render_template("dashboard.html", user=user)
101
+
102
+ @user_bp.route("/profile")
103
+ @login_required
104
+ def profile():
105
+ user = get_user_by_id(session.get('user_id'))
106
+ return render_template("profile.html", user=user)
107
+
108
+
109
+ @user_bp.route("/api-key")
110
+ @login_required
111
+ def api_key():
112
+ """Page d'affichage de la clé API unique. Retire la variable max_keys."""
113
+ user = get_user_by_id(session.get('user_id'))
114
+ return render_template("api_key.html", user=user)
web_routes.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # web_routes.py
2
+
3
+ from flask import Blueprint, render_template, request, redirect, url_for
4
+ from auth_backend import get_plan_details # Import nécessaire pour la route /checkout
5
+
6
+ # Création du Blueprint 'web_bp'
7
+ web_bp = Blueprint('web_bp', __name__)
8
+
9
+ @web_bp.route("/")
10
+ def index():
11
+ """Page d'accueil."""
12
+ return render_template("index.html")
13
+
14
+ @web_bp.route("/a-propos")
15
+ def a_propos():
16
+ """Page À Propos."""
17
+ return render_template("a_propos.html")
18
+
19
+ @web_bp.route("/documentation")
20
+ def documentation():
21
+ """Page Documentation."""
22
+ return render_template("documentation.html")
23
+
24
+ @web_bp.route("/tarifs")
25
+ def tarifs():
26
+ """Page Tarifs."""
27
+ return render_template("tarifs.html")
28
+
29
+ @web_bp.route("/checkout")
30
+ def checkout():
31
+ """
32
+ Page de paiement. Récupère le plan ID de l'URL pour l'afficher.
33
+ """
34
+ # Récupère 'plan' du paramètre d'URL /checkout?plan=...
35
+ plan_id = request.args.get('plan')
36
+ plan_details = get_plan_details(plan_id)
37
+
38
+ # Si le plan n'existe pas, ou si le plan est 'free', rediriger vers la page des tarifs
39
+ if not plan_details or plan_id == 'free':
40
+ return redirect(url_for('web_bp.tarifs'))
41
+
42
+ return render_template("checkout.html", plan_id=plan_id, plan=plan_details)
43
+
44
+ @web_bp.route("/support")
45
+ def support():
46
+ """Page Support."""
47
+ return render_template("support.html")
48
+
49
+ @web_bp.route("/mentions-legales")
50
+ def mentions_legales():
51
+ """Page Mentions Légales."""
52
+ return render_template("mentions_legales.html")
53
+
54
+ @web_bp.route("/politique-confidentialite")
55
+ def politique_confidentialite():
56
+ """Page Politique de Confidentialité."""
57
+ return render_template("politique_confidentialite.html")
58
+
59
+ @web_bp.route("/conditions-utilisation")
60
+ def conditions_utilisation():
61
+ """Page Conditions d'Utilisation."""
62
+ return render_template("conditions_utilisation.html")