From 758435fe770555c23c44197487651ee8bd307e5f Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 17:56:20 +0000 Subject: [PATCH] =?UTF-8?q?Corrige=20bugs=20de=20la=20revisi=C3=B3n=20de?= =?UTF-8?q?=20home/views.py=20(dinero,=20bypass=20de=20pago,=20500s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seguridad / dinero: - store_novawow_view: recalcula precios y valida ítems contra la BD (Item) en vez de confiar en el precio/ítems que manda el cliente; guarda en sesión un carrito ya validado para la entrega - *_success_view (x9): @require_paid_stripe verifica el pago real en Stripe por session_id (Stripe lo añade a la success_url) y marca StripeLog.fulfilled para evitar re-entregas; cierra el bypass de ir directo a la success-url sin pagar - gold_character_view: deja de truncar el precio (int(4.99)->4); pasa el precio real Correctitud / 500s: - change_password_view: .get(...,'') evita AttributeError si falta un campo - revive_character_view: lógica de éxito corregida (vacío=éxito, no vacío=error, None=fallo de comunicación) — antes marcaba error como éxito y caía a HTML - store category y recruit_a_friend: guardas para Category.DoesNotExist y cuenta None Co-Authored-By: Claude Opus 4.8 (1M context) --- home/migrations/0013_stripelog_fulfilled.py | 18 +++ home/models.py | 1 + home/pagos_stripe.py | 17 +++ home/views.py | 123 +++++++++++++++----- 4 files changed, 133 insertions(+), 26 deletions(-) create mode 100644 home/migrations/0013_stripelog_fulfilled.py diff --git a/home/migrations/0013_stripelog_fulfilled.py b/home/migrations/0013_stripelog_fulfilled.py new file mode 100644 index 0000000..3cef38e --- /dev/null +++ b/home/migrations/0013_stripelog_fulfilled.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-07-12 17:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0012_alter_accountactivation_salt_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='stripelog', + name='fulfilled', + field=models.BooleanField(default=False), + ), + ] diff --git a/home/models.py b/home/models.py index 9b1218c..3ccbdba 100644 --- a/home/models.py +++ b/home/models.py @@ -296,6 +296,7 @@ class StripeLog(models.Model): mode = models.CharField(max_length=10, choices=[("test", "Test"), ("live", "Live")]) # Modo de Stripe character_name = models.CharField(max_length=100) # Nombre del personaje que compró timestamp = models.DateTimeField(auto_now_add=True) # Fecha y hora + fulfilled = models.BooleanField(default=False) # Si la entrega ya se procesó (evita re-entregas) def __str__(self): return f"{self.username} compró {self.product_name} por {self.amount}€ ({self.mode})" diff --git a/home/pagos_stripe.py b/home/pagos_stripe.py index debec79..3da337b 100644 --- a/home/pagos_stripe.py +++ b/home/pagos_stripe.py @@ -4,8 +4,25 @@ from .models import StripeLog from django.utils.timezone import now stripe.api_key = settings.STRIPE_SECRET_KEY +def is_session_paid(session_id): + """True si la sesión de Stripe existe y está realmente pagada.""" + if not session_id: + return False + try: + s = stripe.checkout.Session.retrieve(session_id) + return bool(s and getattr(s, "payment_status", None) == "paid") + except Exception: + return False + + def create_checkout_session(account_id, username, email, acore_ip, amount, character_name, currency="eur", success_url=None, cancel_url=None, product_name=""): try: + # Añadir el id de sesión a la success_url para poder verificar el pago + # al volver (Stripe sustituye {CHECKOUT_SESSION_ID} por el id real). + if success_url: + sep = "&" if "?" in success_url else "?" + success_url = f"{success_url}{sep}session_id={{CHECKOUT_SESSION_ID}}" + # 🔹 Crear sesión de pago en Stripe session = stripe.checkout.Session.create( payment_method_types=["card"], diff --git a/home/views.py b/home/views.py index ced99b4..b18f3e9 100644 --- a/home/views.py +++ b/home/views.py @@ -8,7 +8,8 @@ from django.contrib.auth import logout from .pagos_sumup import crear_checkout, crear_checkout_battlepay, obtener_checkout from django import forms import stripe -from .pagos_stripe import create_checkout_session +from .pagos_stripe import create_checkout_session, is_session_paid +from functools import wraps from .models import Noticia, ClienteCategoria, ServerSelection, RecruitAFriend, DownloadClientPage, ContentCreator, RecruitReward, ClaimedReward, AccountActivation, SecurityToken, GuildRenameSettings, VoteSite, VoteLog, HomeApiPoints, UnstuckHistory, ReviveHistory, RenamePrice, CustomizePrice, ChangeRacePrice, ChangeFactionPrice, LevelUpPrice, GoldPrice, TransferPrice, Category, Item, StripeLog, LoginAttempt, Pedido from django.views.decorators.csrf import csrf_exempt @@ -26,6 +27,33 @@ from decimal import Decimal, ROUND_HALF_UP # Configuración del logger logger = logging.getLogger(__name__) + +def require_paid_stripe(redirect_to='index'): + """ + Protege las vistas de "éxito" de pago: exige un `session_id` de Stripe + realmente PAGADO en la URL (Stripe lo añade tras el pago) y evita re-entregas + marcando el StripeLog como `fulfilled`. Cierra el bypass de ir directo a la + success-url sin pagar. + """ + def deco(view): + @wraps(view) + def wrapper(request, *args, **kwargs): + session_id = request.GET.get('session_id') + if not is_session_paid(session_id): + messages.error(request, 'No se ha podido verificar el pago.') + return redirect(redirect_to) + log = StripeLog.objects.filter(session_id=session_id).first() + if log is not None and log.fulfilled: + messages.info(request, 'Este pago ya había sido procesado.') + return redirect(redirect_to) + response = view(request, *args, **kwargs) + if log is not None: + log.fulfilled = True + log.save(update_fields=['fulfilled']) + return response + return wrapper + return deco + @csrf_exempt def stripe_webhook(request): payload = request.body @@ -541,7 +569,10 @@ def recruit_a_friend_view(request): if request.method == 'POST': if not username: return JsonResponse({'success': False, 'message': 'Debes estar logueado para reclamar una recompensa.'}) - + + if not account_id or account_status is None: + return JsonResponse({'success': False, 'message': 'No se ha encontrado tu cuenta.'}) + reward_id = request.POST.get('reward_id') character_name = request.POST.get('character') @@ -903,10 +934,10 @@ def change_password_view(request): email = user_data['email'] if request.method == 'POST': - current_password = request.POST.get('cur-password').strip() - new_password = request.POST.get('new-password').strip() - conf_new_password = request.POST.get('conf-new-password').strip() - security_token = request.POST.get('security-token').strip() + current_password = request.POST.get('cur-password', '').strip() + new_password = request.POST.get('new-password', '').strip() + conf_new_password = request.POST.get('conf-new-password', '').strip() + security_token = request.POST.get('security-token', '').strip() # Validar campos vacíos if not current_password or not new_password or not conf_new_password or not security_token: @@ -1321,6 +1352,7 @@ def rename_character_view(request): return render(request, 'account/rename_character.html', {'characters': characters, 'price': price}) +@require_paid_stripe() def rename_success_view(request): # Procesar el comando SOAP tras el pago exitoso character_name = request.session.get('rename_character') @@ -1592,6 +1624,7 @@ def customize_character_view(request): return render(request, 'account/customize_character.html', {'characters': characters, 'price': price}) +@require_paid_stripe() def customize_success_view(request): # Procesar el comando SOAP tras el pago exitoso character_name = request.session.get('customize_character') @@ -1682,6 +1715,7 @@ def change_race_character_view(request): return render(request, 'account/change_race.html', {'characters': characters, 'price': price}) +@require_paid_stripe() def change_race_success_view(request): # Procesar el comando SOAP tras el pago exitoso character_name = request.session.get('change_race_character') @@ -1772,6 +1806,7 @@ def change_faction_character_view(request): return render(request, 'account/change_faction.html', {'characters': characters, 'price': price}) +@require_paid_stripe() def change_faction_success_view(request): # Procesar el comando SOAP tras el pago exitoso character_name = request.session.get('change_faction_character') @@ -1863,6 +1898,7 @@ def level_up_character_view(request): return render(request, 'account/level_up.html', {'characters': characters, 'price': price}) +@require_paid_stripe() def level_up_success_view(request): character_name = request.session.get('levelup_character') if not character_name: @@ -1944,8 +1980,8 @@ def gold_character_view(request): if not gold_price: return JsonResponse({'success': False, 'message': 'Cantidad de oro no válida.'}) - # Convertir el precio a formato que Stripe acepta (en centavos) - stripe_price = int(float(gold_price.price) * 1) + # El precio va en euros (con decimales); el helper lo pasa a céntimos (×100) + stripe_price = gold_price.price # Crear sesión de pago con Stripe success_url = request.build_absolute_uri(reverse('gold-success')) @@ -1981,6 +2017,7 @@ def gold_character_view(request): return render(request, 'account/gold_character.html', {'characters': characters, 'gold_prices': gold_prices}) +@require_paid_stripe() def gold_success_view(request): # Procesar el pago exitoso transaction = request.session.pop('gold_transaction', None) @@ -2129,6 +2166,7 @@ def transfer_character_view(request): +@require_paid_stripe() def transfer_success_view(request): transfer_data = request.session.get('transfer_data') if not transfer_data: @@ -2185,8 +2223,11 @@ def store_novawow_view(request): # Si se está solicitando una categoría específica, devolver solo los ítems de esa categoría if category_name: - items_in_category = Category.objects.get(name=category_name).items.values() - return JsonResponse({'success': True, 'items': list(items_in_category)}) + try: + category = Category.objects.get(name=category_name) + except Category.DoesNotExist: + return JsonResponse({'success': False, 'message': 'Categoría no encontrada'}, status=404) + return JsonResponse({'success': True, 'items': list(category.items.values())}) # Obtener carrito desde la sesión carrito = request.session.get('carrito', []) @@ -2199,25 +2240,49 @@ def store_novawow_view(request): # Obtener datos del body data = json.loads(request.body) character_name = data.get('character') - carrito = data.get('carrito') + carrito_cliente = data.get('carrito') or [] if not character_name: return JsonResponse({'success': False, 'message': 'Por favor, selecciona un personaje.'}) - # ACTUALIZAR EL CARRITO Y FORZAR LA SESIÓN - request.session['carrito'] = carrito - request.session.modified = True # Forzar que Django guarde la sesión inmediatamente - - # RECALCULAR EL PRECIO CON EL CARRITO ACTUALIZADO - total_price = sum(Decimal(item['precio']) for item in carrito) - total_price_with_iva = (total_price * Decimal('1.21')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - # Verificar si el personaje pertenece al usuario if character_name not in characters: - return JsonResponse({'success': False, 'message': 'No tienes permiso para renombrar este personaje.'}) + return JsonResponse({'success': False, 'message': 'No tienes permiso para este personaje.'}) - # Definir siempre `product_description` antes de usarlo - product_description = ", ".join([f"{item.get('nombre', 'Ítem desconocido')} x{item.get('cantidad', 1)}" for item in carrito]) + if not carrito_cliente: + return JsonResponse({'success': False, 'message': 'El carrito está vacío.'}) + + # VALIDAR ÍTEMS Y PRECIOS CONTRA LA BASE DE DATOS (nunca confiar en el + # precio ni en los ítems que manda el cliente). + try: + ids_solicitados = [int(item['id']) for item in carrito_cliente] + except (KeyError, TypeError, ValueError): + return JsonResponse({'success': False, 'message': 'Carrito no válido.'}) + + items_db = {i.item_id: i for i in Item.objects.filter(item_id__in=ids_solicitados)} + + carrito = [] # carrito saneado que se guardará en sesión + total_price = Decimal('0.00') + for item in carrito_cliente: + item_id = int(item['id']) + db_item = items_db.get(item_id) + if not db_item: + return JsonResponse({'success': False, 'message': f'Ítem no disponible en la tienda (ID {item_id}).'}) + try: + cantidad = max(1, int(item.get('cantidad', 1))) + except (TypeError, ValueError): + cantidad = 1 + total_price += db_item.price * cantidad + carrito.append({'id': item_id, 'nombre': db_item.name, 'cantidad': cantidad}) + + total_price_with_iva = (total_price * Decimal('1.21')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + # Guardar en sesión el carrito YA VALIDADO (para la entrega tras el pago) + request.session['carrito'] = carrito + request.session.modified = True + + # Descripción del producto a partir de los datos validados + product_description = ", ".join([f"{it['nombre']} x{it['cantidad']}" for it in carrito]) # Obtener detalles del usuario with connections['acore_auth'].cursor() as cursor: @@ -2266,6 +2331,7 @@ def store_novawow_view(request): from collections import Counter +@require_paid_stripe(redirect_to='store-novawow') def store_novawow_success_view(request): # Procesar el comando SOAP tras el pago exitoso character_name = request.session.get('store_novawow') @@ -2350,13 +2416,17 @@ def revive_character_view(request): command = f".revive {character_name}" response = execute_soap_command(command) - # Si la respuesta es vacía o None, asumir éxito - if response: + # None = fallo de comunicación con el servidor + if response is None: + return JsonResponse({'success': False, 'message': 'Error de comunicación con el servidor. Intente nuevamente más tarde.'}) + + # Respuesta vacía = éxito (el comando .revive no devuelve texto si va bien) + if response.strip() == "": ReviveHistory.objects.create(character_name=character_name, used_at=now()) return JsonResponse({'success': True, 'message': f'El personaje {character_name} ha sido revivido con éxito.'}) - if response is None: - return JsonResponse({'success': False, 'message': 'Error de comunicación con el servidor. Intente nuevamente más tarde.'}) + # Respuesta no vacía = el servidor devolvió un mensaje/error + return JsonResponse({'success': False, 'message': f'{response}'}) return render(request, 'account/revive_character.html', {'characters': characters.keys()}) @@ -2516,6 +2586,7 @@ def rename_guild_view(request): }) +@require_paid_stripe() def guild_rename_success_view(request): # Obtener los datos de la sesión guild_rename_data = request.session.get('guild_rename')