syntaxwebsite/app/routes/cryptomus_handler.py

384 lines
16 KiB
Python

import base64
import hashlib
import requests
import json
import string
import random
import logging
import redis_lock
from flask import request, Blueprint, jsonify, make_response, abort, redirect, render_template
from functools import wraps
from datetime import datetime, timedelta
from flask_wtf.csrf import CSRFError
from config import Config
from app.models.cryptomus_invoice import CryptomusInvoice
from app.models.user import User
from app.models.giftcard_key import GiftcardKey
from app.enums.GiftcardType import GiftcardType
from app.enums.CryptomusPaymentStatus import CryptomusPaymentStatus
from app.util import auth
from app.extensions import get_remote_address, db, csrf, user_limiter, redis_controller, limiter
config = Config()
CryptomusHandler = Blueprint('CryptomusHandler', __name__, url_prefix='/cryptomus_service')
StatusStringToEnum = {
"paid": CryptomusPaymentStatus.Paid,
"paid_over": CryptomusPaymentStatus.PaidOver,
"wrong_amount": CryptomusPaymentStatus.WrongAmount,
"process": CryptomusPaymentStatus.Process,
"confirm_check": CryptomusPaymentStatus.ConfirmCheck,
"wrong_amount_waiting": CryptomusPaymentStatus.WrongAmountWaiting,
"check": CryptomusPaymentStatus.Check,
"fail": CryptomusPaymentStatus.Fail,
"cancel": CryptomusPaymentStatus.Cancel,
"system_fail": CryptomusPaymentStatus.SystemFail,
"refund_process": CryptomusPaymentStatus.RefundProcess,
"refund_fail": CryptomusPaymentStatus.RefundFail,
"refund_paid": CryptomusPaymentStatus.RefundPaid,
"locked": CryptomusPaymentStatus.Locked
}
def GenerateSignature( PayloadData : str = "" ) -> str:
"""
Generates a signature for the given payload data
:param PayloadData: The payload data to sign
:returns: str
"""
assert isinstance( PayloadData, str ), "PayloadData must be a string"
return hashlib.md5(
(
base64.b64encode( PayloadData.encode( "utf-8" ) ).decode( "utf-8" ) + config.CRYPTOMUS_API_KEY
).encode( "utf-8" )
).hexdigest()
def VerifySignature( PayloadData : str, Signature : str ) -> bool:
"""
Verifies the signature of the given payload data
:param PayloadData: The payload data to verify
:param Signature: The signature to verify
:returns: bool
"""
assert isinstance( PayloadData, str ), "PayloadData must be a string"
assert isinstance( Signature, str ), "Signature must be a string"
return GenerateSignature( PayloadData ) == Signature
def PerformRequest( Endpoint : str = "/", RequestMethod : str = "GET", PayloadData : dict | list | None = None, RequestTimeout : int = 20 ) -> requests.Response:
"""
Performs a request to the Cryptomus API
:param Endpoint: The endpoint to send the request to
:param RequestMethod: The request method to use
:param PayloadData: The payload data to send with the request on POST requests
:param RequestTimeout: The amount of time before the request times out
:returns: requests.Response
"""
assert isinstance( Endpoint, str ), "Endpoint must be a string"
assert isinstance( RequestMethod, str ), "RequestMethod must be a string"
assert RequestMethod in ["GET", "POST"], "RequestMethod must be either GET or POST"
assert isinstance( PayloadData, (dict, list, type(None)) ), "PayloadData must be a dictionary, list or None"
assert isinstance( RequestTimeout, int ), "RequestTimeout must be an integer"
headers = {
"Content-Type": "application/json",
"merchant": config.CRYPTOMUS_MERCHANT_ID,
}
if RequestMethod == "GET":
headers.update({
"sign": GenerateSignature()
})
return requests.get(
url = f"{config.CRYPTOMUS_API_BASEURL}{Endpoint}",
headers = headers,
timeout = RequestTimeout
)
else:
headers.update({
"sign": GenerateSignature( json.dumps( PayloadData ) )
})
return requests.post(
url = f"{config.CRYPTOMUS_API_BASEURL}{Endpoint}",
headers = headers,
data = json.dumps( PayloadData ),
timeout = RequestTimeout
)
def CryptomusSignatureRequired(f):
"""
Decorator to require a valid Cryptomus signature for a request
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if get_remote_address() != "91.227.144.54":
logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > Invalid remote address: {get_remote_address()}")
abort(404)
if not request.is_json:
logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > Request is not JSON")
abort(400)
PayloadData = request.get_json()
if "sign" not in PayloadData:
logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > sign not found in payload")
abort(400)
# PayloadSignature = PayloadData["sign"]
# del PayloadData["sign"]
# if not VerifySignature( json.dumps( PayloadData ), PayloadSignature ):
# logging.warning(f"cryptomus_handler > CryptomusSignatureRequired > Invalid signature")
# abort(400)
return f(*args, **kwargs)
return decorated_function
def GenerateInvoiceID():
"""
Generates a new invoice ID
:returns: str
"""
NewInvoiceID = ''.join(random.choices(string.ascii_uppercase + string.digits, k=64))
if CryptomusInvoice.query.filter_by( id = NewInvoiceID ).first() is not None:
# This will never happen, but we are handling money here so we need to be sure
return GenerateInvoiceID()
return NewInvoiceID
def ConvertStringStatusToEnum( Status : str ) -> CryptomusPaymentStatus:
"""
Converts a string status to a CryptomusPaymentStatus enum
:param Status: The status to convert
:returns: CryptomusPaymentStatus
"""
assert Status in StatusStringToEnum, "Invalid status"
return StatusStringToEnum[Status]
@CryptomusHandler.errorhandler( 429 )
def RateLimitExceeded( e ):
"""
Rate limit exceeded error handler
"""
return jsonify({ "status": "error", "message": "Rate limit exceeded" }), 429
@CryptomusHandler.errorhandler( CSRFError )
def CSRFError( e ):
"""
CSRF error handler
"""
return jsonify({ "status": "error", "message": "Invalid CSRF Token" }), 400
@CryptomusHandler.route("/invoice_status_callback/<invoice_id>", methods=["POST"])
@CryptomusSignatureRequired
@csrf.exempt
def InvoiceStatusCallback( invoice_id : str ):
"""
Callback for invoice status updates
:param invoice_id: The invoice ID to update the status for
"""
CryptomusInvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if CryptomusInvoiceObj is None:
logging.warning(f"cryptomus_handler > InvoiceStatusCallback > Invoice not found: {invoice_id}")
return jsonify({ "status": "error", "message": "Invoice not found" }), 404
PayloadData = request.get_json()
try:
assert isinstance( PayloadData, dict ), "PayloadData must be a dictionary"
assert "status" in PayloadData, "Status not found in payload"
assert "payment_amount_usd" in PayloadData, "payment_amount_usd not found in payload"
assert "is_final" in PayloadData, "is_final not found in payload"
assert isinstance( PayloadData["payment_amount_usd"], str ), "payment_amount_usd must be a string"
assert isinstance( PayloadData["is_final"], bool ), "is_final must be a boolean"
assert PayloadData["status"] in StatusStringToEnum, "Invalid status"
except Exception as e:
logging.error(f"cryptomus_handler > InvoiceStatusCallback > Validation failed: {e}")
return jsonify({ "status": "error", "message": f"Validation failed: {e}" }), 500
CryptomusInvoiceObj.status = StatusStringToEnum[PayloadData["status"]]
CryptomusInvoiceObj.paid_amount_usd = float(PayloadData["payment_amount_usd"])
CryptomusInvoiceObj.is_final = PayloadData["is_final"]
CryptomusInvoiceObj.updated_at = datetime.utcnow()
db.session.commit()
def GenerateCode():
Code = ""
for i in range(0, 5):
Chunk = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))
Code += Chunk
if i != 4:
Code += "-"
return Code
if CryptomusInvoiceObj.status in [ CryptomusPaymentStatus.Paid, CryptomusPaymentStatus.PaidOver ] and CryptomusInvoiceObj.assigned_key is None and CryptomusInvoiceObj.extra_data == "membership":
NewGiftcardCode : str = GenerateCode()
NewGiftcardObj : GiftcardKey = GiftcardKey(
key = NewGiftcardCode,
type = GiftcardType.Outrageous_BuildersClub,
value = 1
)
db.session.add(NewGiftcardObj)
db.session.commit()
CryptomusInvoiceObj.assigned_key = NewGiftcardObj.key
db.session.commit()
return jsonify({ "status": "success" }), 200
@CryptomusHandler.route("/payment_cancelled/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def PaymentCancelled( invoice_id : str ):
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
AuthenticatedUser : User = auth.GetCurrentUser()
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return redirect(f"/cryptomus_service/view_payment/{invoice_id}")
@CryptomusHandler.route("/payment_success/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def PaymentSuccess( invoice_id : str ):
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
AuthenticatedUser : User = auth.GetCurrentUser()
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return redirect(f"/cryptomus_service/view_payment/{invoice_id}")
@CryptomusHandler.route("/create_payment/membership", methods=["POST"])
@auth.authenticated_required_api
@limiter.limit("5/minute")
@user_limiter.limit("5/minute")
def user_create_payment():
AuthenticatedUser : User = auth.GetCurrentUser()
try:
with redis_lock.Lock(
redis_client = redis_controller,
name = f"cryptomus_handler:user_create_payment:{AuthenticatedUser.id}",
expire = 30,
auto_renewal = True
) as lock:
ActiveUserInvoices : int = CryptomusInvoice.query.filter_by( initiator_id = AuthenticatedUser.id ).filter(
CryptomusInvoice.created_at > datetime.now() - timedelta( minutes = 30 )
).count(
)
if ActiveUserInvoices >= 10:
return jsonify({ "status": "error", "message": "You have created too many payments recently" }), 403
InvoiceOrderID = GenerateInvoiceID()
try:
InvoiceCreateRequest = PerformRequest(
Endpoint = "/v1/payment",
RequestMethod = "POST",
PayloadData = {
"amount": "5",
"currency": "USD",
"order_id": InvoiceOrderID,
"url_return": f"{config.BaseURL}/cryptomus_service/payment_cancelled/{InvoiceOrderID}",
"url_callback": f"{config.BaseURL}/cryptomus_service/invoice_status_callback/{InvoiceOrderID}",
"url_success": f"{config.BaseURL}/cryptomus_service/payment_success/{InvoiceOrderID}",
"is_payment_multiple": True,
"lifetime": 60 * 60 * 12, # 12 hours
"additional_data": "membership"
}
)
except requests.exceptions.RequestException as e:
logging.error(f"cryptomus_handler > user_create_payment > Request failed: {e}")
return jsonify({ "status": "error", "message": "Request failed" }), 500
except Exception as e:
logging.error(f"cryptomus_handler > user_create_payment > Unknown error: {e}")
return jsonify({ "status": "error", "message": "Unknown error" }), 500
if InvoiceCreateRequest.status_code != 200:
logging.error(f"cryptomus_handler > user_create_payment > Request failed: {InvoiceCreateRequest.status_code}, {InvoiceCreateRequest.text}")
return jsonify({ "status": "error", "message": "Request failed" }), 500
InvoiceCreateResponse = InvoiceCreateRequest.json()
try:
assert "state" in InvoiceCreateResponse, "state not found in response"
assert isinstance( InvoiceCreateResponse["state"], int ), "state must be a integer"
assert "result" in InvoiceCreateResponse, "result not found in response"
assert isinstance( InvoiceCreateResponse["result"], dict ), "result must be a dictionary"
except AssertionError as e:
logging.error(f"cryptomus_handler > user_create_payment > Validation failed: {e}")
return jsonify({ "status": "error", "message": f"Request failed" }), 500
if InvoiceCreateResponse["state"] != 0:
logging.error(f"cryptomus_handler > user_create_payment > Request failed: {InvoiceCreateResponse['result']}")
return jsonify({ "status": "error", "message": "Request failed" }), 500
InvoiceResult : dict = InvoiceCreateResponse["result"]
NewCryptomusInvoice = CryptomusInvoice(
id = InvoiceOrderID,
cryptomus_invoice_id = InvoiceResult["uuid"],
initiator_id = AuthenticatedUser.id,
required_amount = 5,
currency = "USD",
status = ConvertStringStatusToEnum( InvoiceResult["status"] ),
expires_at = datetime.utcnow() + timedelta( hours = 12 ),
extra_data = "membership"
)
db.session.add(NewCryptomusInvoice)
db.session.commit()
return jsonify({ "status": "success", "invoice_id": InvoiceOrderID, "cryptomus_invoice_id": InvoiceResult["uuid"], "payment_url": InvoiceResult["url"] }), 200
except AssertionError:
abort( 429 )
@CryptomusHandler.route("/pay_invoice/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def pay_invoice( invoice_id : str ):
AuthenticatedUser : User = auth.GetCurrentUser()
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return redirect( f"https://pay.cryptomus.com/pay/{InvoiceObj.cryptomus_invoice_id}" )
@CryptomusHandler.route("/view_payment/<invoice_id>", methods=["GET"])
@auth.authenticated_required
def view_payment( invoice_id : str ):
AuthenticatedUser : User = auth.GetCurrentUser()
InvoiceObj : CryptomusInvoice = CryptomusInvoice.query.filter_by( id = invoice_id ).first()
if InvoiceObj is None:
return abort(404)
if InvoiceObj.initiator_id != AuthenticatedUser.id:
return abort(404)
return render_template("cryptomus/view_payment.html", InvoiceObj = InvoiceObj)
@CryptomusHandler.route("/dashboard", methods=["GET"])
@auth.authenticated_required
def dashboard():
AuthenticatedUser : User = auth.GetCurrentUser()
PageNumber = max(request.args.get('page', default=1, type=int) , 1)
UserInvoices = CryptomusInvoice.query.filter_by( initiator_id = AuthenticatedUser.id ).order_by( CryptomusInvoice.created_at.desc() ).paginate(
page=PageNumber, per_page=15, error_out=False
)
return render_template("cryptomus/dashboard.html", UserInvoices = UserInvoices)