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
diff --git a/capsulflask/auth.py b/capsulflask/auth.py
index f3731a2..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,6 +109,24 @@ def magiclink(token):
abort(404, f"Token {token} doesn't exist or has already been used.")
+@bp.route("/change-email/", methods=("GET", ))
+def email_change_landing(token):
+ return render_template("change-email-landing.html", 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
+
+ session.clear()
+ session["account"] = new_email
+ session["csrf-token"] = generate()
+
+ return redirect(url_for("console.index"))
+
@bp.route("/logout")
def logout():
session.clear()
diff --git a/capsulflask/console.py b/capsulflask/console.py
index 4b92b04..810a1c0 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,10 +381,9 @@ def get_payments():
-@bp.route("/account-balance")
+@bp.route("/account", methods=("GET",))
@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':
@@ -420,9 +419,9 @@ def account_balance():
months=format(vm_months, '.3f'),
dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f')
))
-
+
return render_template(
- "account-balance.html",
+ "account.html",
has_vms=len(vms_billed)>0,
vms_billed=vms_billed,
warning_text=warning_text,
@@ -437,5 +436,52 @@ def account_balance():
payments
)),
has_payments=len(payments)>0,
- account_balance=format(balance_now, '.2f')
+ account_balance=format(balance_now, '.2f'),
+ csrf_token=session["csrf-token"],
)
+
+
+@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')}")
+
+ 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: {message}")
+
+ 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.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..25fb835 100644
--- a/capsulflask/db_model.py
+++ b/capsulflask/db_model.py
@@ -512,3 +512,52 @@ 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 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()
+ 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."
+
+ 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))
+ 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()
+
+ return new_email, None, None
\ 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_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_email_updates.sql b/capsulflask/schema_migrations/26_up_email_updates.sql
new file mode 100644
index 0000000..96b504f
--- /dev/null
+++ b/capsulflask/schema_migrations/26_up_email_updates.sql
@@ -0,0 +1,7 @@
+CREATE TABLE email_updates (
+ current_email TEXT NOT NULL,
+ new_email TEXT PRIMARY KEY NOT NULL,
+ token TEXT NOT NULL
+);
+
+UPDATE schemaversion SET version = 26;
diff --git a/capsulflask/shared.py b/capsulflask/shared.py
index b712204..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
@@ -104,7 +112,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 +122,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 +132,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 +143,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 +154,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/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-balance.html b/capsulflask/templates/account.html
similarity index 80%
rename from capsulflask/templates/account-balance.html
rename to capsulflask/templates/account.html
index 75ce760..8828521 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 }}
@@ -44,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 %}
@@ -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..95146dc 100644
--- a/capsulflask/templates/base.html
+++ b/capsulflask/templates/base.html
@@ -1,3 +1,4 @@
+
@@ -32,7 +33,7 @@
{% if session["account"] %}
Capsuls
SSH Public Keys
- Account Balance
+ Account
{% endif %}
Support
diff --git a/capsulflask/templates/change-email-landing.html b/capsulflask/templates/change-email-landing.html
new file mode 100644
index 0000000..76ce892
--- /dev/null
+++ b/capsulflask/templates/change-email-landing.html
@@ -0,0 +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
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..b593360 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?
+ 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?
Not now, but email us and we can probably figure something out.