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 %}
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 @@
-
{% 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