From e9dcf80f6cdb7a183dab5717f5beceec3d39c588 Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 15 May 2020 18:18:19 -0500 Subject: [PATCH] btcpay working! added bitpay client code to source tree to fix a bug fixed a stripe race condition added account balance warning to account balance page --- Pipfile | 1 - Pipfile.lock | 16 +- README.md | 2 +- capsulflask/__init__.py | 2 +- capsulflask/btcpay/__init__.py | 0 capsulflask/btcpay/client.py | 121 ++++++++++ capsulflask/btcpay/exceptions.py | 3 + capsulflask/btcpay/key_utils.py | 68 ++++++ capsulflask/cli.py | 224 +++++++++++------- capsulflask/console.py | 29 ++- capsulflask/db_model.py | 9 +- capsulflask/payment.py | 76 ++++-- .../02_up_accounts_vms_etc.sql | 6 +- capsulflask/static/style.css | 4 + capsulflask/templates/account-balance.html | 7 +- capsulflask/templates/capsul-detail.html | 2 +- capsulflask/templates/stripe.html | 7 +- 17 files changed, 438 insertions(+), 139 deletions(-) create mode 100644 capsulflask/btcpay/__init__.py create mode 100644 capsulflask/btcpay/client.py create mode 100644 capsulflask/btcpay/exceptions.py create mode 100644 capsulflask/btcpay/key_utils.py diff --git a/Pipfile b/Pipfile index 35bcb73..e0edea9 100644 --- a/Pipfile +++ b/Pipfile @@ -28,7 +28,6 @@ stripe = "*" matplotlib = "*" requests = "*" python-dotenv = "*" -bitpay = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 3db94ca..25156ce 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "51691e6158d1c309027d595bc4d26079c368f983223f7a567688dd5a98c865fa" + "sha256": "4f61804f9405ff8f66e41739aeac24a2577f7b64f7d08b93459b568528c4f494" }, "pipfile-spec": 6, "requires": { @@ -24,13 +24,6 @@ "index": "pypi", "version": "==2.4.1" }, - "bitpay": { - "hashes": [ - "sha256:3da21326e5a7e98fae93f7fe4c1b046993cf1d8fba60a6c5049cc15f30026b75" - ], - "index": "pypi", - "version": "==2.6.1910" - }, "blinker": { "hashes": [ "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" @@ -67,13 +60,6 @@ ], "version": "==0.10.0" }, - "ecdsa": { - "hashes": [ - "sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061", - "sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277" - ], - "version": "==0.15" - }, "flask": { "hashes": [ "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", diff --git a/README.md b/README.md index e8b4c7d..f51317c 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ In general, for safety, schema version upgrades should not delete data. Schema v ## how to setup btcpay server -Generate a private key and the accompanying bitpay SIN for the bitpay API client. +Generate a private key and the accompanying bitpay SIN for the btcpay API client. I used this code as an example: https://github.com/bitpay/bitpay-python/blob/master/bitpay/key_utils.py#L6 diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index ae52406..489814b 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -2,13 +2,13 @@ import os import stripe -from bitpay import client as btcpay from dotenv import load_dotenv, find_dotenv from flask import Flask from flask_mail import Mail from flask import render_template from capsulflask import virt_model, cli +from capsulflask.btcpay import client as btcpay load_dotenv(find_dotenv()) diff --git a/capsulflask/btcpay/__init__.py b/capsulflask/btcpay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capsulflask/btcpay/client.py b/capsulflask/btcpay/client.py new file mode 100644 index 0000000..6493af7 --- /dev/null +++ b/capsulflask/btcpay/client.py @@ -0,0 +1,121 @@ +from capsulflask.btcpay.exceptions import * +from capsulflask.btcpay import key_utils +import requests +import json +import re + +class Client: + def __init__(self, api_uri="https://bitpay.com", insecure=False, pem=key_utils.generate_pem(), tokens={}): + self.uri = api_uri + self.verify = not(insecure) + self.pem = pem + self.client_id = key_utils.get_sin_from_pem(pem) + self.tokens = tokens + self.user_agent = 'bitpay-python' + + def pair_pos_client(self, code): + if re.match("^\w{7,7}$", code) is None: + raise BtcPayArgumentError("pairing code is not legal") + payload = {'id': self.client_id, 'pairingCode': code} + response = self.unsigned_request('/tokens', payload) + if response.ok: + self.tokens = self.token_from_response(response.json()) + return self.tokens + self.response_error(response) + + def create_token(self, facade): + payload = {'id': self.client_id, 'facade': facade} + response = self.unsigned_request('/tokens', payload) + if response.ok: + self.tokens = self.token_from_response(response.json()) + return response.json()['data'][0]['pairingCode'] + self.response_error(response) + + def create_invoice(self, params): + self.verify_invoice_params(params['price'], params['currency']) + payload = json.dumps(params) + uri = self.uri + "/invoices" + xidentity = key_utils.get_compressed_public_key_from_pem(self.pem) + xsignature = key_utils.sign(uri + payload, self.pem) + headers = {"content-type": "application/json", 'X-Identity': xidentity, 'X-Signature': xsignature, 'X-accept-version': '2.0.0'} + try: + response = requests.post(uri, data=payload, headers=headers, verify=self.verify) + except Exception as pro: + raise BtcPayConnectionError(pro.args) + if response.ok: + return response.json()['data'] + self.response_error(response) + + def get_invoice(self, invoice_id): + uri = self.uri + "/invoices/" + invoice_id + xidentity = key_utils.get_compressed_public_key_from_pem(self.pem) + xsignature = key_utils.sign(uri, self.pem) + headers = {"content-type": "application/json", 'X-Identity': xidentity, 'X-Signature': xsignature, 'X-accept-version': '2.0.0'} + + try: + response = requests.get(uri, headers=headers, verify=self.verify) + except Exception as pro: + raise BtcPayConnectionError(pro.args) + if response.ok: + return response.json()['data'] + self.response_error(response) + + def verify_tokens(self): + """ + Deprecated, will be made private in 2.4 + """ + xidentity = key_utils.get_compressed_public_key_from_pem(self.pem) + url = self.uri + "/tokens" + xsignature = key_utils.sign(self.uri + "/tokens", self.pem) + headers = {"content-type": "application/json", 'X-Identity': xidentity, 'X-Signature': xsignature, 'X-accept-version': '2.0.0'} + response = requests.get(self.uri + "/tokens", headers=headers, verify=self.verify) + if response.ok: + allTokens = response.json()['data'] + selfKeys = self.tokens.keys() + matchedTokens = [token for token in allTokens for key in selfKeys if token.get(key) == self.tokens.get(key)] + if not matchedTokens: + return False + return True + + def token_from_response(self, responseJson): + """ + Deprecated, will be made private in 2.4 + """ + token = responseJson['data'][0]['token'] + facade = responseJson['data'][0]['facade'] + return {facade: token} + raise BtcPayBtcPayError('%(code)d: %(message)s' % {'code': response.status_code, 'message': response.json()['error']}) + + def verify_invoice_params(self, price, currency): + """ + Deprecated, will be made private in 2.4 + """ + if re.match("^[A-Z]{3,3}$", currency) is None: + raise BtcPayArgumentError("Currency is invalid.") + try: + float(price) + except: + raise BtcPayArgumentError("Price must be formatted as a float") + def response_error(self, response): + raise BtcPayBtcPayError('%(code)d: %(message)s' % {'code': response.status_code, 'message': response.json()['error']}) + + def unsigned_request(self, path, payload=None): + """ + generic btcpay usigned wrapper + passing a payload will do a POST, otherwise a GET + """ + headers = {"content-type": "application/json", "X-accept-version": "2.0.0"} + try: + if payload: + response = requests.post(self.uri + path, verify=self.verify, data=json.dumps(payload), headers=headers) + else: + response = requests.get(self.uri + path, verify=self.verify, headers=headers) + except Exception as pro: + raise BtcPayConnectionError('Connection refused') + return response + + def unsigned_get_request(self, path, payload=None): + """ + Deprecated, will be removed in 2.4 + """ + return self.unsigned_request('/tokens', payload) diff --git a/capsulflask/btcpay/exceptions.py b/capsulflask/btcpay/exceptions.py new file mode 100644 index 0000000..20c8ba7 --- /dev/null +++ b/capsulflask/btcpay/exceptions.py @@ -0,0 +1,3 @@ +class BtcPayArgumentError(Exception): pass +class BtcPayBtcPayError(Exception): pass +class BtcPayConnectionError(Exception): pass diff --git a/capsulflask/btcpay/key_utils.py b/capsulflask/btcpay/key_utils.py new file mode 100644 index 0000000..96ce6f2 --- /dev/null +++ b/capsulflask/btcpay/key_utils.py @@ -0,0 +1,68 @@ +from ecdsa import SigningKey, SECP256k1, VerifyingKey +from ecdsa import util as ecdsaUtil +import binascii +import hashlib + +def generate_pem(): + sk = SigningKey.generate(curve=SECP256k1) + pem = sk.to_pem() + pem = pem.decode("utf-8") + return pem + +def get_sin_from_pem(pem): + public_key = get_compressed_public_key_from_pem(pem) + version = get_version_from_compressed_key(public_key) + checksum = get_checksum_from_version(version) + return base58encode(version + checksum) + +def get_compressed_public_key_from_pem(pem): + vks = SigningKey.from_pem(pem).get_verifying_key().to_string() + bts = binascii.hexlify(vks) + compressed = compress_key(bts) + return compressed + +def sign(message, pem): + message = message.encode() + sk = SigningKey.from_pem(pem) + signed = sk.sign(message, hashfunc=hashlib.sha256, sigencode=ecdsaUtil.sigencode_der) + return binascii.hexlify(signed).decode() + +def base58encode(hexastring): + chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + int_val = int(hexastring, 16) + encoded = encode58("", int_val, chars) + return encoded + +def encode58(string, int_val, chars): + if int_val == 0: + return string + else: + new_val, rem = divmod(int_val, 58) + new_string = (chars[rem]) + string + return encode58(new_string, new_val, chars) + +def get_checksum_from_version(version): + return sha_digest(sha_digest(version))[0:8] + +def get_version_from_compressed_key(key): + sh2 = sha_digest(key) + rphash = hashlib.new('ripemd160') + rphash.update(binascii.unhexlify(sh2)) + rp1 = rphash.hexdigest() + return '0F02' + rp1 + +def sha_digest(hexastring): + return hashlib.sha256(binascii.unhexlify(hexastring)).hexdigest() + +def compress_key(bts): + intval = int(bts, 16) + prefix = find_prefix(intval) + return prefix + bts[0:64].decode("utf-8") + +def find_prefix(intval): + if(intval % 2 == 0): + prefix = '02' + else: + prefix = '03' + return prefix + diff --git a/capsulflask/cli.py b/capsulflask/cli.py index d0dc28c..9195fce 100644 --- a/capsulflask/cli.py +++ b/capsulflask/cli.py @@ -1,7 +1,7 @@ import os import re -from datetime import datetime +from datetime import datetime, timedelta import click from flask.cli import with_appcontext @@ -60,95 +60,158 @@ def sql_script(f, c): model.connection.commit() + @bp.cli.command('cron-task') @with_appcontext def cron_task(): # make sure btcpay payments get completed (in case we miss a webhook), otherwise invalidate the payment - - unresolved_btcpay_invoices = get_model().get_unresolved_btcpay_invoices() - for invoice in unresolved_btcpay_invoices: - invoice_id = invoice.id - invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id) - days = float((datetime.now() - invoice.created).total_seconds())/float(60*60*24) - - if invoice['status'] == "complete": - get_model().btcpay_invoice_resolved(invoice_id, True) - elif days >= 1: - get_model().btcpay_invoice_resolved(invoice_id, False) + print("cron_task: starting clean_up_unresolved_btcpay_invoices") + clean_up_unresolved_btcpay_invoices() + print("cron_task: finished clean_up_unresolved_btcpay_invoices") # notify when funds run out + print("cron_task: starting notify_users_about_account_balance") + notify_users_about_account_balance() + print("cron_task: finished notify_users_about_account_balance") - accounts = get_model().accounts_list() + # make sure vm system and DB are synced + print("cron_task: starting ensure_vms_and_db_are_synced") + ensure_vms_and_db_are_synced() + print("cron_task: finished ensure_vms_and_db_are_synced") + + + +def clean_up_unresolved_btcpay_invoices(): + unresolved_btcpay_invoices = get_model().get_unresolved_btcpay_invoices() + for unresolved_invoice in unresolved_btcpay_invoices: + invoice_id = unresolved_invoice['id'] + btcpay_invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id) + days = float((datetime.now() - unresolved_invoice['created']).total_seconds())/float(60*60*24) + + if btcpay_invoice['status'] == "complete": + print( + f"resolving btcpay invoice {invoice_id} " + f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as completed " + ) + get_model().btcpay_invoice_resolved(invoice_id, True) + elif days >= 1: + print( + f"resolving btcpay invoice {invoice_id} " + f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as invalidated, " + f"btcpay server invoice status: {btcpay_invoice['status']}" + ) + get_model().btcpay_invoice_resolved(invoice_id, False) + +delete_at_account_balance_dollars = -10 + +def get_warning_headline(warning_id, pluralize_capsul): + return dict( + zero_1w= ( + "According to our calculations, your Capsul account will run out of funds before this time next week.\n\n" + ), + zero_1d= ( + "According to our calculations, your Capsul account will run out of funds by this time tomorrow.\n\n" + ), + zero_now= ( + f"You have run out of funds! You will no longer be able to create Capsuls.\n\n" + f"As a courtesy, we'll let your existing Capsul{pluralize_capsul} keep running until your account " + "reaches a -$10 balance, at which point they will be deleted.\n\n" + ), + delete_1w= ( + "You have run out of funds and have not refilled your account.\n\n" + f"As a courtesy, we've let your existing Capsul{pluralize_capsul} keep running. " + f"However, your account will reach a -$10 balance some time next week and your Capsul{pluralize_capsul} " + "will be deleted.\n\n" + ), + delete_1d= ( + "You have run out of funds and have not refilled your account.\n\n" + f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. " + f"However, your account will reach a -$10 balance by this time tomorrow and " + f"your Capsul{pluralize_capsul} will be deleted.\n\n" + f"Last chance to deposit funds now and keep your Capsul{pluralize_capsul} running! " + ), + delete_now= ( + f"Your account reached a -$10 balance and your Capsul{pluralize_capsul} were deleted." + ) + )[warning_id] + +def get_warnings_list(): + return [ + dict( + id='zero_1w', + get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < 0, + get_subject=lambda _: "Capsul One Week Payment Reminder", + get_body=lambda base_url, pluralize_capsul: ( + f"{get_warning_headline('zero_1w', pluralize_capsul)}" + f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + "If you believe you have recieved this message in error, please let us know: support@cyberia.club" + ) + ), + dict( + id='zero_1d', + get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < 0, + get_subject=lambda _: "Capsul One Day Payment Reminder", + get_body=lambda base_url, pluralize_capsul: ( + f"{get_warning_headline('zero_1d', pluralize_capsul)}" + f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + "If you believe you have recieved this message in error, please let us know: support@cyberia.club" + ) + ), + dict( + id='zero_now', + get_active=lambda balance_1w, balance_1d, balance_now: balance_now < 0, + get_subject=lambda _: "Your Capsul Account is No Longer Funded", + get_body=lambda base_url, pluralize_capsul: ( + f"{get_warning_headline('zero_now', pluralize_capsul)}" + f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + f"If you need help decomissioning your Capsul{pluralize_capsul}, " + "would like to request backups, or de-activate your account, please contact: support@cyberia.club" + ) + ), + dict( + id='delete_1w', + get_active=lambda balance_1w, balance_1d, balance_now: balance_1w < delete_at_account_balance_dollars, + get_subject=lambda pluralize_capsul: f"Your Capsul{pluralize_capsul} Will be Deleted In Less Than a Week", + get_body=lambda base_url, pluralize_capsul: ( + f"{get_warning_headline('delete_1w', pluralize_capsul)}" + f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + f"If you need help decomissioning your Capsul{pluralize_capsul}, " + "would like to request backups, or de-activate your account, please contact: support@cyberia.club" + ) + ), + dict( + id='delete_1d', + get_active=lambda balance_1w, balance_1d, balance_now: balance_1d < delete_at_account_balance_dollars, + get_subject=lambda pluralize_capsul: f"Last Chance to Save your Capsul{pluralize_capsul}: Gone Tomorrow", + get_body=lambda base_url, pluralize_capsul: ( + f"{get_warning_headline('delete_1d', pluralize_capsul)}" + f"{base_url}/console/account-balance" + ) + ), + dict( + id='delete_now', + get_active=lambda balance_1w, balance_1d, balance_now: balance_now < delete_at_account_balance_dollars, + get_subject=lambda pluralize_capsul: f"Capsul{pluralize_capsul} Deleted", + get_body=lambda base_url, pluralize_capsul: ( + f"{get_warning_headline('delete_now', pluralize_capsul)}" + ) + ) + ] + +def notify_users_about_account_balance(): + accounts = get_model().all_accounts() for account in accounts: vms = get_model().list_vms_for_account(account['email']) payments = get_model().list_payments_for_account(account['email']) - balance_1w = get_account_balance(vms, payments, datetime.utcnow() + datetime.timedelta(days=7)) - balance_1d = get_account_balance(vms, payments, datetime.utcnow() + datetime.timedelta(days=1)) + balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7)) + balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1)) balance_now = get_account_balance(vms, payments, datetime.utcnow()) current_warning = account['account_balance_warning'] - delete_at_account_balance = -10 - pluralize_capsul = "s" if len(vms) > 1 else "" - warnings = [ - dict( - id='zero_1w', - active=balance_1w < 0, - subject="Capsul One Week Payment Reminder", - body=("According to our calculations, your Capsul account will run out of funds in one week.\n\n" - f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n" - "If you believe you have recieved this message in error, please let us know: support@cyberia.club") - ), - dict( - id='zero_1d', - active=balance_1d < 0, - subject="Capsul One Day Payment Reminder", - body=("According to our calculations, your Capsul account will run out of funds tomorrow.\n\n" - f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n" - "If you believe you have recieved this message in error, please let us know: support@cyberia.club") - ), - dict( - id='zero_now', - active=balance_now < 0, - subject="Your Capsul Account is No Longer Funded", - body=(f"You have run out of funds! You will no longer be able to create Capsul{pluralize_capsul}.\n\n" - f"As a courtesy, we'll let your existing Capsul{pluralize_capsul} keep running until your account " - "reaches a -$10 balance, at which point they will be deleted.\n\n" - f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n" - f"If you need help decomissioning your Capsul{pluralize_capsul}, " - "would like to request backups, or de-activate your account, please contact: support@cyberia.club") - ), - dict( - id='delete_1w', - active=balance_1w < delete_at_account_balance, - subject=f"Your Capsul{pluralize_capsul} Will be Deleted In Less Than a Week", - body=("You have run out of funds and have not refilled your account.\n\n" - f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. " - f"However, your account will reach a -$10 balance some time next week and your Capsul{pluralize_capsul}" - "will be deleted.\n\n" - f"Log in now to re-fill your account! {current_app.config['BASE_URL']}/console/account-balance\n\n" - f"If you need help decomissioning your Capsul{pluralize_capsul}, " - "would like to request backups, or de-activate your account, please contact: support@cyberia.club") - ), - dict( - id='delete_1d', - active=balance_1d < delete_at_account_balance, - subject=f"Last Chance to Save your Capsul{pluralize_capsul}: Gone Tomorrow", - body=("You have run out of funds and have not refilled your account.\n\n" - f"As a courtesy, we have let your existing Capsul{pluralize_capsul} keep running. " - f"However, your account will reach a -$10 balance tomorrow and your Capsul{pluralize_capsul} will be deleted.\n\n" - f"Last chance to deposit funds now and keep your Capsul{pluralize_capsul} running! {current_app.config['BASE_URL']}/console/account-balance") - ), - dict( - id='delete_now', - active=balance_now < delete_at_account_balance, - subject=f"Capsul{pluralize_capsul} Deleted", - body=(f"Your account reached a -$10 balance and your Capsul{pluralize_capsul} were deleted.") - ) - ] - + warnings = get_warnings_list() current_warning_index = -1 if current_warning: for i in range(0, len(warnings)): @@ -157,15 +220,17 @@ def cron_task(): index_to_send = -1 for i in range(0, len(warnings)): - if i > current_warning_index and warnings[i].active: + if i > current_warning_index and warnings[i]['get_active'](balance_1w, balance_1d, balance_now): index_to_send = i if index_to_send > -1: print(f"cron_task: sending {warnings[index_to_send]['id']} warning email to {account['email']}.") + get_body = warnings[index_to_send]['get_body'] + get_subject = warnings[index_to_send]['get_subject'] current_app.config["FLASK_MAIL_INSTANCE"].send( Message( - warnings[index_to_send]['subject'], - body=warnings[index_to_send]['body'], + get_subject(pluralize_capsul), + body=get_body(current_app.config['BASE_URL'], pluralize_capsul), recipients=[account['email']] ) ) @@ -176,6 +241,5 @@ def cron_task(): current_app.config["VIRTUALIZATION_MODEL"].destroy(email=account["email"], id=vm['id']) get_model().delete_vm(email=account["email"], id=vm['id']) - - - # make sure vm system and DB are synced +def ensure_vms_and_db_are_synced(): + print("a") \ No newline at end of file diff --git a/capsulflask/console.py b/capsulflask/console.py index 567a143..03715e2 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -1,6 +1,6 @@ import re import sys -from datetime import datetime +from datetime import datetime, timedelta from flask import Blueprint from flask import flash from flask import current_app @@ -15,6 +15,7 @@ from nanoid import generate from capsulflask.metrics import durations as metric_durations from capsulflask.auth import account_required from capsulflask.db import get_model, my_exec_info_message +from capsulflask import cli bp = Blueprint("console", __name__, url_prefix="/console") @@ -74,18 +75,18 @@ def detail(id): return abort(404, f"{id} doesn't exist.") if vm['deleted']: - return render_template("capsul-detail.html", vm=vm, delete=True, are_you_sure=True) + return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True) if request.method == "POST": if 'are_you_sure' not in request.form or not request.form['are_you_sure']: - return render_template("capsul-detail.html", vm=vm, delete=True, are_you_sure=False) + return render_template("capsul-detail.html", vm=vm, delete=True, deleted=False) else: print(f"deleting {vm['id']} per user request ({session['account']})") current_app.config["VIRTUALIZATION_MODEL"].destroy(email=session['account'], id=id) get_model().delete_vm(email=session['account'], id=id) - return render_template("capsul-detail.html", vm=vm, delete=True, are_you_sure=True) + return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True) else: vm["ipv4"] = double_check_capsul_address(vm["id"], vm["ipv4"]) @@ -277,7 +278,22 @@ def get_account_balance(vms, payments, as_of): @account_required def account_balance(): payments = get_payments() - account_balance = get_account_balance(get_vms(), payments, datetime.utcnow()) + vms = get_vms() + balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7)) + balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1)) + balance_now = get_account_balance(vms, payments, datetime.utcnow()) + + warning_index = -1 + warning_text = "" + warnings = cli.get_warnings_list() + + for i in range(0, len(warnings)): + if warnings[i]['get_active'](balance_1w, balance_1d, balance_now): + warning_index = i + if warning_index > -1: + pluralize_capsul = "s" if len(vms) > 1 else "" + warning_id = warnings[warning_index]['id'] + warning_text = cli.get_warning_headline(warning_id, pluralize_capsul) vms_billed = list() @@ -299,6 +315,7 @@ def account_balance(): "account-balance.html", has_vms=len(vms_billed)>0, vms_billed=vms_billed, + warning_text=warning_text, payments=list(map( lambda x: dict( dollars=x["dollars"], @@ -308,5 +325,5 @@ def account_balance(): payments )), has_payments=len(payments)>0, - account_balance=format(account_balance, '.2f') + account_balance=format(balance_now, '.2f') ) \ No newline at end of file diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 1706133..2a8f91f 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -203,8 +203,11 @@ class DBModel: def get_unresolved_btcpay_invoices(self): - self.cursor.execute("SELECT id, payments.created, email FROM unresolved_btcpay_invoices JOIN payments on payment_id = payment.id") - return list(map(lambda row: dict(id=row[0], created=row[1], email=row[2]), self.cursor.fetchall())) + self.cursor.execute(""" + SELECT unresolved_btcpay_invoices.id, payments.created, payments.dollars, unresolved_btcpay_invoices.email + FROM unresolved_btcpay_invoices JOIN payments on payment_id = payments.id + """) + return list(map(lambda row: dict(id=row[0], created=row[1], dollars=row[2], email=row[3]), self.cursor.fetchall())) def get_account_balance_warning(self, email): self.cursor.execute("SELECT account_balance_warning FROM accounts WHERE email = %s", (email,)) @@ -216,7 +219,7 @@ class DBModel: def all_accounts(self): self.cursor.execute("SELECT email, account_balance_warning FROM accounts") - return list(map(lambda row: dict(row=row[0], account_balance_warning=row[1]), self.cursor.fetchall())) + return list(map(lambda row: dict(email=row[0], account_balance_warning=row[1]), self.cursor.fetchall())) diff --git a/capsulflask/payment.py b/capsulflask/payment.py index 90313ce..ee3da67 100644 --- a/capsulflask/payment.py +++ b/capsulflask/payment.py @@ -2,6 +2,7 @@ import stripe import json import time import decimal +from time import sleep from flask import Blueprint from flask import request @@ -55,7 +56,7 @@ def btcpay_payment(): currency="USD", itemDesc="Capsul Cloud Compute", transactionSpeed="high", - redirectURL=f"{current_app.config['BASE_URL']}/account-balance", + redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance", notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook" )) # print(invoice) @@ -89,16 +90,18 @@ def btcpay_webhook(): dollars = invoice['price'] - if invoice['status'] == "paid" or invoice['status'] == "confirmed": + if invoice['status'] == "paid" or invoice['status'] == "confirmed" or invoice['status'] == "complete": success_account = get_model().consume_payment_session("btcpay", invoice_id, dollars) if success_account: print(f"{success_account} paid ${dollars} successfully (btcpay_invoice_id={invoice_id})") - elif invoice['status'] == "complete": + if invoice['status'] == "complete": get_model().btcpay_invoice_resolved(invoice_id, True) elif invoice['status'] == "expired" or invoice['status'] == "invalid": get_model().btcpay_invoice_resolved(invoice_id, False) + + return {"msg": "ok"}, 200 @@ -122,7 +125,7 @@ def stripe_payment(): success_url=current_app.config['BASE_URL'] + "/payment/stripe/success?session_id={CHECKOUT_SESSION_ID}", cancel_url=current_app.config['BASE_URL'] + "/payment/stripe", payment_method_types=["card"], - customer_email=session["account"], + #customer_email=session["account"], line_items=[ { "name": "Capsul Cloud Compute", @@ -139,6 +142,17 @@ def stripe_payment(): get_model().create_payment_session("stripe", stripe_checkout_session_id, session["account"], dollars) + # We can't do this because stripe requires a bunch of server-authenticated data to be sent in the hash + # of the URL. I briefly looked into reverse-engineering their proprietary javascript in order to try to figure out + # how it works and gave up after I discovered that it would require multiple complex interactions with stripe's + # servers, and it looked like they were trying to make what I was trying to do impossible. + + # I never tried running the stripe proprietary javascript in a headless brower and passing the hash from the + # headless browser to the client, but I suspect it might not work anyway because they probably have thier tracking + # cookie info in there somewhere, and if the cookie doesn't match they may refuse to display the page. + + #return redirect(f"https://checkout.stripe.com/pay/{stripe_checkout_session_id}") + for error in errors: flash(error) @@ -148,6 +162,31 @@ def stripe_payment(): stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"] ) +def validate_stripe_checkout_session(stripe_checkout_session_id): + checkout_session_completed_events = stripe.Event.list( + type='checkout.session.completed', + created={ + # Check for events created in the last half hour + 'gte': int(time.time() - (30 * 60)), + }, + ) + + for event in checkout_session_completed_events.auto_paging_iter(): + checkout_session = event['data']['object'] + + if checkout_session and 'id' in checkout_session and checkout_session['id'] == stripe_checkout_session_id: + cents = checkout_session['display_items'][0]['amount'] + dollars = decimal.Decimal(cents)/100 + + #consume_payment_session deletes the checkout session row and inserts a payment row + # its ok to call consume_payment_session more than once because it only takes an action if the session exists + success_email = get_model().consume_payment_session("stripe", stripe_checkout_session_id, dollars) + + if success_email: + return dict(email=success_email, dollars=dollars) + + return None + @bp.route("/stripe/success", methods=("GET",)) def success(): stripe_checkout_session_id = request.args.get('session_id') @@ -155,29 +194,14 @@ def success(): print("/payment/stripe/success returned 400: missing required URL parameter session_id") abort(400, "missing required URL parameter session_id") else: - checkout_session_completed_events = stripe.Event.list( - type='checkout.session.completed', - created={ - # Check for events created in the last half hour - 'gte': int(time.time() - (30 * 60)), - }, - ) - - for event in checkout_session_completed_events.auto_paging_iter(): - checkout_session = event['data']['object'] - - if checkout_session and 'id' in checkout_session and checkout_session['id'] == stripe_checkout_session_id: - cents = checkout_session['display_items'][0]['amount'] - dollars = decimal.Decimal(cents)/100 - - #consume_payment_session deletes the checkout session row and inserts a payment row - # its ok to call consume_payment_session more than once because it only takes an action if the session exists - success_account = get_model().consume_payment_session("stripe", stripe_checkout_session_id, dollars) - - if success_account: - print(f"{success_account} paid ${dollars} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") - + for _ in range(0, 5): + paid = validate_stripe_checkout_session(stripe_checkout_session_id) + if paid: + print(f"{paid['email']} paid ${paid['dollars']} successfully (stripe_checkout_session_id={stripe_checkout_session_id})") return redirect(url_for("console.account_balance")) + else: + sleep(1) + abort(400, "this checkout session is not paid yet") diff --git a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql index a434fb7..7f98356 100644 --- a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql @@ -103,9 +103,9 @@ INSERT INTO accounts (email) VALUES ('forest.n.johnson@gmail.com'); INSERT INTO payments (email, dollars, created) -VALUES ('forest.n.johnson@gmail.com', 20.00, TO_TIMESTAMP('2020-05-05','YYYY-MM-DDTHH24-MI-SS')); +VALUES ('forest.n.johnson@gmail.com', 20.00, TO_TIMESTAMP('2020-04-05','YYYY-MM-DD')); -INSERT INTO vms (id, email, os, size) -VALUES ('capsul-yi9ffqbjly', 'forest.n.johnson@gmail.com', 'alpine311', 'f1-xx'); +INSERT INTO vms (id, email, os, size, created) +VALUES ('capsul-yi9ffqbjly', 'forest.n.johnson@gmail.com', 'alpine311', 'f1-xx', TO_TIMESTAMP('2020-04-19','YYYY-MM-DD')); UPDATE schemaversion SET version = 2; \ No newline at end of file diff --git a/capsulflask/static/style.css b/capsulflask/static/style.css index 9122197..e496808 100644 --- a/capsulflask/static/style.css +++ b/capsulflask/static/style.css @@ -116,6 +116,10 @@ form { margin: 0; } +pre.wrap { + white-space: normal; +} + label.align { min-width: 10em; } diff --git a/capsulflask/templates/account-balance.html b/capsulflask/templates/account-balance.html index dec3e2a..512e24c 100644 --- a/capsulflask/templates/account-balance.html +++ b/capsulflask/templates/account-balance.html @@ -8,6 +8,11 @@
+ {% if has_vms and has_payments and warning_text != "" %} +
+
{{ warning_text }}
+
+ {% endif %}
{% if has_payments %}
@@ -43,7 +48,7 @@
  • Add funds with Bitcoin/Litecoin/Monero (btcpay)
  • -
  • Cash: email treasurer@cyberia.club
  • +
  • Cash: email treasurer@cyberia.club
  • diff --git a/capsulflask/templates/capsul-detail.html b/capsulflask/templates/capsul-detail.html index 369d9a2..8df6cd3 100644 --- a/capsulflask/templates/capsul-detail.html +++ b/capsulflask/templates/capsul-detail.html @@ -5,7 +5,7 @@ {% block content %} {% if delete %} - {% if are_you_sure %} + {% if deleted %}

    DELETED

    diff --git a/capsulflask/templates/stripe.html b/capsulflask/templates/stripe.html index 2c74101..7a73b60 100644 --- a/capsulflask/templates/stripe.html +++ b/capsulflask/templates/stripe.html @@ -2,7 +2,12 @@ {% block title %}Pay with Stripe{% endblock %} -{% block head %}{% endblock %} +{% block head %} +{% if stripe_checkout_session_id %} + +{% endif %} +{% endblock %} + {% block content %}