From 69e2b1a024bf16af69b447d58115caa8a604f956 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 4 Mar 2023 18:43:09 -0400 Subject: [PATCH 1/7] Added self-service email changes --- capsulflask/auth.py | 21 +++ capsulflask/console.py | 135 +++++++++++------- capsulflask/db.py | 2 +- capsulflask/db_model.py | 39 +++++ capsulflask/payment.py | 2 +- .../26_up_create_email_updates.sql | 7 + capsulflask/shared.py | 10 +- .../{account-balance.html => account.html} | 13 +- capsulflask/templates/base.html | 2 +- capsulflask/templates/changelog.html | 1 + capsulflask/templates/create-capsul.html | 4 +- capsulflask/templates/faq.html | 4 + 12 files changed, 180 insertions(+), 60 deletions(-) create mode 100644 capsulflask/schema_migrations/26_up_create_email_updates.sql rename capsulflask/templates/{account-balance.html => account.html} (83%) diff --git a/capsulflask/auth.py b/capsulflask/auth.py index f3731a2..6b3a44e 100644 --- a/capsulflask/auth.py +++ b/capsulflask/auth.py @@ -109,6 +109,27 @@ def magiclink(token): abort(404, f"Token {token} doesn't exist or has already been used.") +@bp.route("/email//", methods=("GET", )) +def email_change_confirmation(new_email, token): + email = get_model().check_email_change_token(token) + if email is not None: + if get_model().emails_contained_in_row(new_email, email) == True: + get_model().update_email(email, new_email) + + session.clear() + session["account"] = new_email + session["csrf-token"] = generate() + + return redirect(url_for("console.index")) + else: + abort(404, f"Email is invalid.") + else: + # this is here to prevent xss + if token and not re.match(r"^[a-zA-Z0-9_-]+$", token): + token = '___________' + + abort(404, f"Token {token} doesn't exist or has already been used.") + @bp.route("/logout") def logout(): session.clear() diff --git a/capsulflask/console.py b/capsulflask/console.py index 4b92b04..5dcf31b 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -381,61 +381,98 @@ def get_payments(): -@bp.route("/account-balance") +@bp.route("/account", methods=("GET", "POST")) @account_required -def account_balance(): +def account(): - payment_sessions = get_model().list_payment_sessions_for_account(session['account']) - for payment_session in payment_sessions: - if payment_session['type'] == 'btcpay': - poll_btcpay_session(payment_session['id']) + if request.method == "GET": + payment_sessions = get_model().list_payment_sessions_for_account(session['account']) + for payment_session in payment_sessions: + if payment_session['type'] == 'btcpay': + poll_btcpay_session(payment_session['id']) - payments = get_payments() - 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()) + payments = get_payments() + 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 = get_warnings_list() + warning_index = -1 + warning_text = "" + warnings = 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 = get_warning_headline(warning_id, pluralize_capsul) + 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 = get_warning_headline(warning_id, pluralize_capsul) - vms_billed = list() + vms_billed = list() - for vm in get_vms(): - vm_months = get_vm_months_float(vm, datetime.utcnow()) - vms_billed.append(dict( - id=vm["id"], - dollars_per_month=vm["dollars_per_month"], - created=vm["created"].strftime("%b %d %Y"), - deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A", - months=format(vm_months, '.3f'), - dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f') - )) + for vm in get_vms(): + vm_months = get_vm_months_float(vm, datetime.utcnow()) + vms_billed.append(dict( + id=vm["id"], + dollars_per_month=vm["dollars_per_month"], + created=vm["created"].strftime("%b %d %Y"), + deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A", + months=format(vm_months, '.3f'), + dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f') + )) + + if request.method == "POST": + if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: + return abort(418, f"u want tea") + + new_email = request.form["new-email"] + if new_email != session["account"]: + if re.fullmatch('^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$', new_email): + token = generate() + + get_model().insert_email_update_row(new_email, session["account"], token) + + link = f"{current_app.config['BASE_URL']}/auth/email/{new_email}/{token}" + + message = (f"Navigate to {link} to finalize your email change.\n" + "\nIf you didn't request this, ignore this message.") + + if current_app.config["MAIL_SERVER"] != "": + current_app.config["FLASK_MAIL_INSTANCE"].send( + Message( + "Click This Link to change your Capsul email", + sender=current_app.config["MAIL_DEFAULT_SENDER"], + body=message, + recipients=[new_email] + ) + ) + else: + print("[DEBUG] Email change message: " + message) + + + + return redirect(f"{url_for('console.account')}") + + return render_template( - "account-balance.html", - has_vms=len(vms_billed)>0, - vms_billed=vms_billed, - warning_text=warning_text, - btcpay_enabled=current_app.config["BTCPAY_URL"] != "", - btcpay_is_working="BTCPAY_CLIENT" in current_app.config, - payments=list(map( - lambda x: dict( - dollars=x["dollars"], - class_name="invalidated" if x["invalidated"] else "", - created=x["created"].strftime("%b %d %Y") - ), - payments - )), - has_payments=len(payments)>0, - account_balance=format(balance_now, '.2f') - ) + "account.html", + has_vms=len(vms_billed)>0, + vms_billed=vms_billed, + warning_text=warning_text, + btcpay_enabled=current_app.config["BTCPAY_URL"] != "", + btcpay_is_working="BTCPAY_CLIENT" in current_app.config, + payments=list(map( + lambda x: dict( + dollars=x["dollars"], + class_name="invalidated" if x["invalidated"] else "", + created=x["created"].strftime("%b %d %Y") + ), + payments + )), + has_payments=len(payments)>0, + account_balance=format(balance_now, '.2f'), + csrf_token=session["csrf-token"], + ) + + diff --git a/capsulflask/db.py b/capsulflask/db.py index 72b3930..970d498 100644 --- a/capsulflask/db.py +++ b/capsulflask/db.py @@ -50,7 +50,7 @@ def init_app(app, is_running_server): hasSchemaVersionTable = False actionWasTaken = False schemaVersion = 0 - desiredSchemaVersion = 25 + desiredSchemaVersion = 26 cursor = connection.cursor() diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index bb68a29..6ca032a 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -512,3 +512,42 @@ class DBModel: def set_broadcast_message(self, message): self.cursor.execute("DELETE FROM broadcast_message; INSERT INTO broadcast_message (message) VALUES (%s)", (message, )) self.connection.commit() + + + def check_email_change_token(self, token): + self.cursor.execute("SELECT current_email FROM email_updates WHERE token = %s", (token, )) + row = self.cursor.fetchone() + if row: + email = row[0] + self.connection.commit() + return email + return None + + def emails_contained_in_row(self, new_email, old_email): + self.cursor.execute("SELECT current_email from email_updates WHERE current_email = %s AND new_email = %s", (old_email, new_email)) + row = self.cursor.fetchone() + if row: + self.connection.commit() + return True + self.connection.commit() + return False + + def insert_email_update_row(self, new_email, old_email, token): + self.cursor.execute("INSERT INTO email_updates (current_email, new_email, token) VALUES (%s, %s, %s)", (old_email, new_email, token)) + self.connection.commit() + pass + + def update_email(self, old_email, new_email): + self.cursor.execute("DELETE FROM email_updates WHERE current_email = %s", (old_email, )) + self.cursor.execute("UPDATE accounts SET lower_case_email = %s WHERE email = %s", (new_email.lower(), old_email.lower())) + self.cursor.execute("UPDATE accounts SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE login_tokens SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE operations SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE payment_sessions SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE payments SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE ssh_public_keys SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE unresolved_btcpay_invoices SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE vm_ssh_authorized_key SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE vm_ssh_host_key SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE vms SET email = %s WHERE email = %s", (new_email, old_email)) + self.connection.commit() \ No newline at end of file diff --git a/capsulflask/payment.py b/capsulflask/payment.py index badf03a..5dd6559 100644 --- a/capsulflask/payment.py +++ b/capsulflask/payment.py @@ -74,7 +74,7 @@ def btcpay_payment(): currency="USD", itemDesc="Capsul Cloud Compute", transactionSpeed="high", - redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance", + redirectURL=f"{current_app.config['BASE_URL']}/console/account", notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook" )) except: diff --git a/capsulflask/schema_migrations/26_up_create_email_updates.sql b/capsulflask/schema_migrations/26_up_create_email_updates.sql new file mode 100644 index 0000000..9963d7c --- /dev/null +++ b/capsulflask/schema_migrations/26_up_create_email_updates.sql @@ -0,0 +1,7 @@ +CREATE TABLE email_updates ( + current_email TEXT PRIMARY KEY NOT NULL, + new_email TEXT NOT NULL, + token TEXT NOT NULL +); + +UPDATE schemaversion SET version = 26; diff --git a/capsulflask/shared.py b/capsulflask/shared.py index b712204..2a9b0dd 100644 --- a/capsulflask/shared.py +++ b/capsulflask/shared.py @@ -104,7 +104,7 @@ def get_warnings_list(): 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" + f"Log in now to re-fill your account! {base_url}/console/account\n\n" "If you believe you have recieved this message in error, please let us know: support@cyberia.club" ) ), @@ -114,7 +114,7 @@ def get_warnings_list(): 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" + f"Log in now to re-fill your account! {base_url}/console/account\n\n" "If you believe you have recieved this message in error, please let us know: support@cyberia.club" ) ), @@ -124,7 +124,7 @@ def get_warnings_list(): 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"Log in now to re-fill your account! {base_url}/console/account\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" ) @@ -135,7 +135,7 @@ def get_warnings_list(): 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"Log in now to re-fill your account! {base_url}/console/account\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" ) @@ -146,7 +146,7 @@ def get_warnings_list(): 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" + f"{base_url}/console/account" ) ), dict( diff --git a/capsulflask/templates/account-balance.html b/capsulflask/templates/account.html similarity index 83% rename from capsulflask/templates/account-balance.html rename to capsulflask/templates/account.html index 75ce760..ddbad1a 100644 --- a/capsulflask/templates/account-balance.html +++ b/capsulflask/templates/account.html @@ -1,8 +1,9 @@ {% extends 'base.html' %} -{% block title %}Account Balance{% endblock %} +{% block title %}Account{% endblock %} {% block content %} +

