import os import re import sys from datetime import datetime, timedelta import click from flask.cli import with_appcontext from flask import Blueprint from flask import current_app from psycopg2 import ProgrammingError from flask_mail import Message from capsulflask.db import get_model from capsulflask.shared import * from capsulflask.console import get_account_balance bp = Blueprint('cli', __name__) @bp.cli.command('sql') @click.option('-f', help='script filename') @click.option('-c', help='sql command') @with_appcontext def sql_script(f, c): """Run a sql script against the database. script is run 1 command at a time inside a single transaction.""" model = get_model() script = "" if f: filepath = os.path.join(os.getcwd(), f) if not os.path.isfile(filepath): raise f"{filepath} is not a file" with open(filepath, 'rb') as file: script = file.read().decode("utf8") elif c: script = c else: click.echo(f"you must provide sql to run either inline with the -c argument or in a file with the -f argument") return commands = re.split(";\\s+", script) for command in commands: if command.strip() != "": model.cursor.execute(command) if re.match("^\\s*select", command, re.IGNORECASE) is not None: for row in model.cursor.fetchall(): def format_value(x): if isinstance(x, bool): return "true" if x else "false" if not x : return "null" if isinstance(x, datetime): return x.isoformat() return f"{x}" click.echo(", ".join(list(map(format_value, row)))) else: click.echo(f"{model.cursor.rowcount} rows affected.") 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 mylog_info(current_app, "cron_task: starting clean_up_unresolved_btcpay_invoices") clean_up_unresolved_btcpay_invoices() mylog_info(current_app, "cron_task: finished clean_up_unresolved_btcpay_invoices") # notify when funds run out mylog_info(current_app, "cron_task: starting notify_users_about_account_balance") notify_users_about_account_balance() mylog_info(current_app, "cron_task: finished notify_users_about_account_balance") # make sure vm system and DB are synced mylog_info(current_app, "cron_task: starting ensure_vms_and_db_are_synced") ensure_vms_and_db_are_synced() mylog_info(current_app, "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 = None try: btcpay_invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id) except: mylog_error(current_app, f""" error was thrown when contacting btcpay server for invoice {invoice_id}: {my_exec_info_message(sys.exc_info())}""" ) continue days = float((datetime.now() - unresolved_invoice['created']).total_seconds())/float(60*60*24) if btcpay_invoice['status'] == "complete": mylog_info(current_app, 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: mylog_info(current_app, 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) get_model().delete_payment_session("btcpay", invoice_id) 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() + 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'] pluralize_capsul = "s" if len(vms) > 1 else "" warnings = get_warnings_list() current_warning_index = -1 if current_warning: for i in range(0, len(warnings)): if warnings[i]['id'] == current_warning: current_warning_index = i index_to_send = -1 for i in range(0, len(warnings)): 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: mylog_info(current_app, 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( get_subject(pluralize_capsul), body=get_body(current_app.config['BASE_URL'], pluralize_capsul), sender=current_app.config["MAIL_DEFAULT_SENDER"], recipients=[account['email']] ) ) get_model().set_account_balance_warning(account['email'], warnings[index_to_send]['id']) if index_to_send == len(warnings)-1: for vm in vms: mylog_warning(current_app, f"cron_task: deleting {vm['id']} ( {account['email']} ) due to negative account balance.") current_app.config["HUB_MODEL"].destroy(email=account["email"], id=vm['id']) get_model().delete_vm(email=account["email"], id=vm['id']) def ensure_vms_and_db_are_synced(): db_ids = get_model().all_non_deleted_vm_ids() virt_ids = current_app.config["HUB_MODEL"].list_ids() db_ids_dict = dict() virt_ids_dict = dict() for id in db_ids: db_ids_dict[id] = True for id in virt_ids: virt_ids_dict[id] = True errors = list() for id in db_ids_dict: if id not in virt_ids_dict: errors.append(f"{id} is in the database but not in the virtualization model") for id in virt_ids_dict: if id not in db_ids_dict: errors.append(f"{id} is in the virtualization model but not in the database") if len(errors) > 0: email_addresses_raw = current_app.config['ADMIN_EMAIL_ADDRESSES'].split(",") email_addresses = list(filter(lambda x: len(x) > 6, map(lambda x: x.strip(), email_addresses_raw ) )) mylog_info(current_app, f"cron_task: sending inconsistency warning email to {','.join(email_addresses)}:") for error in errors: mylog_info(current_app, f"cron_task: {error}.") current_app.config["FLASK_MAIL_INSTANCE"].send( Message( "Capsul Consistency Check Failed", sender=current_app.config["MAIL_DEFAULT_SENDER"], body="\n".join(errors), recipients=email_addresses ) )