Account Balance: ${{ account_balance }}

@@ -102,7 +103,17 @@ {% endif %} + +

Current Account Email: {{ session["account"] }}

+
+ + + + + +
+

Once you click 'Change', you will receive an email at your new address.

{% endblock %} {% block pagesource %}/templates/create-capsul.html{% endblock %} diff --git a/capsulflask/templates/base.html b/capsulflask/templates/base.html index 036b402..e36af6f 100644 --- a/capsulflask/templates/base.html +++ b/capsulflask/templates/base.html @@ -32,7 +32,7 @@ {% if session["account"] %} Capsuls SSH Public Keys - Account Balance + Account {% endif %} Support diff --git a/capsulflask/templates/changelog.html b/capsulflask/templates/changelog.html index ad6d246..ef63cbb 100644 --- a/capsulflask/templates/changelog.html +++ b/capsulflask/templates/changelog.html @@ -8,6 +8,7 @@ {% block subcontent %}

    +
  • 2023-01-20: Added support for users to change their own email addresses.
  • 2022-07-18: Add NixOS support
  • 2022-02-11: Added the /add-ssh-key-to-existing-capsul page
      diff --git a/capsulflask/templates/create-capsul.html b/capsulflask/templates/create-capsul.html index 57be57a..dd20fac 100644 --- a/capsulflask/templates/create-capsul.html +++ b/capsulflask/templates/create-capsul.html @@ -23,12 +23,12 @@ * all VMs come standard with one public IPv4 address * vms are billed for a minimum of 1 hour upon creation
      -    Your current account balance: ${{ account_balance }} {% if account_balance != account_balance_in_one_month  %} 
      +    Your current account balance: ${{ account_balance }} {% if account_balance != account_balance_in_one_month  %} 
       
           Projected account balance in one month: ${{ account_balance_in_one_month }}{% endif %}
         
      {% if cant_afford %} -

      Please fund your account in order to create Capsuls

      +

      Please fund your account in order to create Capsuls

      (At least two hours worth of funding is required)

      {% elif no_ssh_public_keys %}

      You don't have any ssh public keys yet.

      diff --git a/capsulflask/templates/faq.html b/capsulflask/templates/faq.html index efe8ffa..eae5b91 100644 --- a/capsulflask/templates/faq.html +++ b/capsulflask/templates/faq.html @@ -69,6 +69,10 @@ $ doas su - We promise to keep your machines running smoothly. If you lose access to your VM, that's on you.

      +
    • + How do backups work? +

      Your VMs are backed up daily, and backups are kept for two days. To restore a backup, contact support.

      +
    • Do you offer refunds?

      Not now, but email us and we can probably figure something out.

      -- 2.40.1 From 01931766cd4d0c1405db46c6eeee8e822c3d44b4 Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 24 Mar 2023 12:25:44 -0500 Subject: [PATCH 2/7] forests edits on change email feature --- capsulflask/auth.py | 41 ++--- capsulflask/console.py | 173 +++++++++--------- capsulflask/db_model.py | 47 +++-- capsulflask/shared.py | 8 + .../templates/change-email-landing.html | 18 ++ 5 files changed, 170 insertions(+), 117 deletions(-) create mode 100644 capsulflask/templates/change-email-landing.html diff --git a/capsulflask/auth.py b/capsulflask/auth.py index 6b3a44e..4bc8c60 100644 --- a/capsulflask/auth.py +++ b/capsulflask/auth.py @@ -16,6 +16,7 @@ from flask_mail import Message from werkzeug.exceptions import abort from capsulflask.db import get_model +from capsulflask.shared import validate_email bp = Blueprint("auth", __name__, url_prefix="/auth") @@ -53,10 +54,9 @@ def login(): email = request.form["email"] errors = list() - if not email: - errors.append("email is required") - elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0: - errors.append("enter a valid email address") + email_error = validate_email(email) + if email_error != None: + errors.append(email_error) if len(errors) == 0: result = get_model().login(email) @@ -109,26 +109,23 @@ def magiclink(token): abort(404, f"Token {token} doesn't exist or has already been used.") -@bp.route("/email//", methods=("GET", )) -def email_change_confirmation(new_email, token): - email = get_model().check_email_change_token(token) - if email is not None: - if get_model().emails_contained_in_row(new_email, email) == True: - get_model().update_email(email, new_email) +@bp.route("/change-email/", methods=("GET", )) +def email_change_landing(token): + return render_template("change-email-landing.html", token=token) - session.clear() - session["account"] = new_email - session["csrf-token"] = generate() - - return redirect(url_for("console.index")) - else: - abort(404, f"Email is invalid.") - else: - # this is here to prevent xss - if token and not re.match(r"^[a-zA-Z0-9_-]+$", token): - token = '___________' +@bp.route("/change-email/", methods=("POST",)) +@account_required +def email_change_confirmation(token): + new_email, status_code, error_message = get_model().try_update_email(session["account"], token) + if error_message: + abort(status_code, error_message) + return - abort(404, f"Token {token} doesn't exist or has already been used.") + session.clear() + session["account"] = new_email + session["csrf-token"] = generate() + + return redirect(url_for("console.index")) @bp.route("/logout") def logout(): diff --git a/capsulflask/console.py b/capsulflask/console.py index 5dcf31b..e375c31 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -17,7 +17,7 @@ from nanoid import generate from capsulflask.metrics import metric_durations from capsulflask.auth import account_required from capsulflask.db import get_model -from capsulflask.shared import my_exec_info_message, get_warnings_list, get_warning_headline, get_vm_months_float, get_account_balance, average_number_of_days_in_a_month +from capsulflask.shared import my_exec_info_message, get_warnings_list, get_warning_headline, get_vm_months_float, get_account_balance, validate_email, average_number_of_days_in_a_month from capsulflask.payment import poll_btcpay_session, check_if_shortterm_flag_can_be_unset from capsulflask import cli @@ -381,98 +381,107 @@ def get_payments(): -@bp.route("/account", methods=("GET", "POST")) +@bp.route("/account", methods=("GET",)) @account_required def account(): + payment_sessions = get_model().list_payment_sessions_for_account(session['account']) + for payment_session in payment_sessions: + if payment_session['type'] == 'btcpay': + poll_btcpay_session(payment_session['id']) - if request.method == "GET": - payment_sessions = get_model().list_payment_sessions_for_account(session['account']) - for payment_session in payment_sessions: - if payment_session['type'] == 'btcpay': - poll_btcpay_session(payment_session['id']) + payments = get_payments() + 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()) - payments = get_payments() - 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 = get_warnings_list() - warning_index = -1 - warning_text = "" - warnings = 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 = get_warning_headline(warning_id, pluralize_capsul) - 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 = get_warning_headline(warning_id, pluralize_capsul) + vms_billed = list() - vms_billed = list() - - for vm in get_vms(): - vm_months = get_vm_months_float(vm, datetime.utcnow()) - vms_billed.append(dict( - id=vm["id"], - dollars_per_month=vm["dollars_per_month"], - created=vm["created"].strftime("%b %d %Y"), - deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A", - months=format(vm_months, '.3f'), - dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f') - )) + for vm in get_vms(): + vm_months = get_vm_months_float(vm, datetime.utcnow()) + vms_billed.append(dict( + id=vm["id"], + dollars_per_month=vm["dollars_per_month"], + created=vm["created"].strftime("%b %d %Y"), + deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A", + months=format(vm_months, '.3f'), + dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f') + )) - if request.method == "POST": - if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: - return abort(418, f"u want tea") - - new_email = request.form["new-email"] + return render_template( + "account.html", + has_vms=len(vms_billed)>0, + vms_billed=vms_billed, + warning_text=warning_text, + btcpay_enabled=current_app.config["BTCPAY_URL"] != "", + btcpay_is_working="BTCPAY_CLIENT" in current_app.config, + payments=list(map( + lambda x: dict( + dollars=x["dollars"], + class_name="invalidated" if x["invalidated"] else "", + created=x["created"].strftime("%b %d %Y") + ), + payments + )), + has_payments=len(payments)>0, + account_balance=format(balance_now, '.2f'), + csrf_token=session["csrf-token"], + ) - if new_email != session["account"]: - if re.fullmatch('^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$', new_email): - token = generate() - - get_model().insert_email_update_row(new_email, session["account"], token) - - link = f"{current_app.config['BASE_URL']}/auth/email/{new_email}/{token}" - - message = (f"Navigate to {link} to finalize your email change.\n" + "\nIf you didn't request this, ignore this message.") - - if current_app.config["MAIL_SERVER"] != "": - current_app.config["FLASK_MAIL_INSTANCE"].send( - Message( - "Click This Link to change your Capsul email", - sender=current_app.config["MAIL_DEFAULT_SENDER"], - body=message, - recipients=[new_email] - ) - ) - else: - print("[DEBUG] Email change message: " + message) - - - + +@bp.route("/account", methods=("POST",)) +@account_required +def change_email_address(): + if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: + return abort(418, f"u want tea") + + new_email = request.form["new-email"] + + email_error = validate_email(new_email) + if email_error != None: + flash(email_error) return redirect(f"{url_for('console.account')}") - - return render_template( - "account.html", - has_vms=len(vms_billed)>0, - vms_billed=vms_billed, - warning_text=warning_text, - btcpay_enabled=current_app.config["BTCPAY_URL"] != "", - btcpay_is_working="BTCPAY_CLIENT" in current_app.config, - payments=list(map( - lambda x: dict( - dollars=x["dollars"], - class_name="invalidated" if x["invalidated"] else "", - created=x["created"].strftime("%b %d %Y") - ), - payments - )), - has_payments=len(payments)>0, - account_balance=format(balance_now, '.2f'), - csrf_token=session["csrf-token"], + token = generate() + + email_update_error = get_model().try_insert_email_update_row(session['account'], new_email, token) + if email_update_error != None: + flash(email_update_error) + return redirect(f"{url_for('console.account')}") + + link = f"{current_app.config['BASE_URL']}/auth/change-email/{token}" + + message = (f"Navigate to {link} to finalize your email change.\n" + "\nIf you didn't request this, you may ignore this message.") + + if current_app.config["MAIL_SERVER"] != "": + current_app.config["FLASK_MAIL_INSTANCE"].send( + Message( + "Click This Link to change your Capsul email", + sender=current_app.config["MAIL_DEFAULT_SENDER"], + body=message, + recipients=[new_email] + ) ) + current_app.logger.info(f"Email change {session['account']} -> {new_email} email was sent.") + + flash(f"An email has been sent to {new_email}. Click the link in that email to finalize your email change.") + else: + current_app.logger.info(f"Email change [{session['account']} -> {new_email}] message: {body}") + + flash(f"No email server is configured. The email message was printed to the log.") + + return redirect(f"{url_for('console.account')}") diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 6ca032a..d21ce5f 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -514,14 +514,7 @@ class DBModel: self.connection.commit() - def check_email_change_token(self, token): - self.cursor.execute("SELECT current_email FROM email_updates WHERE token = %s", (token, )) - row = self.cursor.fetchone() - if row: - email = row[0] - self.connection.commit() - return email - return None + def emails_contained_in_row(self, new_email, old_email): self.cursor.execute("SELECT current_email from email_updates WHERE current_email = %s AND new_email = %s", (old_email, new_email)) @@ -532,14 +525,40 @@ class DBModel: self.connection.commit() return False - def insert_email_update_row(self, new_email, old_email, token): + def try_insert_email_update_row(self, old_email, new_email, token): + + self.cursor.execute("SELECT new_email from email_updates WHERE new_email = %s OR new_email = %s", (new_email, new_email.lower())) + already_existing_email_update = self.cursor.fetchone() + self.cursor.execute("SELECT email from accounts WHERE email = %s or lower_case_email = %s", (new_email, new_email.lower())) + already_existing_account = self.cursor.fetchone() + + if already_existing_email_update or already_existing_account: + return "another account is already using that email address" + self.cursor.execute("INSERT INTO email_updates (current_email, new_email, token) VALUES (%s, %s, %s)", (old_email, new_email, token)) self.connection.commit() - pass + return None + + def try_update_email(self, old_email, token): + + self.cursor.execute("SELECT new_email from email_updates WHERE current_email = %s AND token = %s", (old_email, token)) + row = self.cursor.fetchone() + if not row: + # this is here to prevent xss + if token and not re.match(r"^[a-zA-Z0-9_-]+$", token): + token = '___________' + + return None, 404, f"Token '{token}' doesn't exist or has already been used." + + new_email = row[0] + self.cursor.execute("SELECT email from accounts WHERE email = %s or lower_case_email = %s", (new_email, new_email.lower())) + already_existing_account = self.cursor.fetchone() + + if already_existing_account: + return None, 400, "another account is already using that email address." - def update_email(self, old_email, new_email): self.cursor.execute("DELETE FROM email_updates WHERE current_email = %s", (old_email, )) - self.cursor.execute("UPDATE accounts SET lower_case_email = %s WHERE email = %s", (new_email.lower(), old_email.lower())) + self.cursor.execute("UPDATE accounts SET lower_case_email = %s WHERE email = %s", (new_email.lower(), old_email)) self.cursor.execute("UPDATE accounts SET email = %s WHERE email = %s", (new_email, old_email)) self.cursor.execute("UPDATE login_tokens SET email = %s WHERE email = %s", (new_email, old_email)) self.cursor.execute("UPDATE operations SET email = %s WHERE email = %s", (new_email, old_email)) @@ -550,4 +569,6 @@ class DBModel: self.cursor.execute("UPDATE vm_ssh_authorized_key SET email = %s WHERE email = %s", (new_email, old_email)) self.cursor.execute("UPDATE vm_ssh_host_key SET email = %s WHERE email = %s", (new_email, old_email)) self.cursor.execute("UPDATE vms SET email = %s WHERE email = %s", (new_email, old_email)) - self.connection.commit() \ No newline at end of file + self.connection.commit() + + return new_email, None, None \ No newline at end of file diff --git a/capsulflask/shared.py b/capsulflask/shared.py index 2a9b0dd..db7c6f3 100644 --- a/capsulflask/shared.py +++ b/capsulflask/shared.py @@ -60,6 +60,14 @@ def authorized_as_hub(headers): return auth_header_value == current_app.config["HUB_TOKEN"] return False +def validate_email(email): + if not email: + return "email is required" + elif len(email.strip()) < 6 or email.count('@') != 1 or email.split('@')[1].count('.') == 0: + return "enter a valid email address" + + return None + def get_account_balance(vms, payments, as_of): vm_cost_dollars = 0.0 diff --git a/capsulflask/templates/change-email-landing.html b/capsulflask/templates/change-email-landing.html new file mode 100644 index 0000000..baf8b42 --- /dev/null +++ b/capsulflask/templates/change-email-landing.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block title %}changing email...{% endblock %} + +{% block content %} +
      + +
      + +{% endblock %} + +{% block pagesource %}/templates/change-email-landing.html{% endblock %} \ No newline at end of file -- 2.40.1 From 5adcf89d6b43fc70fa50f4cedd1db988271446c3 Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 24 Mar 2023 12:29:43 -0500 Subject: [PATCH 3/7] forests edits on change email feature --- capsulflask/db_model.py | 11 ----------- .../schema_migrations/26_down_email_updates.sql | 3 +++ ...eate_email_updates.sql => 26_up_email_updates.sql} | 0 3 files changed, 3 insertions(+), 11 deletions(-) create mode 100644 capsulflask/schema_migrations/26_down_email_updates.sql rename capsulflask/schema_migrations/{26_up_create_email_updates.sql => 26_up_email_updates.sql} (100%) diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index d21ce5f..25fb835 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -514,17 +514,6 @@ class DBModel: self.connection.commit() - - - def emails_contained_in_row(self, new_email, old_email): - self.cursor.execute("SELECT current_email from email_updates WHERE current_email = %s AND new_email = %s", (old_email, new_email)) - row = self.cursor.fetchone() - if row: - self.connection.commit() - return True - self.connection.commit() - return False - def try_insert_email_update_row(self, old_email, new_email, token): self.cursor.execute("SELECT new_email from email_updates WHERE new_email = %s OR new_email = %s", (new_email, new_email.lower())) diff --git a/capsulflask/schema_migrations/26_down_email_updates.sql b/capsulflask/schema_migrations/26_down_email_updates.sql new file mode 100644 index 0000000..bd4e3a0 --- /dev/null +++ b/capsulflask/schema_migrations/26_down_email_updates.sql @@ -0,0 +1,3 @@ +DROP TABLE email_updates; + +UPDATE schemaversion SET version = 25; diff --git a/capsulflask/schema_migrations/26_up_create_email_updates.sql b/capsulflask/schema_migrations/26_up_email_updates.sql similarity index 100% rename from capsulflask/schema_migrations/26_up_create_email_updates.sql rename to capsulflask/schema_migrations/26_up_email_updates.sql -- 2.40.1 From 2bbbac66faee4719f71a79f290aec6d8c021f697 Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 24 Mar 2023 12:31:31 -0500 Subject: [PATCH 4/7] label for="..." refers to inputs id, not name --- capsulflask/templates/account.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capsulflask/templates/account.html b/capsulflask/templates/account.html index ddbad1a..57860ec 100644 --- a/capsulflask/templates/account.html +++ b/capsulflask/templates/account.html @@ -110,7 +110,7 @@ - +

      Once you click 'Change', you will receive an email at your new address.

      -- 2.40.1 From 46f5a5f41ac1dd6f6f66a40ad0a536f9465653ed Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 24 Mar 2023 12:33:40 -0500 Subject: [PATCH 5/7] support mailto link --- capsulflask/templates/faq.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/capsulflask/templates/faq.html b/capsulflask/templates/faq.html index eae5b91..b593360 100644 --- a/capsulflask/templates/faq.html +++ b/capsulflask/templates/faq.html @@ -69,9 +69,9 @@ $ doas su - We promise to keep your machines running smoothly. If you lose access to your VM, that's on you.

    • -
    • +
    • How do backups work? -

      Your VMs are backed up daily, and backups are kept for two days. To restore a backup, contact support.

      +

      Capsuls are backed up daily, and backups are kept for two days. To restore a backup, contact support: support@cyberia.club.

    • Do you offer refunds? -- 2.40.1 From a7341c889b467fa86b6bea4b3e0b7203f35757df Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 24 Mar 2023 12:59:41 -0500 Subject: [PATCH 6/7] fix bugs --- capsulflask/console.py | 2 +- .../schema_migrations/26_up_email_updates.sql | 4 ++-- capsulflask/static/change-email-landing.js | 7 +++++++ capsulflask/templates/account.html | 2 +- capsulflask/templates/base.html | 1 + capsulflask/templates/change-email-landing.html | 12 +++++------- 6 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 capsulflask/static/change-email-landing.js diff --git a/capsulflask/console.py b/capsulflask/console.py index e375c31..810a1c0 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -478,7 +478,7 @@ def change_email_address(): flash(f"An email has been sent to {new_email}. Click the link in that email to finalize your email change.") else: - current_app.logger.info(f"Email change [{session['account']} -> {new_email}] message: {body}") + current_app.logger.info(f"Email change [{session['account']} -> {new_email}] message: {message}") flash(f"No email server is configured. The email message was printed to the log.") diff --git a/capsulflask/schema_migrations/26_up_email_updates.sql b/capsulflask/schema_migrations/26_up_email_updates.sql index 9963d7c..96b504f 100644 --- a/capsulflask/schema_migrations/26_up_email_updates.sql +++ b/capsulflask/schema_migrations/26_up_email_updates.sql @@ -1,6 +1,6 @@ CREATE TABLE email_updates ( - current_email TEXT PRIMARY KEY NOT NULL, - new_email TEXT NOT NULL, + current_email TEXT NOT NULL, + new_email TEXT PRIMARY KEY NOT NULL, token TEXT NOT NULL ); diff --git a/capsulflask/static/change-email-landing.js b/capsulflask/static/change-email-landing.js new file mode 100644 index 0000000..401989c --- /dev/null +++ b/capsulflask/static/change-email-landing.js @@ -0,0 +1,7 @@ +document.addEventListener("DOMContentLoaded", function(){ + document.querySelector("form").submit() + document.querySelector("form input").style.display = "none"; + const pleaseWaitMessage = document.createElement("p"); + pleaseWaitMessage.textContent = "Please wait while your email change is finalized..." + document.querySelector("form").appendChild(pleaseWaitMessage); +}) \ No newline at end of file diff --git a/capsulflask/templates/account.html b/capsulflask/templates/account.html index 57860ec..8828521 100644 --- a/capsulflask/templates/account.html +++ b/capsulflask/templates/account.html @@ -45,7 +45,7 @@
      • Add funds with Credit/Debit (stripe) -
        • notice: stripe will load nonfree javascript
        +
        • notice: will load stripe's javascript
      • {% if btcpay_enabled %} {% if btcpay_is_working %} diff --git a/capsulflask/templates/base.html b/capsulflask/templates/base.html index e36af6f..95146dc 100644 --- a/capsulflask/templates/base.html +++ b/capsulflask/templates/base.html @@ -1,3 +1,4 @@ + diff --git a/capsulflask/templates/change-email-landing.html b/capsulflask/templates/change-email-landing.html index baf8b42..76ce892 100644 --- a/capsulflask/templates/change-email-landing.html +++ b/capsulflask/templates/change-email-landing.html @@ -1,18 +1,16 @@ {% extends 'base.html' %} +{% block head %} + +{% endblock %} + + {% block title %}changing email...{% endblock %} {% block content %}
        - {% endblock %} {% block pagesource %}/templates/change-email-landing.html{% endblock %} \ No newline at end of file -- 2.40.1 From 15ecd1712798abf0133b22976ef0a4f958973c0e Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 24 Mar 2023 13:00:49 -0500 Subject: [PATCH 7/7] readme fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c53f0bf..b34d8c5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python Flask web application implementing user accounts, payment, and virtual ma ``` # get an instance of postgres running locally on port 5432 # (you don't have to use docker, but we thought this might be the easiest for a how-to example) -docker run --rm -it -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres & +docker run --rm -it -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres # install dependencies sudo apt install pipenv python3-dev libpq-dev -- 2.40.1