From 3ab8298e1888061195dccf2c27ac3bde801ee7dd Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Wed, 11 Sep 2024 08:36:09 +0000 Subject: [PATCH 01/25] use db.create_all() in dev env instead of alembic migrations --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b4f73cbc..481d672d 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,9 @@ install: .PHONY: run -run: migrate ## Run the app +run: ## Run the app . ./dev_env.sh && \ + poetry run python -c 'from hushline import create_app; from hushline.db import db; create_app().app_context().push(); db.create_all()' && \ poetry run flask run --debug -h localhost -p 8080 .PHONY: lint From dae86deca6ae01a7ebc7cf4b920d3ec540aa516a Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Wed, 11 Sep 2024 10:16:38 +0000 Subject: [PATCH 02/25] added basic dev_data.py script to populate the db with test data --- dev_data.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100755 dev_data.py diff --git a/dev_data.py b/dev_data.py new file mode 100755 index 00000000..570f2486 --- /dev/null +++ b/dev_data.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python + +from pprint import pprint + +from hushline import create_app +from hushline.db import db +from hushline.model import User + + +def main() -> None: + create_app().app_context().push() + + user_args = { + "username": "test", + "password": "Test-testtesttesttest-1", + } + + user = User(**user_args) + db.session.add(user) + db.session.commit() + + print("User created:") + pprint(user_args) + + +if __name__ == "__main__": + main() From c72ffa822017d592950ccf4af4dc656a0f50d50a Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Tue, 17 Sep 2024 08:45:57 +0000 Subject: [PATCH 03/25] skip reformatting jinja templates --- .prettierignore | 1 + .prettierrc | 11 ----------- package-lock.json | 14 +------------- package.json | 3 +-- 4 files changed, 3 insertions(+), 26 deletions(-) delete mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore index 099e236b..95a19ccf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ build coverage hushline/static/vendor/* +hushline/templates/* .pytest_cache diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index ec4fe1a6..00000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "overrides": [ - { - "files": "hushline/templates/*.html", - "options": { - "parser": "jinja-template", - "plugins": ["prettier-plugin-jinja-template"] - } - } - ] -} diff --git a/package-lock.json b/package-lock.json index 1cc70234..ac0ff9ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,8 +5,7 @@ "packages": { "": { "devDependencies": { - "prettier": "3.3.3", - "prettier-plugin-jinja-template": "^1.5.0" + "prettier": "3.3.3" } }, "node_modules/prettier": { @@ -14,7 +13,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -24,16 +22,6 @@ "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } - }, - "node_modules/prettier-plugin-jinja-template": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-1.5.0.tgz", - "integrity": "sha512-gLOnCmM8j/psoO+s1L/M3chKmZEO7zrqhodbD+xRONrmOaYhU7Y9gLxlTVm++MeuRt3hA0jev6TmIlNFcr2hYA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "prettier": "^3.0.0" - } } } } diff --git a/package.json b/package.json index 4de6d95b..a32393d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "devDependencies": { - "prettier": "3.3.3", - "prettier-plugin-jinja-template": "^1.5.0" + "prettier": "3.3.3" } } From e8fd5c3f156f7a8d732f3dba858c4b04e66b0e42 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Thu, 12 Sep 2024 15:55:16 +0000 Subject: [PATCH 04/25] split settings into routes/form modules --- .../{settings.py => settings/__init__.py} | 188 ++---------------- hushline/settings/forms.py | 171 ++++++++++++++++ 2 files changed, 187 insertions(+), 172 deletions(-) rename hushline/{settings.py => settings/__init__.py} (79%) create mode 100644 hushline/settings/forms.py diff --git a/hushline/settings.py b/hushline/settings/__init__.py similarity index 79% rename from hushline/settings.py rename to hushline/settings/__init__.py index 83df45f2..fd7ed95f 100644 --- a/hushline/settings.py +++ b/hushline/settings/__init__.py @@ -1,9 +1,7 @@ import asyncio import base64 import io -import re from datetime import UTC, datetime -from typing import Any, Optional import aiohttp import pyotp @@ -20,178 +18,24 @@ session, url_for, ) -from flask_wtf import FlaskForm from werkzeug.wrappers.response import Response -from wtforms import ( - BooleanField, - Field, - FormField, - IntegerField, - PasswordField, - SelectField, - StringField, - TextAreaField, +from wtforms import Field + +from ..crypto import is_valid_pgp_key +from ..db import db +from ..forms import TwoFactorForm +from ..model import Message, SecondaryUsername, SMTPEncryption, User +from ..utils import authentication_required, create_smtp_config +from .forms import ( + ChangePasswordForm, + ChangeUsernameForm, + DirectoryVisibilityForm, + DisplayNameForm, + EmailForwardingForm, + PGPKeyForm, + PGPProtonForm, + ProfileForm, ) -from wtforms.validators import DataRequired, Email, Length -from wtforms.validators import Optional as OptionalField - -from .crypto import is_valid_pgp_key -from .db import db -from .forms import ComplexPassword, TwoFactorForm -from .model import Message, SecondaryUsername, SMTPEncryption, User -from .utils import authentication_required, create_smtp_config - - -class ChangePasswordForm(FlaskForm): - old_password = PasswordField("Old Password", validators=[DataRequired()]) - new_password = PasswordField( - "New Password", - validators=[ - DataRequired(), - Length(min=18, max=128), - ComplexPassword(), - ], - ) - - -class ChangeUsernameForm(FlaskForm): - new_username = StringField("New Username", validators=[DataRequired(), Length(min=4, max=25)]) - - -class SMTPSettingsForm(FlaskForm): - class Meta: - csrf = False - - smtp_server = StringField("SMTP Server", validators=[OptionalField(), Length(max=255)]) - smtp_port = IntegerField("SMTP Port", validators=[OptionalField()]) - smtp_username = StringField("SMTP Username", validators=[OptionalField(), Length(max=255)]) - smtp_password = PasswordField("SMTP Password", validators=[OptionalField(), Length(max=255)]) - smtp_encryption = SelectField( - "SMTP Encryption Protocol", choices=[proto.value for proto in SMTPEncryption] - ) - smtp_sender = StringField("SMTP Sender Address", validators=[Length(max=255)]) - - -class EmailForwardingForm(FlaskForm): - forwarding_enabled = BooleanField("Enable Forwarding", validators=[OptionalField()]) - email_address = StringField("Email Address", validators=[OptionalField(), Length(max=255)]) - custom_smtp_settings = BooleanField("Custom SMTP Settings", validators=[OptionalField()]) - smtp_settings = FormField(SMTPSettingsForm) - - def validate(self, extra_validators: list | None = None) -> bool: - if not FlaskForm.validate(self, extra_validators): - return False - - rv = True - if self.forwarding_enabled.data: - if not self.email_address.data: - self.email_address.errors.append( - "Email address must be specified when forwarding is enabled." - ) - rv = False - if self.custom_smtp_settings.data or not current_app.config["NOTIFICATIONS_ADDRESS"]: - smtp_fields = [ - self.smtp_settings.smtp_sender, - self.smtp_settings.smtp_username, - self.smtp_settings.smtp_server, - self.smtp_settings.smtp_port, - ] - unset_smtp_fields = [field for field in smtp_fields if not field.data] - - def remove_tags(text: str) -> str: - return re.sub("<[^<]+?>", "", text) - - for field in unset_smtp_fields: - field.errors.append( - f"{remove_tags(field.label())} is" - " required if custom SMTP settings are enabled." - ) - rv = False - return rv - - def flattened_errors(self, input: Optional[dict | list] = None) -> list[str]: - errors = input if input else self.errors - if isinstance(errors, list): - return errors - ret = [] - if isinstance(errors, dict): - for error in errors.values(): - ret.extend(self.flattened_errors(error)) - return ret - - -class PGPProtonForm(FlaskForm): - email = StringField( - "", - validators=[DataRequired(), Email()], - render_kw={ - "placeholder": "Search Proton email...", - "id": "proton_email", - "required": True, - }, - ) - - -class PGPKeyForm(FlaskForm): - pgp_key = TextAreaField("Or, Add Your Public PGP Key Manually", validators=[Length(max=100000)]) - - -class DisplayNameForm(FlaskForm): - display_name = StringField("Display Name", validators=[Length(max=100)]) - - -class DirectoryVisibilityForm(FlaskForm): - show_in_directory = BooleanField("Show on public directory") - - -def strip_whitespace(value: Optional[Any]) -> Optional[str]: - if value is not None and hasattr(value, "strip"): - return value.strip() - return value - - -class ProfileForm(FlaskForm): - bio = TextAreaField("Bio", filters=[strip_whitespace], validators=[Length(max=250)]) - extra_field_label1 = StringField( - "Extra Field Label 1", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=50)], - ) - extra_field_value1 = StringField( - "Extra Field Value 1", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=4096)], - ) - extra_field_label2 = StringField( - "Extra Field Label 2", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=50)], - ) - extra_field_value2 = StringField( - "Extra Field Value 2", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=4096)], - ) - extra_field_label3 = StringField( - "Extra Field Label 3", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=50)], - ) - extra_field_value3 = StringField( - "Extra Field Value 3", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=4096)], - ) - extra_field_label4 = StringField( - "Extra Field Label 4", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=50)], - ) - extra_field_value4 = StringField( - "Extra Field Value 4", - filters=[strip_whitespace], - validators=[OptionalField(), Length(max=4096)], - ) def set_field_attribute(input_field: Field, attribute: str, value: str) -> None: diff --git a/hushline/settings/forms.py b/hushline/settings/forms.py new file mode 100644 index 00000000..abddd203 --- /dev/null +++ b/hushline/settings/forms.py @@ -0,0 +1,171 @@ +import re +from typing import Any, Optional + +from flask import current_app +from flask_wtf import FlaskForm +from wtforms import ( + BooleanField, + FormField, + IntegerField, + PasswordField, + SelectField, + StringField, + TextAreaField, +) +from wtforms.validators import DataRequired, Email, Length +from wtforms.validators import Optional as OptionalField + +from ..forms import ComplexPassword +from ..model import SMTPEncryption + + +class ChangePasswordForm(FlaskForm): + old_password = PasswordField("Old Password", validators=[DataRequired()]) + new_password = PasswordField( + "New Password", + validators=[ + DataRequired(), + Length(min=18, max=128), + ComplexPassword(), + ], + ) + + +class ChangeUsernameForm(FlaskForm): + new_username = StringField("New Username", validators=[DataRequired(), Length(min=4, max=25)]) + + +class SMTPSettingsForm(FlaskForm): + class Meta: + csrf = False + + smtp_server = StringField("SMTP Server", validators=[OptionalField(), Length(max=255)]) + smtp_port = IntegerField("SMTP Port", validators=[OptionalField()]) + smtp_username = StringField("SMTP Username", validators=[OptionalField(), Length(max=255)]) + smtp_password = PasswordField("SMTP Password", validators=[OptionalField(), Length(max=255)]) + smtp_encryption = SelectField( + "SMTP Encryption Protocol", choices=[proto.value for proto in SMTPEncryption] + ) + smtp_sender = StringField("SMTP Sender Address", validators=[Length(max=255)]) + + +class EmailForwardingForm(FlaskForm): + forwarding_enabled = BooleanField("Enable Forwarding", validators=[OptionalField()]) + email_address = StringField("Email Address", validators=[OptionalField(), Length(max=255)]) + custom_smtp_settings = BooleanField("Custom SMTP Settings", validators=[OptionalField()]) + smtp_settings = FormField(SMTPSettingsForm) + + def validate(self, extra_validators: list | None = None) -> bool: + if not FlaskForm.validate(self, extra_validators): + return False + + rv = True + if self.forwarding_enabled.data: + if not self.email_address.data: + self.email_address.errors.append( + "Email address must be specified when forwarding is enabled." + ) + rv = False + if self.custom_smtp_settings.data or not current_app.config["NOTIFICATIONS_ADDRESS"]: + smtp_fields = [ + self.smtp_settings.smtp_sender, + self.smtp_settings.smtp_username, + self.smtp_settings.smtp_server, + self.smtp_settings.smtp_port, + ] + unset_smtp_fields = [field for field in smtp_fields if not field.data] + + def remove_tags(text: str) -> str: + return re.sub("<[^<]+?>", "", text) + + for field in unset_smtp_fields: + field.errors.append( + f"{remove_tags(field.label())} is" + " required if custom SMTP settings are enabled." + ) + rv = False + return rv + + def flattened_errors(self, input: Optional[dict | list] = None) -> list[str]: + errors = input if input else self.errors + if isinstance(errors, list): + return errors + ret = [] + if isinstance(errors, dict): + for error in errors.values(): + ret.extend(self.flattened_errors(error)) + return ret + + +class PGPProtonForm(FlaskForm): + email = StringField( + "", + validators=[DataRequired(), Email()], + render_kw={ + "placeholder": "Search Proton email...", + "id": "proton_email", + "required": True, + }, + ) + + +class PGPKeyForm(FlaskForm): + pgp_key = TextAreaField("Or, Add Your Public PGP Key Manually", validators=[Length(max=100000)]) + + +class DisplayNameForm(FlaskForm): + display_name = StringField("Display Name", validators=[Length(max=100)]) + + +class DirectoryVisibilityForm(FlaskForm): + show_in_directory = BooleanField("Show on public directory") + + +def strip_whitespace(value: Optional[Any]) -> Optional[str]: + if value is not None and hasattr(value, "strip"): + return value.strip() + return value + + +class ProfileForm(FlaskForm): + bio = TextAreaField("Bio", filters=[strip_whitespace], validators=[Length(max=250)]) + extra_field_label1 = StringField( + "Extra Field Label 1", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=50)], + ) + extra_field_value1 = StringField( + "Extra Field Value 1", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=4096)], + ) + extra_field_label2 = StringField( + "Extra Field Label 2", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=50)], + ) + extra_field_value2 = StringField( + "Extra Field Value 2", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=4096)], + ) + extra_field_label3 = StringField( + "Extra Field Label 3", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=50)], + ) + extra_field_value3 = StringField( + "Extra Field Value 3", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=4096)], + ) + extra_field_label4 = StringField( + "Extra Field Label 4", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=50)], + ) + extra_field_value4 = StringField( + "Extra Field Value 4", + filters=[strip_whitespace], + validators=[OptionalField(), Length(max=4096)], + ) From ce3fbb1af8aba391529c00086d8018955fe36816 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Fri, 13 Sep 2024 07:03:30 +0000 Subject: [PATCH 05/25] remove secondary users in preparation for refactor --- docs/3-managed-service.md | 6 +++--- ...condary.inbox.png => paid.alias.inbox.png} | 0 hushline/make_admin.py | 17 ++++------------ hushline/model.py | 20 ------------------- hushline/routes.py | 2 -- hushline/settings/__init__.py | 9 +-------- hushline/templates/inbox.html | 10 ---------- .../templates/secondary_user_settings.html | 20 ------------------- 8 files changed, 8 insertions(+), 76 deletions(-) rename docs/img/{paid.secondary.inbox.png => paid.alias.inbox.png} (100%) delete mode 100644 hushline/templates/secondary_user_settings.html diff --git a/docs/3-managed-service.md b/docs/3-managed-service.md index 84dcb109..bfffba72 100644 --- a/docs/3-managed-service.md +++ b/docs/3-managed-service.md @@ -210,8 +210,8 @@ The account's primary inbox will aggregate and label messages in a single view. -#### Secondary Inboxes +#### Aliased Inboxes -Account owners can navigate to their secondary username's inboxes from the settings page. +Account owners can navigate to their aliases' inboxes from the settings page. - + diff --git a/docs/img/paid.secondary.inbox.png b/docs/img/paid.alias.inbox.png similarity index 100% rename from docs/img/paid.secondary.inbox.png rename to docs/img/paid.alias.inbox.png diff --git a/hushline/make_admin.py b/hushline/make_admin.py index 5db463c1..06de475b 100755 --- a/hushline/make_admin.py +++ b/hushline/make_admin.py @@ -4,23 +4,14 @@ from hushline import create_app from hushline.db import db -from hushline.model import SecondaryUsername, User +from hushline.model import User def toggle_admin(username: str) -> None: - # First, try to find a primary user - user = db.session.scalars(db.select(User).filter_by(primary_username=username).limit(1)).first() - - # If not found, try to find a secondary user + user = User.query.filter_by(primary_username=username).one_or_none() if not user: - secondary_username = db.session.scalars( - db.select(SecondaryUsername).filter_by(username=username).limit(1) - ).first() - if secondary_username: - user = secondary_username.primary_user - else: - print("User not found.") - return + print("User not found.") + return # Toggle admin status user.is_admin = not user.is_admin diff --git a/hushline/model.py b/hushline/model.py index c14d7ffb..78f26500 100644 --- a/hushline/model.py +++ b/hushline/model.py @@ -46,10 +46,6 @@ class User(Model): is_admin: Mapped[bool] = mapped_column(default=False) show_in_directory: Mapped[bool] = mapped_column(default=False) bio: Mapped[Optional[str]] = mapped_column(db.Text) - # Corrected the relationship and backref here - secondary_usernames: Mapped[Set["SecondaryUsername"]] = relationship( - backref=db.backref("primary_user", lazy=True) - ) smtp_encryption: Mapped[SMTPEncryption] = mapped_column( db.Enum(SMTPEncryption, native_enum=False), default=SMTPEncryption.StartTLS ) @@ -210,27 +206,11 @@ def __init__( self.timecode = timecode -class SecondaryUsername(Model): - __tablename__ = "secondary_usernames" - - id: Mapped[int] = mapped_column(primary_key=True) - username: Mapped[str] = mapped_column(db.String(80), unique=True) - # This foreign key points to the 'user' table's 'id' field - user_id: Mapped[int] = mapped_column(db.ForeignKey("users.id")) - display_name: Mapped[Optional[str]] = mapped_column(db.String(80)) - - class Message(Model): id: Mapped[int] = mapped_column(primary_key=True) _content: Mapped[str] = mapped_column("content", db.Text) # Encrypted content stored here user_id: Mapped[int] = mapped_column(db.ForeignKey("users.id")) user: Mapped["User"] = relationship(backref=db.backref("messages", lazy=True)) - secondary_user_id: Mapped[Optional[int]] = mapped_column( - db.ForeignKey("secondary_usernames.id") - ) - secondary_username: Mapped[Set["SecondaryUsername"]] = relationship( - "SecondaryUsername", backref="messages" - ) def __init__(self, content: str, user_id: int) -> None: super().__init__() diff --git a/hushline/routes.py b/hushline/routes.py index 64e1939b..55d342c0 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -98,13 +98,11 @@ def inbox() -> Response | str: messages = db.session.scalars( db.select(Message).filter_by(user_id=user.id).order_by(Message.id.desc()) ).all() - secondary_users_dict = {su.id: su for su in user.secondary_usernames} return render_template( "inbox.html", user=user, messages=messages, - secondary_usernames=secondary_users_dict, is_personal_server=app.config["IS_PERSONAL_SERVER"], ) diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index fd7ed95f..59ed4fe1 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -24,7 +24,7 @@ from ..crypto import is_valid_pgp_key from ..db import db from ..forms import TwoFactorForm -from ..model import Message, SecondaryUsername, SMTPEncryption, User +from ..model import Message, SMTPEncryption, User from ..utils import authentication_required, create_smtp_config from .forms import ( ChangePasswordForm, @@ -105,9 +105,6 @@ async def index() -> str | Response: directory_visibility_form = DirectoryVisibilityForm( show_in_directory=user.show_in_directory ) - secondary_usernames = db.session.scalars( - db.select(SecondaryUsername).filter_by(user_id=user.id) - ).all() change_password_form = ChangePasswordForm() change_username_form = ChangeUsernameForm() pgp_proton_form = PGPProtonForm() @@ -237,7 +234,6 @@ async def index() -> str | Response: "settings.html", now=datetime.now(UTC), user=user, - secondary_usernames=secondary_usernames, all_users=all_users, # Pass to the template for admin view email_forwarding_form=email_forwarding_form, change_password_form=change_password_form, @@ -561,9 +557,6 @@ def delete_account() -> Response | str: # Explicitly delete messages for the user db.session.execute(db.delete(Message).filter_by(user_id=user.id)) - # Explicitly delete secondary users if necessary - db.session.execute(db.delete(SecondaryUsername).filter_by(user_id=user.id)) - # Now delete the user db.session.delete(user) db.session.commit() diff --git a/hushline/templates/inbox.html b/hushline/templates/inbox.html index 4a787b03..311e4e52 100644 --- a/hushline/templates/inbox.html +++ b/hushline/templates/inbox.html @@ -10,16 +10,6 @@

Inbox for {{ user.display_name or user.primary_username }}

data-encrypted-content="{{ message.content }}" aria-label="Message with {{ message.user.primary_username or message.user.display_name }}" > - {% if message.secondary_user_id %} - {% set sender_info = secondary_usernames[message.secondary_user_id] %} -

- 📥 {{ sender_info.display_name or sender_info.username }} -

- {% elif secondary_usernames|length > 0 %} -

- 📥 {{ user.display_name or user.primary_username }} -

- {% endif %}

{{ message.content }}

- Settings for - {{ secondary_username.display_name or secondary_username.username }} - - -
- - - -
-{% endblock %} From 3f09a70f2076277f27ebed2fae17dcbf19ba3b46 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Fri, 13 Sep 2024 16:08:15 +0000 Subject: [PATCH 06/25] refactored current code to support multiple usernames --- Makefile | 2 +- dev_data.py | 20 +- hushline/model.py | 150 +++++++---- hushline/routes.py | 203 ++++++--------- hushline/settings/__init__.py | 95 ++++--- hushline/templates/inbox.html | 2 +- hushline/templates/profile.html | 17 +- hushline/templates/settings.html | 20 +- pyproject.toml | 1 + tests/auth_helper.py | 19 +- tests/test_profile.py | 173 +++++-------- tests/test_registration_and_login.py | 160 +++++------- tests/test_settings.py | 370 +++++++++------------------ 13 files changed, 513 insertions(+), 719 deletions(-) diff --git a/Makefile b/Makefile index 481d672d..b3816395 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ install: .PHONY: run run: ## Run the app . ./dev_env.sh && \ - poetry run python -c 'from hushline import create_app; from hushline.db import db; create_app().app_context().push(); db.create_all()' && \ + poetry run python -c 'from hushline import create_app; from hushline.db import db; from sqlalchemy import text; create_app().app_context().push(); db.session.execute(text("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")); db.session.commit(); db.create_all()' && \ poetry run flask run --debug -h localhost -p 8080 .PHONY: lint diff --git a/dev_data.py b/dev_data.py index 570f2486..455b2071 100755 --- a/dev_data.py +++ b/dev_data.py @@ -1,26 +1,24 @@ #!/usr/bin/env python - -from pprint import pprint - from hushline import create_app from hushline.db import db -from hushline.model import User +from hushline.model import User, Username def main() -> None: create_app().app_context().push() - user_args = { - "username": "test", - "password": "Test-testtesttesttest-1", - } + username = "test" + password = "Test-testtesttesttest-1" # noqa: S105 - user = User(**user_args) + user = User(password=password) db.session.add(user) + db.session.flush() + + un = Username(user_id=user.id, _username=username, is_primary=True) + db.session.add(un) db.session.commit() - print("User created:") - pprint(user_args) + print(f"User created:\n username = {username}\n password = {password}") if __name__ == "__main__": diff --git a/hushline/model.py b/hushline/model.py index 78f26500..abc6eebc 100644 --- a/hushline/model.py +++ b/hushline/model.py @@ -1,9 +1,9 @@ import enum import secrets +from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Optional, Set +from typing import TYPE_CHECKING, Any, Generator, Optional, Sequence -from flask import current_app from flask_sqlalchemy.model import Model from passlib.hash import scrypt from sqlalchemy import Index @@ -19,6 +19,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship +@enum.unique class SMTPEncryption(enum.Enum): SSL = "SSL" StartTLS = "StartTLS" @@ -28,28 +29,32 @@ def default(cls) -> "SMTPEncryption": return cls.StartTLS -class User(Model): - __tablename__ = "users" +@dataclass(frozen=True, repr=False, eq=False) +class ExtraField: + label: Optional[str] + value: Optional[str] + is_verified: Optional[bool] + + +class Username(Model): + """ + Class representing a username and associated profile. + This was pulled out of the `User` class so that a `username` could be globally unique among + both users and aliases and enforced at the database level. + """ + + __tablename__ = "usernames" id: Mapped[int] = mapped_column(primary_key=True) - primary_username: Mapped[str] = mapped_column(db.String(80), unique=True) - display_name: Mapped[Optional[str]] = mapped_column(db.String(80)) - _password_hash: Mapped[str] = mapped_column("password_hash", db.String(512)) - _totp_secret: Mapped[Optional[str]] = mapped_column("totp_secret", db.String(255)) - _email: Mapped[Optional[str]] = mapped_column("email", db.String(255)) - _smtp_server: Mapped[Optional[str]] = mapped_column("smtp_server", db.String(255)) - smtp_port: Mapped[Optional[int]] - _smtp_username: Mapped[Optional[str]] = mapped_column("smtp_username", db.String(255)) - _smtp_password: Mapped[Optional[str]] = mapped_column("smtp_password", db.String(255)) - _pgp_key: Mapped[Optional[str]] = mapped_column("pgp_key", db.Text) + user_id: Mapped[int] = mapped_column(db.ForeignKey("users.id")) + user: Mapped["User"] = relationship() + _username: Mapped[str] = mapped_column("username", unique=True) + _display_name: Mapped[Optional[str]] = mapped_column(db.String(80)) + is_primary: Mapped[bool] = mapped_column() is_verified: Mapped[bool] = mapped_column(default=False) - is_admin: Mapped[bool] = mapped_column(default=False) show_in_directory: Mapped[bool] = mapped_column(default=False) bio: Mapped[Optional[str]] = mapped_column(db.Text) - smtp_encryption: Mapped[SMTPEncryption] = mapped_column( - db.Enum(SMTPEncryption, native_enum=False), default=SMTPEncryption.StartTLS - ) - smtp_sender: Mapped[Optional[str]] + extra_field_label1: Mapped[Optional[str]] extra_field_value1: Mapped[Optional[str]] extra_field_label2: Mapped[Optional[str]] @@ -63,6 +68,64 @@ class User(Model): extra_field_verified3: Mapped[Optional[bool]] = mapped_column(default=False) extra_field_verified4: Mapped[Optional[bool]] = mapped_column(default=False) + @property + def username(self) -> str: + return self._username + + @username.setter + def username(self, username: str) -> None: + self._username = username + self.is_verified = False + db.session.commit() + + @property + def display_name(self) -> Optional[str]: + return self._display_name + + @display_name.setter + def display_name(self, display_name: str | None) -> None: + self._display_name = display_name + self.is_verified = False + db.session.commit() + + @property + def extra_fields(self) -> Generator[ExtraField, None, None]: + for i in range(1, 5): + yield ExtraField( + getattr(self, f"extra_field_label{i}", None), + getattr(self, f"extra_field_value{i}", None), + getattr(self, f"extra_field_verified{i}", None), + ) + + @property + def valid_fields(self) -> Sequence[ExtraField]: + return [x for x in self.extra_fields if x.label and x.value] + + +class User(Model): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + is_admin: Mapped[bool] = mapped_column(default=False) + _password_hash: Mapped[str] = mapped_column("password_hash", db.String(512)) + _totp_secret: Mapped[Optional[str]] = mapped_column("totp_secret", db.String(255)) + + primary_username: Mapped[Username] = relationship( + primaryjoin="and_(Username.user_id == User.id, Username.is_primary)", + back_populates="user", + ) + + _email: Mapped[Optional[str]] = mapped_column("email", db.String(255)) + _smtp_server: Mapped[Optional[str]] = mapped_column("smtp_server", db.String(255)) + smtp_port: Mapped[Optional[int]] + _smtp_username: Mapped[Optional[str]] = mapped_column("smtp_username", db.String(255)) + _smtp_password: Mapped[Optional[str]] = mapped_column("smtp_password", db.String(255)) + _pgp_key: Mapped[Optional[str]] = mapped_column("pgp_key", db.Text) + smtp_encryption: Mapped[SMTPEncryption] = mapped_column( + db.Enum(SMTPEncryption, native_enum=False), default=SMTPEncryption.StartTLS + ) + smtp_sender: Mapped[Optional[str]] + @property def password_hash(self) -> str: """Return the hashed password.""" @@ -131,40 +194,13 @@ def pgp_key(self, value: str) -> None: else: self._pgp_key = encrypt_field(value) - def update_display_name(self, new_display_name: str) -> None: - """Update the user's display name and remove verification status if the user is verified.""" - self.display_name = new_display_name - if self.is_verified: - self.is_verified = False - - # In the User model - def update_username(self, new_username: str) -> None: - """Update the user's username and remove verification status if the user is verified.""" - try: - # Log the attempt to update the username - current_app.logger.debug( - f"Attempting to update username from {self.primary_username} to {new_username}" - ) - - # Update the username - self.primary_username = new_username - if self.is_verified: - self.is_verified = False - # Log the change in verification status due to username update - current_app.logger.debug("Verification status set to False due to username update") - - # Commit the change to the database - db.session.commit() - - # Log the successful update - current_app.logger.debug(f"Username successfully updated to {new_username}") - except Exception as e: - # Log any exceptions that occur during the update - current_app.logger.error(f"Error updating username: {e}", exc_info=True) - - def __init__(self, primary_username: str) -> None: + def __init__(self, **kwargs: Any) -> None: + for key in ["password_hash", "_password_hash"]: + if key in kwargs: + raise ValueError(f"Key {key!r} cannot be mannually set. Try 'password' instead.") + pw = kwargs.pop("password", None) super().__init__() - self.primary_username = primary_username + self.password_hash = pw class AuthenticationLog(Model): @@ -209,13 +245,15 @@ def __init__( class Message(Model): id: Mapped[int] = mapped_column(primary_key=True) _content: Mapped[str] = mapped_column("content", db.Text) # Encrypted content stored here - user_id: Mapped[int] = mapped_column(db.ForeignKey("users.id")) + username_id: Mapped[int] = mapped_column(db.ForeignKey("users.id")) user: Mapped["User"] = relationship(backref=db.backref("messages", lazy=True)) - def __init__(self, content: str, user_id: int) -> None: - super().__init__() + def __init__(self, **kwargs: Any) -> None: + if "_content" in kwargs: + raise ValueError("Cannot set '_content' directly. Use 'content'") + content = kwargs.pop("content", None) + super().__init__(**kwargs) self.content = content - self.user_id = user_id @property def content(self) -> str | None: diff --git a/hushline/routes.py b/hushline/routes.py index 55d342c0..7c0246f4 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -8,6 +8,7 @@ import pyotp from flask import ( Flask, + current_app, flash, make_response, redirect, @@ -17,6 +18,8 @@ url_for, ) from flask_wtf import FlaskForm +from sqlalchemy import select +from sqlalchemy.sql import exists from werkzeug.wrappers.response import Response from wtforms import Field, Form, PasswordField, StringField, TextAreaField from wtforms.validators import DataRequired, Length, Optional, ValidationError @@ -24,7 +27,7 @@ from .crypto import decrypt_field, encrypt_field, encrypt_message, generate_salt from .db import db from .forms import ComplexPassword -from .model import AuthenticationLog, InviteCode, Message, SMTPEncryption, User +from .model import AuthenticationLog, InviteCode, Message, SMTPEncryption, User, Username from .utils import SMTPConfig, authentication_required, create_smtp_config, send_email # Logging setup @@ -95,9 +98,7 @@ def inbox() -> Response | str: flash("👉 Please log in to access your inbox.") return redirect(url_for("login")) - messages = db.session.scalars( - db.select(Message).filter_by(user_id=user.id).order_by(Message.id.desc()) - ).all() + messages = Message.query.filter_by(username_id=user.id).order_by(Message.id.desc()).all() return render_template( "inbox.html", @@ -109,10 +110,8 @@ def inbox() -> Response | str: @app.route("/to/", methods=["GET"]) def profile(username: str) -> Response | str: form = MessageForm() - user = db.session.scalars( - db.select(User).filter_by(primary_username=username).limit(1) - ).first() - if not user: + uname = Username.query.filter_by(_username=username).one_or_none() + if not uname: flash("🫥 User not found.") return redirect(url_for("index")) @@ -143,56 +142,29 @@ def profile(username: str) -> Response | str: math_problem = f"{num1} + {num2} =" session["math_answer"] = str(num1 + num2) # Store the answer in session as a string - # Prepare extra fields and verification status - extra_fields = [ - { - "label": user.extra_field_label1, - "value": user.extra_field_value1, - "verified": user.extra_field_verified1, - }, - { - "label": user.extra_field_label2, - "value": user.extra_field_value2, - "verified": user.extra_field_verified2, - }, - { - "label": user.extra_field_label3, - "value": user.extra_field_value3, - "verified": user.extra_field_verified3, - }, - { - "label": user.extra_field_label4, - "value": user.extra_field_value4, - "verified": user.extra_field_verified4, - }, - ] - return render_template( "profile.html", form=form, - user=user, + user=uname.user, username=username, - display_name_or_username=user.display_name or user.primary_username, + display_name_or_username=uname.display_name or uname.username, current_user_id=session.get("user_id"), - public_key=user.pgp_key, + public_key=uname.user.pgp_key, is_personal_server=app.config["IS_PERSONAL_SERVER"], require_pgp=app.config["REQUIRE_PGP"], math_problem=math_problem, - extra_fields=extra_fields, # Pass extra fields to template ) @app.route("/to/", methods=["POST"]) def submit_message(username: str) -> Response | str: form = MessageForm() - user = db.session.scalars( - db.select(User).filter_by(primary_username=username).limit(1) - ).first() - if not user: + uname = Username.query.filter_by(_username=username).one_or_none() + if not uname: flash("🫥 User not found.") return redirect(url_for("index")) if form.validate_on_submit(): - if not user.pgp_key and app.config["REQUIRE_PGP"]: + if not uname.user.pgp_key and app.config["REQUIRE_PGP"]: flash("⛔️ You cannot submit messages to users who have not set a PGP key.", "error") return redirect(url_for("profile", username=username)) @@ -220,9 +192,9 @@ def submit_message(username: str) -> Response | str: content_to_save = ( content # Assume content is already encrypted and includes contact method ) - elif user.pgp_key: + elif uname.user.pgp_key: try: - encrypted_content = encrypt_message(full_content, user.pgp_key) + encrypted_content = encrypt_message(full_content, uname.user.pgp_key) if not encrypted_content: flash("⛔️ Failed to encrypt message.", "error") return redirect(url_for("profile", username=username)) @@ -234,11 +206,11 @@ def submit_message(username: str) -> Response | str: else: content_to_save = full_content - new_message = Message(content=content_to_save, user_id=user.id) + new_message = Message(content=content_to_save, username_id=uname.user_id) db.session.add(new_message) db.session.commit() - if user.email and content_to_save: + if uname.user.email and content_to_save: try: smtp_config: SMTPConfig = create_smtp_config( app.config["SMTP_USERNAME"], @@ -248,17 +220,19 @@ def submit_message(username: str) -> Response | str: app.config["NOTIFICATIONS_ADDRESS"], encryption=SMTPEncryption[app.config["SMTP_ENCRYPTION"]], ) - if user.smtp_server: + if uname.user.smtp_server: smtp_config = create_smtp_config( - user.smtp_username, - user.smtp_server, - user.smtp_port, - user.smtp_password, - user.smtp_sender, - encryption=user.smtp_encryption, + uname.user.smtp_username, + uname.user.smtp_server, + uname.user.smtp_port, + uname.user.smtp_password, + uname.user.smtp_sender, + encryption=uname.user.smtp_encryption, ) - email_sent = send_email(user.email, "New Message", content_to_save, smtp_config) + email_sent = send_email( + uname.user.email, "New Message", content_to_save, smtp_config + ) flash_message = ( "👍 Message submitted successfully." if email_sent @@ -296,19 +270,31 @@ def delete_message(message_id: int) -> Response: flash("🔑 Please log in to continue.") return redirect(url_for("login")) - user = db.session.get(User, session.get("user_id")) + user = User.query.get(session.get("user_id")) if not user: flash("🫥 User not found. Please log in again.") return redirect(url_for("login")) - message = db.session.get(Message, message_id) - if message and message.user_id == user.id: - db.session.delete(message) - db.session.commit() - flash("🗑️ Message deleted successfully.") - return redirect(url_for("inbox")) + row_count = Message.query.filter( + Message.id == message_id, + Message.username_id.in_( + select(Username.user_id).select_from(Username).filter(Username.user_id == user.id) + ), + ).delete() + match row_count: + case 1: + db.session.commit() + flash("🗑️ Message deleted successfully.") + case 0: + db.session.rollback() + flash("⛔️ Message not found or unauthorized access.") + case _: + db.session.rollback() + current_app.logger.error( + f"Multiple messages would have been deleted. Message.id={message_id}" + ) + flash("Internal server error. Message not deleted.") - flash("⛔️ Message not found or unauthorized access.") return redirect(url_for("inbox")) @app.route("/register", methods=["GET", "POST"]) @@ -332,9 +318,7 @@ def register() -> Response | str | tuple[Response | str, int]: invite_code_input = form.invite_code.data if require_invite_code else None if invite_code_input: - invite_code = db.session.scalars( - db.select(InviteCode).filter_by(code=invite_code_input).limit(1) - ).first() + invite_code = InviteCode.query.filter_by(code=invite_code_input).one_or_none() if not invite_code or invite_code.expiration_date.replace( tzinfo=UTC ) < datetime.now(UTC): @@ -348,9 +332,7 @@ def register() -> Response | str | tuple[Response | str, int]: 400, ) - if db.session.scalars( - db.select(User).filter_by(primary_username=username).limit(1) - ).first(): + if db.session.query(exists(Username).where(Username.username == username)).scalar(): flash("💔 Username already taken.", "error") return ( render_template( @@ -362,9 +344,12 @@ def register() -> Response | str | tuple[Response | str, int]: ) # Create new user instance - new_user = User(primary_username=username) - new_user.password_hash = password # This triggers the password_hash setter - db.session.add(new_user) + user = User(password=password) + db.session.add(user) + db.session.flush() + + username = Username(username=username, user_id=user.id, is_primary=True) + db.session.add(username) db.session.commit() flash("Registration successful!", "success") @@ -385,26 +370,22 @@ def login() -> Response | str: form = LoginForm() if form.validate_on_submit(): - username = form.username.data.strip() - password = form.password.data - - user = db.session.scalars( - db.select(User).filter_by(primary_username=username).limit(1) - ).first() - - if user and user.check_password(password): + username = Username.query.filter_by( + _username=form.username.data.strip(), is_primary=True + ).one_or_none() + if username and username.user.check_password(form.password.data): session.permanent = True - session["user_id"] = user.id - session["username"] = user.primary_username + session["user_id"] = username.user_id + session["username"] = username.username session["is_authenticated"] = True # 2FA enabled? - if user.totp_secret: + if username.user.totp_secret: session["is_authenticated"] = False return redirect(url_for("verify_2fa_login")) # Successful login - auth_log = AuthenticationLog(user_id=user.id, successful=True) + auth_log = AuthenticationLog(user_id=username.user_id, successful=True) db.session.add(auth_log) db.session.commit() @@ -442,12 +423,12 @@ def verify_2fa_login() -> Response | str | tuple[Response | str, int]: rate_limit = False # If the most recent successful login was made with the same OTP code, reject this one - last_login = db.session.scalars( - db.select(AuthenticationLog) - .filter_by(user_id=user.id, successful=True) + last_login = ( + AuthenticationLog.query.filter_by(user_id=user.id, successful=True) .order_by(AuthenticationLog.timestamp.desc()) .limit(1) - ).first() + .first() + ) if ( last_login and last_login.timecode == timecode @@ -513,36 +494,14 @@ def logout() -> Response: # Redirect to the login page or home page after logout return redirect(url_for("index")) - @app.route("/settings/update_directory_visibility", methods=["POST"]) - @authentication_required - def update_directory_visibility() -> Response: - if "user_id" in session: - user = db.session.get(User, session.get("user_id")) - if user: - user.show_in_directory = "show_in_directory" in request.form - db.session.commit() - flash("👍 Directory visibility updated.") - else: - flash("⛔️ You need to be logged in to update settings.") - return redirect(url_for("settings.index")) - - def sort_users_by_display_name(users: list[User], admin_first: bool = True) -> list[User]: + def get_directory_usernames(admin_first: bool = False) -> list[Username]: + query = Username.query.filter_by(show_in_directory=True) + display_ordering = db.func.coalesce(Username._display_name, Username._username) if admin_first: - # Sorts admins to the top, then by display name or username - return sorted( - users, - key=lambda u: ( - not u.is_admin, - (u.display_name or u.primary_username).strip().lower(), - ), - ) - - # Sorts only by display name or username - return sorted(users, key=lambda u: (u.display_name or u.primary_username).strip().lower()) - - def get_directory_users() -> list[User]: - users = db.session.scalars(db.select(User).filter_by(show_in_directory=True)).all() - return sort_users_by_display_name(list(users)) + query = query.order_by(Username.user.is_admin.desc(), display_ordering) + else: + query = query.order_by(display_ordering) + return query.all() @app.route("/directory") def directory() -> Response | str: @@ -550,7 +509,7 @@ def directory() -> Response | str: is_personal_server = app.config["IS_PERSONAL_SERVER"] return render_template( "directory.html", - users=get_directory_users(), + users=get_directory_usernames(), logged_in=logged_in, is_personal_server=is_personal_server, ) @@ -564,13 +523,13 @@ def session_user() -> dict[str, bool]: def directory_users() -> list[dict[str, str | bool | None]]: return [ { - "primary_username": user.primary_username, - "display_name": user.display_name or user.primary_username, - "bio": user.bio, - "is_admin": user.is_admin, - "is_verified": user.is_verified, + "primary_username": username.username, + "display_name": username.display_name or username.username, + "bio": username.bio, + "is_admin": username.user.is_admin, + "is_verified": username.is_verified, } - for user in get_directory_users() + for username in get_directory_usernames() ] @app.route("/vision", methods=["GET"]) diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index 59ed4fe1..3369389f 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -1,7 +1,6 @@ import asyncio import base64 import io -from datetime import UTC, datetime import aiohttp import pyotp @@ -18,13 +17,14 @@ session, url_for, ) +from sqlalchemy.sql import exists from werkzeug.wrappers.response import Response from wtforms import Field from ..crypto import is_valid_pgp_key from ..db import db from ..forms import TwoFactorForm -from ..model import Message, SMTPEncryption, User +from ..model import Message, SMTPEncryption, User, Username from ..utils import authentication_required, create_smtp_config from .forms import ( ChangePasswordForm, @@ -65,7 +65,7 @@ def set_input_disabled(input_field: Field, disabled: bool = True) -> None: # Define the async function for URL verification async def verify_url( - session: aiohttp.ClientSession, user: User, i: int, url_to_verify: str, profile_url: str + session: aiohttp.ClientSession, username: Username, i: int, url_to_verify: str, profile_url: str ) -> None: try: async with session.get(url_to_verify, timeout=aiohttp.ClientTimeout(total=5)) as response: @@ -81,10 +81,10 @@ async def verify_url( verified = True break - setattr(user, f"extra_field_verified{i}", verified) + setattr(username, f"extra_field_verified{i}", verified) except aiohttp.ClientError as e: current_app.logger.error(f"Error fetching URL for field {i}: {e}") - setattr(user, f"extra_field_verified{i}", False) + setattr(username, f"extra_field_verified{i}", False) def create_blueprint() -> Blueprint: @@ -103,7 +103,7 @@ async def index() -> str | Response: return redirect(url_for("login")) directory_visibility_form = DirectoryVisibilityForm( - show_in_directory=user.show_in_directory + show_in_directory=user.primary_username.show_in_directory ) change_password_form = ChangePasswordForm() change_username_form = ChangeUsernameForm() @@ -118,7 +118,7 @@ async def index() -> str | Response: if request.method == "POST": # Update bio and custom fields if "update_bio" in request.form and profile_form.validate_on_submit(): - user.bio = profile_form.bio.data + user.primary_username.bio = profile_form.bio.data.strip() # Define base_url from the environment or config profile_url = url_for("profile", _external=True, username=user.primary_username) @@ -126,24 +126,24 @@ async def index() -> str | Response: async with aiohttp.ClientSession() as client_session: tasks = [] for i in range(1, 5): - label_field = getattr(profile_form, f"extra_field_label{i}", "") - value_field = getattr(profile_form, f"extra_field_value{i}", "") - - label = label_field.data if hasattr(label_field, "data") else label_field - setattr(user, f"extra_field_label{i}", label) - - value = value_field.data if hasattr(value_field, "data") else value_field - setattr(user, f"extra_field_value{i}", value) - - # If the value is empty, reset the verification status - if not value: - setattr(user, f"extra_field_verified{i}", False) + if ( + label_field := getattr(profile_form, f"extra_field_label{i}", None) + ) and (label := getattr(label_field, "data", None)): + setattr(user.primary_username, f"extra_field_label{i}", label) + + if ( + value_field := getattr(profile_form, f"extra_field_value{i}", None) + ) and (value := getattr(value_field, "data", None)): + setattr(user.primary_username, f"extra_field_value{i}", value) + else: + setattr(user.primary_username, f"extra_field_verified{i}", False) continue # Verify the URL only if it starts with "https://" - url_to_verify = value - if url_to_verify.startswith("https://"): - task = verify_url(client_session, user, i, url_to_verify, profile_url) + if value.startswith("https://"): + task = verify_url( + client_session, user.primary_username, i, value, profile_url + ) tasks.append(task) # Run all the tasks concurrently @@ -159,38 +159,41 @@ async def index() -> str | Response: "update_directory_visibility" in request.form and directory_visibility_form.validate_on_submit() ): - user.show_in_directory = directory_visibility_form.show_in_directory.data + user.primary_username.show_in_directory = ( + directory_visibility_form.show_in_directory.data + ) db.session.commit() flash("👍 Directory visibility updated successfully.") return redirect(url_for("settings.index")) # Handle Display Name Form Submission if "update_display_name" in request.form and display_name_form.validate_on_submit(): - user.update_display_name(display_name_form.display_name.data.strip()) + user.primary_username.display_name = display_name_form.display_name.data.strip() db.session.commit() flash("👍 Display name updated successfully.") current_app.logger.debug( - f"Display name updated to {user.display_name}, " - f"Verification status: {user.is_verified}" + f"Display name updated to {user.primary_username.display_name}, " + f"Verification status: {user.primary_username.is_verified}" ) return redirect(url_for(".index")) # Handle Change Username Form Submission if "change_username" in request.form and change_username_form.validate_on_submit(): new_username = change_username_form.new_username.data - existing_user = db.session.scalars( - db.select(User).filter_by(primary_username=new_username).limit(1) - ).first() - if existing_user: + + # TODO a better pattern would be to try to commit, catch the exception, and match + # on the name of the unique index that errored + if db.session.query( + exists(Username).where(Username._username == new_username) + ).scalar(): flash("💔 This username is already taken.") else: - user.update_username(new_username) - db.session.commit() + user.primary_username.username = new_username session["username"] = new_username flash("👍 Username changed successfully.") current_app.logger.debug( - f"Username updated to {user.primary_username}, " - f"Verification status: {user.is_verified}" + f"Username updated to {user.primary_username.username}, " + f"Verification status: {user.primary_username.is_verified}" ) return redirect(url_for(".index")) @@ -213,7 +216,7 @@ async def index() -> str | Response: ) two_fa_percentage = (two_fa_count / user_count * 100) if user_count else 0 pgp_key_percentage = (pgp_key_count / user_count * 100) if user_count else 0 - all_users = list(db.session.scalars(db.select(User)).all()) # Fetch all users for admin + all_users = list(User.query.all()) # Prepopulate form fields email_forwarding_form.forwarding_enabled.data = user.email is not None @@ -227,12 +230,13 @@ async def index() -> str | Response: email_forwarding_form.smtp_settings.smtp_encryption.data = user.smtp_encryption.value email_forwarding_form.smtp_settings.smtp_sender.data = user.smtp_sender pgp_key_form.pgp_key.data = user.pgp_key - display_name_form.display_name.data = user.display_name or user.primary_username - directory_visibility_form.show_in_directory.data = user.show_in_directory + display_name_form.display_name.data = ( + user.primary_username.display_name or user.primary_username.username + ) + directory_visibility_form.show_in_directory.data = user.primary_username.show_in_directory return render_template( "settings.html", - now=datetime.now(UTC), user=user, all_users=all_users, # Pass to the template for admin view email_forwarding_form=email_forwarding_form, @@ -254,6 +258,19 @@ async def index() -> str | Response: default_forwarding_enabled=bool(current_app.config["NOTIFICATIONS_ADDRESS"]), ) + @bp.route("//update_directory_visibility", methods=["POST"]) + @authentication_required + def update_directory_visibility() -> Response: + if "user_id" in session: + user = User.query.get(session.get("user_id")) + if user: + user.primary_username.show_in_directory = "show_in_directory" in request.form + db.session.commit() + flash("👍 Directory visibility updated.") + else: + flash("⛔️ You need to be logged in to update settings.") + return redirect(url_for("settings.index")) + @bp.route("/toggle-2fa", methods=["POST"]) @authentication_required def toggle_2fa() -> Response: @@ -330,7 +347,7 @@ def enable_2fa() -> Response | str: session["is_setting_up_2fa"] = True if user: totp_uri = pyotp.totp.TOTP(temp_totp_secret).provisioning_uri( - name=user.primary_username, issuer_name="HushLine" + name=user.primary_username.username, issuer_name="HushLine" ) img = qrcode.make(totp_uri) buffered = io.BytesIO() diff --git a/hushline/templates/inbox.html b/hushline/templates/inbox.html index 311e4e52..8c25d33e 100644 --- a/hushline/templates/inbox.html +++ b/hushline/templates/inbox.html @@ -3,7 +3,7 @@ {% block content %} {% if messages %} -

Inbox for {{ user.display_name or user.primary_username }}

+

Inbox for {{ user.primary_username.display_name or user.primary_username.username }}

{% for message in messages %}
{% endif %} - {% if user.bio %} -

{{ user.bio }}

+ {% if user.primary_username.bio %} +

{{ user.primary_username.bio }}

{% endif %} - {% - set extra_fields = [ - {'label': user.extra_field_label1, 'value': user.extra_field_value1, 'verified': user.extra_field_verified1}, - {'label': user.extra_field_label2, 'value': user.extra_field_value2, 'verified': user.extra_field_verified2}, - {'label': user.extra_field_label3, 'value': user.extra_field_value3, 'verified': user.extra_field_verified3}, - {'label': user.extra_field_label4, 'value': user.extra_field_value4, 'verified': user.extra_field_verified4} - ] - %} - {%- set valid_fields = extra_fields | selectattr('label', 'defined') | selectattr('value', 'defined') | selectattr('value') | list -%} - {% if valid_fields | length > 0 %} + {% if user.primary_username.valid_fields | length > 0 %}
- {% for field in valid_fields %} + {% for field in user.primary_username.valid_fields %}

{{ field.label }} diff --git a/hushline/templates/settings.html b/hushline/templates/settings.html index 3df4fc7b..15700786 100644 --- a/hushline/templates/settings.html +++ b/hushline/templates/settings.html @@ -138,9 +138,7 @@

Add Your Bio

0/250
- +

Extra Fields

@@ -158,7 +156,7 @@

Extra Fields

name="extra_field_label1" id="extra_field_label1" placeholder="Signal" - value="{{ user.extra_field_label1 or '' }}" + value="{{ user.primary_username.extra_field_label1 or '' }}" />
@@ -168,7 +166,7 @@

Extra Fields

name="extra_field_value1" id="extra_field_value1" placeholder="signaluser.123" - value="{{ user.extra_field_value1 or '' }}" + value="{{ user.primary_username.extra_field_value1 or '' }}" /> {% if user.extra_field_verified1 %} @@ -183,7 +181,7 @@

Extra Fields

type="text" name="extra_field_label2" id="extra_field_label2" - value="{{ user.extra_field_label2 or '' }}" + value="{{ user.primary_username.extra_field_label2 or '' }}" />
@@ -192,7 +190,7 @@

Extra Fields

type="text" name="extra_field_value2" id="extra_field_value2" - value="{{ user.extra_field_value2 or '' }}" + value="{{ user.primary_username.extra_field_value2 or '' }}" /> {% if user.extra_field_verified2 %} @@ -207,7 +205,7 @@

Extra Fields

type="text" name="extra_field_label3" id="extra_field_label3" - value="{{ user.extra_field_label3 or '' }}" + value="{{ user.primary_username.extra_field_label3 or '' }}" />
@@ -216,7 +214,7 @@

Extra Fields

type="text" name="extra_field_value3" id="extra_field_value3" - value="{{ user.extra_field_value3 or '' }}" + value="{{ user.primary_username.extra_field_value3 or '' }}" /> {% if user.extra_field_verified3 %} @@ -231,7 +229,7 @@

Extra Fields

type="text" name="extra_field_label4" id="extra_field_label4" - value="{{ user.extra_field_label4 or '' }}" + value="{{ user.primary_username.extra_field_label4 or '' }}" />
@@ -240,7 +238,7 @@

Extra Fields

type="text" name="extra_field_value4" id="extra_field_value4" - value="{{ user.extra_field_value4 or '' }}" + value="{{ user.primary_username.extra_field_value4 or '' }}" /> {% if user.extra_field_verified4 %} diff --git a/pyproject.toml b/pyproject.toml index 118eb1d2..54263d07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ ignore = [ "tests/*.py" = [ # https://docs.astral.sh/ruff/rules/assert/ "S101", + "S105", # hardcoded password # https://docs.astral.sh/ruff/rules/magic-value-comparison/ "PLR2004", ] diff --git a/tests/auth_helper.py b/tests/auth_helper.py index 8dff32b5..139821ac 100644 --- a/tests/auth_helper.py +++ b/tests/auth_helper.py @@ -5,8 +5,7 @@ import pyotp from flask.testing import FlaskClient -from hushline.db import db -from hushline.model import AuthenticationLog, User +from hushline.model import AuthenticationLog, User, Username def register_user(client: FlaskClient, username: str, password: str) -> User: @@ -24,11 +23,10 @@ def register_user(client: FlaskClient, username: str, password: str) -> User: assert b"Registration successful!" in response.data # Verify user is added to the database - user = db.session.scalars(db.select(User).filter_by(primary_username=username).limit(1)).first() + user = User.query.join(Username).filter(Username._username == username).one_or_none() assert user is not None - assert user.primary_username == username + assert user.primary_username.username == username - # Return the registered user return user @@ -39,9 +37,9 @@ def register_user_2fa(client: FlaskClient, username: str, password: str) -> tupl assert response.status_code == 200 # Verify user is added to the database - user = db.session.scalars(db.select(User).filter_by(primary_username=username).limit(1)).first() + user = User.query.join(Username).filter(Username._username == username).one_or_none() assert user is not None - assert user.primary_username == username + assert user.primary_username.username == username # And 2FA is disabled assert user._totp_secret is None @@ -77,7 +75,7 @@ def register_user_2fa(client: FlaskClient, username: str, password: str) -> tupl assert "Enter your 2FA Code" in login_response.text # Modify the timestamps on the AuthenticationLog entries to allow for 2FA verification - for log in db.session.scalars(db.select(AuthenticationLog)).all(): + for log in AuthenticationLog.query.all(): log.timestamp = datetime.now() - timedelta(minutes=5) return (user, totp_secret) @@ -97,8 +95,9 @@ def login_user(client: FlaskClient, username: str, password: str) -> User | None f'href="/inbox?username={username}"'.encode() in response.data ), f"Inbox link should be present for the user {username}" - # Return the logged-in user - return db.session.scalars(db.select(User).filter_by(primary_username=username).limit(1)).first() + if username := Username.query.filter_by(_username=username).one_or_none(): + return username.user + return None def configure_pgp(client: FlaskClient) -> None: diff --git a/tests/test_profile.py b/tests/test_profile.py index 4a2eaa5d..8dd4505f 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,6 +1,6 @@ from auth_helper import login_user, register_user from bs4 import BeautifulSoup -from flask import Flask +from flask import Flask, url_for from flask.testing import FlaskClient from hushline.db import db @@ -9,158 +9,122 @@ def get_captcha_from_session(client: FlaskClient, username: str) -> str: # Simulate loading the profile page to generate and retrieve the CAPTCHA from the session - response = client.get(f"/to/{username}") + response = client.get(url_for("profile", username=username)) assert response.status_code == 200 with client.session_transaction() as session: captcha_answer = session.get("math_answer") - assert captcha_answer is not None # Ensure the CAPTCHA was generated + assert captcha_answer return captcha_answer def test_profile_submit_message(client: FlaskClient) -> None: - # Register a user - user = register_user(client, "test_user", "Hush-Line-Test-Password9") + username = "test_user" + password = "Hush-Line-Test-Password9" + msg_content = "This is a test message." - # Log in the user - login_user(client, "test_user", "Hush-Line-Test-Password9") + user = register_user(client, username, password) + login_user(client, username, password) - # Get the CAPTCHA answer from the session - captcha_answer = get_captcha_from_session(client, user.primary_username) + captcha_answer = get_captcha_from_session(client, user.primary_username.username) - # Prepare the message data - message_data = { - "content": "This is a test message.", - "client_side_encrypted": "false", - "captcha_answer": captcha_answer, # Include the CAPTCHA answer - } - - # Send a POST request to submit the message response = client.post( - f"/to/{user.primary_username}", - data=message_data, + url_for("profile", username=username), + data={ + "content": msg_content, + "client_side_encrypted": "false", + "captcha_answer": captcha_answer, + }, follow_redirects=True, ) - - # Assert that the response status code is 200 (OK) assert response.status_code == 200 - - # Assert that the success message is displayed assert b"Message submitted successfully." in response.data - # Verify that the message is saved in the database - message = db.session.scalars(db.select(Message).filter_by(user_id=user.id).limit(1)).first() - assert message is not None - assert message.content == "This is a test message." + message = Message.query.filter_by(username_id=user.primary_username.id).one() + assert message.content == msg_content - # Navigate to the inbox with follow_redirects=True - response = client.get(f"/inbox?username={user.primary_username}", follow_redirects=True) - - # Assert that the response status code is 200 (OK) + response = client.get(url_for("inbox", unamename=username), follow_redirects=True) assert response.status_code == 200 - - # Assert that the submitted message is displayed in the inbox - assert b"This is a test message." in response.data + assert msg_content in response.data.decode("utf-8") def test_profile_submit_message_with_contact_method(client: FlaskClient) -> None: - # Register a user - user = register_user(client, "test_user_concat", "Secure-Test-Pass123") + username = "test_user_concat" + password = "Secure-Test-Pass123" + user = register_user(client, username, password) assert user is not None - # Log in the user - login_success = login_user(client, "test_user_concat", "Secure-Test-Pass123") + login_success = login_user(client, username, password) assert login_success - # Get the CAPTCHA answer from the session - captcha_answer = get_captcha_from_session(client, user.primary_username) + captcha_answer = get_captcha_from_session(client, user.primary_username.username) - # Prepare the message and contact method data message_content = "This is a test message." contact_method = "email@example.com" - message_data = { - "content": message_content, - "contact_method": contact_method, - "client_side_encrypted": "false", # Simulate that this is not client-side encrypted - "captcha_answer": captcha_answer, # Include the CAPTCHA answer - } - # Send a POST request to submit the message response = client.post( - f"/to/{user.primary_username}", - data=message_data, + url_for("profile", username=username), + data={ + "content": message_content, + "contact_method": contact_method, + "client_side_encrypted": "false", # Simulate that this is not client-side encrypted + "captcha_answer": captcha_answer, + }, follow_redirects=True, ) - - # Assert that the response status code is 200 (OK) assert response.status_code == 200 assert b"Message submitted successfully." in response.data - # Verify that the message is saved in the database - message = db.session.scalars(db.select(Message).filter_by(user_id=user.id).limit(1)).first() - assert message is not None - - # Check if the message content includes the concatenated contact method + message = Message.query.filter_by(username_id=user.primary_username.id).one_or_none() expected_content = f"Contact Method: {contact_method}\n\n{message_content}" assert message.content == expected_content - # Navigate to the inbox to check if the message displays correctly - response = client.get(f"/inbox?username={user.primary_username}", follow_redirects=True) + response = client.get( + url_for("inbox", username=user.primary_username.username), follow_redirects=True + ) assert response.status_code == 200 assert expected_content.encode() in response.data def test_profile_pgp_required(client: FlaskClient, app: Flask) -> None: - # Require PGP app.config["REQUIRE_PGP"] = True + username = "test_user" + password = "Hush-Line-Test-Password9" + user = register_user(client, username, password) - # Register a user (with no PGP key) - user = register_user(client, "test_user", "Hush-Line-Test-Password9") - - # Load the profile page - response = client.get(f"/to/{user.primary_username}") + response = client.get(url_for("profile", username=username)) assert response.status_code == 200 - - # The message form should not be displayed, and the PGP warning should be shown assert b"Sending messages is disabled" in response.data - # Add a PGP key to the user user.pgp_key = "test_pgp_key" db.session.commit() - # Load the profile page again - response = client.get(f"/to/{user.primary_username}") + response = client.get(url_for("profile", username=username)) assert response.status_code == 200 - # The message form should be displayed now assert b'id="messageForm"' in response.data assert b"You can't send encrypted messages to this user through Hush Line" not in response.data def test_profile_extra_fields(client: FlaskClient, app: Flask) -> None: - # Register a user - user = register_user(client, "test_user", "Hush-Line-Test-Password9") - user.extra_field_label1 = "Signal username" - user.extra_field_value1 = "singleusername.666" - user.extra_field_label2 = "Arbitrary Link" - user.extra_field_value2 = "https://scidsg.org/" - user.extra_field_label3 = "xss should fail" - user.extra_field_value3 = "" + username = "test_user" + user = register_user(client, username, "Hush-Line-Test-Password9") + user.primary_username.extra_field_label1 = "Signal username" + user.primary_username.extra_field_value1 = "singleusername.666" + user.primary_username.extra_field_label2 = "Arbitrary Link" + user.primary_username.extra_field_value2 = "https://scidsg.org/" + user.primary_username.extra_field_label3 = "xss should fail" + user.primary_username.extra_field_value3 = "" db.session.commit() - # Load the profile page - response = client.get(f"/to/{user.primary_username}") + response = client.get(url_for("profile", username=username)) assert response.status_code == 200 - # Check the HTML content using BeautifulSoup soup = BeautifulSoup(response.data, "html.parser") - - # Verify the signal username is displayed correctly signal_username_span = soup.find("span", class_="extra-field-value") assert signal_username_span is not None assert signal_username_span.text.strip() == "singleusername.666" - # Verify the arbitrary link is present with correct attributes link = soup.find("a", href="https://scidsg.org/") assert link is not None assert link.get("target") == "_blank" @@ -169,46 +133,43 @@ def test_profile_extra_fields(client: FlaskClient, app: Flask) -> None: # Verify that XSS is correctly escaped # Search for the XSS string directly in the HTML with both possible escapes - assert "<script>alert('xss')</script>" in str( - soup - ) or "<script>alert('xss')</script>" in str(soup) - assert "" not in str(soup) + html_str = str(soup) + assert ( + "<script>alert('xss')</script>" in html_str + or "<script>alert('xss')</script>" in html_str + ) + assert "" not in html_str def test_profile_submit_message_with_invalid_captcha(client: FlaskClient) -> None: - # Register a user - user = register_user(client, "test_user_concat", "Secure-Test-Pass123") + username = "test_user_concat" + password = "Secure-Test-Pass123" + user = register_user(client, username, password) assert user is not None - # Log in the user - login_success = login_user(client, "test_user_concat", "Secure-Test-Pass123") + login_success = login_user(client, username, password) assert login_success - # Prepare the message and contact method data message_content = "This is a test message." contact_method = "email@example.com" - message_data = { - "content": message_content, - "contact_method": contact_method, - "client_side_encrypted": "false", - "captcha_answer": 0, # the answer is never 0 - } # Send a POST request to submit the message response = client.post( - f"/to/{user.primary_username}", - data=message_data, + url_for("profile", username=username), + data={ + "content": message_content, + "contact_method": contact_method, + "client_side_encrypted": "false", + "captcha_answer": 0, # the answer is never 0 + }, follow_redirects=True, ) - # Make sure there's a CAPTCHA error assert response.status_code == 200 assert b"Incorrect CAPTCHA." in response.data - # Make sure the contact method and message content are there assert contact_method.encode() in response.data assert message_content.encode() in response.data # Verify that the message is not saved in the database - message = db.session.scalars(db.select(Message).filter_by(user_id=user.id).limit(1)).first() - assert message is None + assert not Message.query.filter_by(username_id=user.primary_username.id).one_or_none() diff --git a/tests/test_registration_and_login.py b/tests/test_registration_and_login.py index d4fa12e7..53e84c82 100644 --- a/tests/test_registration_and_login.py +++ b/tests/test_registration_and_login.py @@ -1,146 +1,114 @@ import os +from flask import url_for from flask.testing import FlaskClient -# Import models and other modules from hushline import db -from hushline.model import InviteCode, User +from hushline.model import InviteCode, Username def test_user_registration_with_invite_code_disabled(client: FlaskClient) -> None: os.environ["REGISTRATION_CODES_REQUIRED"] = "False" - - # User registration data - user_data = {"username": "test_user", "password": "SecurePassword123!"} - - # Post request to register a new user - response = client.post("/register", data=user_data, follow_redirects=True) - - # Validate response + username = "test_user" + response = client.post( + url_for("register"), + data={"username": username, "password": "SecurePassword123!"}, + follow_redirects=True, + ) assert response.status_code == 200 assert "Registration successful!" in response.text - # Verify user is added to the database - user = db.session.scalars( - db.select(User).filter_by(primary_username="test_user").limit(1) - ).first() - assert user is not None - assert user.primary_username == "test_user" + uname = Username.query.filter_by(_username=username).one() + assert uname.username == username def test_user_registration_with_invite_code_enabled(client: FlaskClient) -> None: - # Enable invite codes os.environ["REGISTRATION_CODES_REQUIRED"] = "True" + username = "newuser" - # Generate a valid invite code using the script code = InviteCode() db.session.add(code) db.session.commit() - # User registration data with valid invite code - user_data = { - "username": "newuser", - "password": "SecurePassword123!", - "invite_code": code.code, - } - - # Post request to register a new user - response = client.post("/register", data=user_data, follow_redirects=True) - - # Validate response + response = client.post( + url_for("register"), + data={ + "username": username, + "password": "SecurePassword123!", + "invite_code": code.code, + }, + follow_redirects=True, + ) assert response.status_code == 200 assert "Registration successful!" in response.text - # Verify user is added to the database - user = db.session.scalars( - db.select(User).filter_by(primary_username="newuser").limit(1) - ).first() - assert user is not None - assert user.primary_username == "newuser" + uname = Username.query.filter_by(_username=username).one() + assert uname.username == "newuser" def test_register_page_loads(client: FlaskClient) -> None: - response = client.get("/register") + response = client.get(url_for("register")) assert response.status_code == 200 assert "

Register

" in response.text def test_login_link(client: FlaskClient) -> None: - # Get the registration page - response = client.get("/register") + response = client.get(url_for("register")) assert response.status_code == 200 - # Check if the login link is in the response - assert 'href="/login"' in response.text, "Login link should be present on the registration page" + assert 'href="/login"' in response.text - # Simulate clicking the login link - login_response = client.get("/login") + login_response = client.get(url_for("login")) assert login_response.status_code == 200 - assert "

Login

" in login_response.text, "Should be on the login page now" + assert "

Login

" in login_response.text def test_registration_link(client: FlaskClient) -> None: - # Get the login page - response = client.get("/login") - assert response.status_code == 200, "Login page should be accessible" - - # Check if the registration link is in the response - assert ( - 'href="/register"' in response.text - ), "Registration link should be present on the login page" + response = client.get(url_for("login")) + assert response.status_code == 200 + assert 'href="/register"' in response.text - # Simulate clicking the registration link - register_response = client.get("/register") - assert register_response.status_code == 200, "Should be on the registration page now" - assert "

Register

" in register_response.text, "Should be on the registration page" + register_response = client.get(url_for("register")) + assert register_response.status_code == 200 + assert "

Register

" in register_response.text def test_user_login_after_registration(client: FlaskClient) -> None: - # Prepare the environment to not require invite codes os.environ["REGISTRATION_CODES_REQUIRED"] = "False" - - # User registration data - registration_data = {"username": "newuser", "password": "SecurePassword123!"} - - # Post request to register a new user - client.post("/register", data=registration_data, follow_redirects=True) - - # Login data should match the registration data - login_data = {"username": "newuser", "password": "SecurePassword123!"} - - # Attempt to log in with the registered user - login_response = client.post("/login", data=login_data, follow_redirects=True) - - # Validate login response + username = "newuser" + password = "SecurePassword123!" + + client.post( + url_for("register"), + data={"username": username, "password": password}, + follow_redirects=True, + ) + + login_response = client.post( + url_for("login"), data={"username": username, "password": password}, follow_redirects=True + ) assert login_response.status_code == 200 - assert "Inbox" in login_response.text, "Should be redirected to the Inbox page" - assert ( - 'href="/inbox?username=newuser"' in login_response.text - ), "Inbox link should be present for the user" + assert "Inbox" in login_response.text + assert 'href="/inbox?username=newuser"' in login_response.text def test_user_login_with_incorrect_password(client: FlaskClient) -> None: - # Prepare the environment to not require invite codes os.environ["REGISTRATION_CODES_REQUIRED"] = "False" - - # User registration data - registration_data = {"username": "newuser", "password": "SecurePassword123!"} - - # Post request to register a new user - client.post("/register", data=registration_data, follow_redirects=True) - - # Login data with an incorrect password - login_data = {"username": "newuser", "password": "Wrong_Password!"} - - # Attempt to log in with the registered user and incorrect password - login_response = client.post("/login", data=login_data, follow_redirects=True) - - # Validate login response + username = "newuser" + password = "SecurePassword123!" + + client.post( + url_for("register"), + data={"username": username, "password": password}, + follow_redirects=True, + ) + + login_response = client.post( + url_for("login"), + data={"username": username, "password": password + "not correct"}, + follow_redirects=True, + ) assert login_response.status_code == 200 - assert "Inbox" not in login_response.text, "Should not be redirected to the Inbox page" - assert ( - 'href="/inbox?username=newuser"' not in login_response.text - ), "Inbox link should not be present for the user" - assert ( - "Invalid username or password" in login_response.text - ), "Error message should be displayed" + assert "Inbox" not in login_response.text + assert 'href="/inbox?username=newuser"' not in login_response.text + assert "Invalid username or password" in login_response.text diff --git a/tests/test_settings.py b/tests/test_settings.py index 3680b98d..b9513c01 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2,37 +2,31 @@ from unittest.mock import ANY, MagicMock, patch from auth_helper import configure_pgp, login_user, register_user +from flask import url_for from flask.testing import FlaskClient -from hushline.db import db -from hushline.model import SMTPEncryption, User +from hushline.model import SMTPEncryption, Username def test_settings_page_loads(client: FlaskClient) -> None: - # Register a user - user = register_user(client, "testuser_settings", "SecureTestPass123!") - assert user is not None, "User registration failed" + username = "testuser_settings" + password = "SecureTestPass123!" + register_user(client, username, password) - # Log in the user - user_logged_in = login_user(client, "testuser_settings", "SecureTestPass123!") - assert user_logged_in is not None, "User login failed" + assert login_user(client, username, password) - # Access the /settings page - response = client.get("/settings/", follow_redirects=True) - assert response.status_code == 200, "Failed to load the settings page" + response = client.get(url_for("settings.index"), follow_redirects=True) + assert response.status_code == 200 def test_change_display_name(client: FlaskClient) -> None: - # Register and log in a user - user = register_user(client, "testuser_settings", "SecureTestPass123!") - assert user is not None, "User registration failed" - - login_user(client, "testuser_settings", "SecureTestPass123!") + username = "testuser_settings" + password = "SecureTestPass123!" + register_user(client, username, password) + login_user(client, username, password) - # Define new display name new_display_name = "New Display Name" - # Submit POST request to change display name response = client.post( "/settings/", data={ @@ -41,82 +35,51 @@ def test_change_display_name(client: FlaskClient) -> None: }, follow_redirects=True, ) - - # Verify update was successful assert response.status_code == 200, "Failed to update display name" + assert "Display name updated successfully" in response.text - # Fetch updated user info from the database to confirm change - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username="testuser_settings").limit(1) - ).one() - assert updated_user is not None, "User was not found after update attempt" - assert updated_user.display_name == new_display_name, "Display name was not updated correctly" - - # Optional: Check for success message in response - assert ( - b"Display name updated successfully" in response.data - ), "Success message not found in response" + updated_user = Username.query.filter_by(_username=username).one() + assert updated_user.display_name == new_display_name def test_change_username(client: FlaskClient) -> None: - # Register and log in a user - user = register_user(client, "original_username", "SecureTestPass123!") - assert user is not None, "User registration failed" - - login_user(client, "original_username", "SecureTestPass123!") - - # Define new username + username = "original_username" + password = "SecureTestPass123!" new_username = "updated_username" - # Submit POST request to change the username + register_user(client, username, password) + assert login_user(client, username, password) + response = client.post( - "/settings/", + url_for("settings.index"), data={ "new_username": new_username, - "change_username": "Update Username", # This button name must match your HTML form + "change_username": "Update Username", # html submit button }, follow_redirects=True, ) + assert response.status_code == 200 + assert "Username changed successfully" in response.text - # Verify update was successful - assert response.status_code == 200, "Failed to update username" - - # Fetch updated user info from the database to confirm change - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username=new_username).limit(1) - ).one() - assert updated_user is not None, "Username was not updated correctly in the database" - assert ( - updated_user.primary_username == new_username - ), "Database does not reflect the new username" - - assert ( - not updated_user.is_verified - ), "User verification status should be reset after username change" - - # Optional: Check for success message in response - assert ( - b"Username changed successfully" in response.data - ), "Success message not found in response" + updated_user = Username.query.filter_by(_username=new_username).one() + assert updated_user.username == new_username + assert not updated_user.is_verified def test_change_password(client: FlaskClient) -> None: - # Register a new user username = "test_change_password" original_password = f"{token_urlsafe(16)}!" new_password = f"{token_urlsafe(16)}!!!" + user = register_user(client, username, original_password) - assert user is not None, "User registration failed" assert len(original_password_hash := user.password_hash) > 32 assert original_password_hash.startswith("$scrypt$") assert original_password not in original_password_hash - # Log in the registered user logged_in_user = login_user(client, username, original_password) assert logged_in_user is not None assert user.id == logged_in_user.id - # Submit POST request to change the username & verify update was successful response = client.post( "/settings/change-password", data={ @@ -132,9 +95,7 @@ def test_change_password(client: FlaskClient) -> None: assert original_password_hash not in new_password_hash assert original_password not in new_password_hash assert new_password not in new_password_hash - assert ( - b"Password successfully changed. Please log in again." in response.data - ), "Success message not found in response" + assert "Password successfully changed. Please log in again." in response.text # Attempt to log in with the registered user's old password response = client.post( @@ -142,7 +103,7 @@ def test_change_password(client: FlaskClient) -> None: ) assert response.status_code == 200 assert "login" in response.request.url - assert b"Invalid username or password" in response.data, "Failure message not found in response" + assert "Invalid username or password" in response.text # Attempt to log in with the registered user's new password response = client.post( @@ -150,138 +111,93 @@ def test_change_password(client: FlaskClient) -> None: ) assert response.status_code == 200 assert "inbox" in response.request.url - assert b"Empty Inbox" in response.data, "Inbox message not found in response" - assert ( - b"Invalid username or password" not in response.data - ), "Failure message was found in response" + assert "Empty Inbox" in response.text + assert "Invalid username or password" not in response.text def test_add_pgp_key(client: FlaskClient) -> None: - # Setup and login - user = register_user(client, "user_with_pgp", "SecureTestPass123!") - assert user is not None, "User registration failed" + register_user(client, "user_with_pgp", "SecureTestPass123!") login_user(client, "user_with_pgp", "SecureTestPass123!") - # Load the PGP key from a file with open("tests/test_pgp_key.txt") as file: new_pgp_key = file.read() - # Submit POST request to add the PGP key response = client.post( "/settings/update-pgp-key", data={"pgp_key": new_pgp_key}, follow_redirects=True, ) - - # Check successful update assert response.status_code == 200, "Failed to update PGP key" - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username="user_with_pgp").limit(1) - ).one() - assert updated_user is not None, "User was not found after update attempt" - assert updated_user.pgp_key == new_pgp_key, "PGP key was not updated correctly" + assert "PGP key updated successfully" in response.text - # Check for success message - assert b"PGP key updated successfully" in response.data, "Success message not found" + updated_user = Username.query.filter_by(_username="user_with_pgp").one() + assert updated_user.user.pgp_key == new_pgp_key def test_add_invalid_pgp_key(client: FlaskClient) -> None: - # Register and log in a user - user = register_user(client, "user_invalid_pgp", "SecureTestPass123!") - assert user is not None, "User registration failed" - - login_user(client, "user_invalid_pgp", "SecureTestPass123!") - - # Define an invalid PGP key string + username = "user_invalid_pgp" + password = "SecureTestPass123!" invalid_pgp_key = "NOT A VALID PGP KEY BLOCK" - # Submit POST request to add the invalid PGP key + register_user(client, username, password) + login_user(client, username, password) + response = client.post( "/settings/update-pgp-key", data={"pgp_key": invalid_pgp_key}, follow_redirects=True, ) + assert response.status_code == 200 + assert "Invalid PGP key format" in response.text - # Check that update was not successful - assert response.status_code == 200, "HTTP status code check" - - # Fetch updated user info from the database to confirm no change - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username="user_invalid_pgp") - ).one() - assert updated_user is not None, "User was not found after update attempt" - assert ( - updated_user.pgp_key != invalid_pgp_key - ), "Invalid PGP key should not have been updated in the database" - - # Optional: Check for error message in response - assert b"Invalid PGP key format" in response.data, "Error message for invalid PGP key not found" + updated_user = Username.query.filter_by(_username=username).one() + assert updated_user.user.pgp_key != invalid_pgp_key @patch("hushline.utils.smtplib.SMTP") def test_update_smtp_settings_no_pgp(SMTP: MagicMock, client: FlaskClient) -> None: - # Register and log in a user - user = register_user(client, "user_smtp_settings_no_pgp", "SecureTestPass123!") - assert user is not None, "User registration failed" - - login_user(client, "user_smtp_settings_no_pgp", "SecureTestPass123!") + username = "user_smtp_settings_no_pgp" + password = "SecureTestPass123!" - # Define new SMTP settings - new_smtp_settings = { - "forwarding_enabled": True, - "email_address": "primary@example.com", - "custom_smtp_settings": True, - "smtp_settings-smtp_server": "smtp.example.com", - "smtp_settings-smtp_port": 587, - "smtp_settings-smtp_username": "user@example.com", - "smtp_settings-smtp_password": "securepassword123", - "smtp_settings-smtp_encryption": "StartTLS", - "smtp_settings-smtp_sender": "sender@example.com", - } + register_user(client, username, password) + login_user(client, username, password) - # Submit POST request to update SMTP settings response = client.post( "/settings/update-smtp-settings", - data=new_smtp_settings, + data={ + "forwarding_enabled": True, + "email_address": "primary@example.com", + "custom_smtp_settings": True, + "smtp_settings-smtp_server": "smtp.example.com", + "smtp_settings-smtp_port": 587, + "smtp_settings-smtp_username": "user@example.com", + "smtp_settings-smtp_password": "securepassword123", + "smtp_settings-smtp_encryption": "StartTLS", + "smtp_settings-smtp_sender": "sender@example.com", + }, follow_redirects=True, ) + assert response.status_code == 200 + assert "Email forwarding requires a configured PGP key" in response.text + + updated_user = Username.query.filter_by(_username=username).one().user - # Check successful update - assert response.status_code == 200, "Failed to update SMTP settings" - assert ( - b"Email forwarding requires a configured PGP key" in response.data - ), "Expected email forwarding to require PGP key" - # Fetch updated user info from the database to confirm changes - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username="user_smtp_settings_no_pgp") - ).one() - assert updated_user is not None, "User was not found after update attempt" - assert updated_user.email is None, f"Email address should not be set, was {updated_user.email}" - assert ( - updated_user.smtp_server is None - ), f"SMTP server should not be set, was {updated_user.smtp_server}" - assert ( - updated_user.smtp_port is None - ), f"SMTP port should not be set, was {updated_user.smtp_port}" - assert ( - updated_user.smtp_username is None - ), f"SMTP username should not be set, was {updated_user.smtp_username}" - assert ( - updated_user.smtp_password is None - ), f"SMTP password should not be set, was {updated_user.smtp_password}" + assert updated_user.email is None + assert updated_user.smtp_server is None + assert updated_user.smtp_port is None + assert updated_user.smtp_username is None + assert updated_user.smtp_password is None @patch("hushline.utils.smtplib.SMTP") def test_update_smtp_settings_starttls(SMTP: MagicMock, client: FlaskClient) -> None: - # Register and log in a user - user = register_user(client, "user_smtp_settings_tls", "SecureTestPass123!") - assert user is not None, "User registration failed" - - login_user(client, "user_smtp_settings_tls", "SecureTestPass123!") + username = "user_smtp_settings_tls" + password = "SecureTestPass123!" + user = register_user(client, username, password) + login_user(client, username, password) configure_pgp(client) - # Define new SMTP settings new_smtp_settings = { "forwarding_enabled": True, "email_address": "primary@example.com", @@ -294,60 +210,38 @@ def test_update_smtp_settings_starttls(SMTP: MagicMock, client: FlaskClient) -> "smtp_settings-smtp_sender": "sender@example.com", } - # Submit POST request to update SMTP settings response = client.post( - "/settings/update-smtp-settings", # Adjust to your app's correct endpoint + "/settings/update-smtp-settings", data=new_smtp_settings, follow_redirects=True, ) + assert response.status_code == 200 + assert "SMTP settings updated successfully" in response.text SMTP.assert_called_with(user.smtp_server, user.smtp_port, timeout=ANY) SMTP.return_value.__enter__.return_value.starttls.assert_called_once_with() SMTP.return_value.__enter__.return_value.login.assert_called_once_with( user.smtp_username, user.smtp_password ) - # Check successful update - assert response.status_code == 200, "Failed to update SMTP settings" - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username="user_smtp_settings_tls") - ).one() - assert ( - updated_user.email == new_smtp_settings["email_address"] - ), "Email address was not updated correctly" - assert ( - updated_user.smtp_server == new_smtp_settings["smtp_settings-smtp_server"] - ), "SMTP server was not updated correctly" - assert ( - updated_user.smtp_port == new_smtp_settings["smtp_settings-smtp_port"] - ), "SMTP port was not updated correctly" - assert ( - updated_user.smtp_username == new_smtp_settings["smtp_settings-smtp_username"] - ), "SMTP username was not updated correctly" - assert ( - updated_user.smtp_password == new_smtp_settings["smtp_settings-smtp_password"] - ), "SMTP password was not updated correctly" - assert ( - updated_user.smtp_encryption.value == new_smtp_settings["smtp_settings-smtp_encryption"] - ), "SMTP encryption was not updated correctly" - assert ( - updated_user.smtp_sender == new_smtp_settings["smtp_settings-smtp_sender"] - ), "SMTP sender was not updated correctly" - - # Optional: Check for success message in response - assert b"SMTP settings updated successfully" in response.data, "Success message not found" + + updated_user = Username.query.filter_by(_username="user_smtp_settings_tls").one().user + assert updated_user.email == new_smtp_settings["email_address"] + assert updated_user.smtp_server == new_smtp_settings["smtp_settings-smtp_server"] + assert updated_user.smtp_port == new_smtp_settings["smtp_settings-smtp_port"] + assert updated_user.smtp_username == new_smtp_settings["smtp_settings-smtp_username"] + assert updated_user.smtp_password == new_smtp_settings["smtp_settings-smtp_password"] + assert updated_user.smtp_encryption.value == new_smtp_settings["smtp_settings-smtp_encryption"] + assert updated_user.smtp_sender == new_smtp_settings["smtp_settings-smtp_sender"] @patch("hushline.utils.smtplib.SMTP_SSL") def test_update_smtp_settings_ssl(SMTP: MagicMock, client: FlaskClient) -> None: - # Register and log in a user - user = register_user(client, "user_smtp_settings_ssl", "SecureTestPass123!") - assert user is not None, "User registration failed" - - login_user(client, "user_smtp_settings_ssl", "SecureTestPass123!") - + username = "user_smtp_settings_ssl" + password = "SecureTestPass123!" + user = register_user(client, username, password) + login_user(client, username, password) configure_pgp(client) - # Define new SMTP settings new_smtp_settings = { "forwarding_enabled": True, "email_address": "primary@example.com", @@ -360,92 +254,62 @@ def test_update_smtp_settings_ssl(SMTP: MagicMock, client: FlaskClient) -> None: "smtp_settings-smtp_sender": "sender@example.com", } - # Submit POST request to update SMTP settings response = client.post( - "/settings/update-smtp-settings", # Adjust to your app's correct endpoint + "/settings/update-smtp-settings", data=new_smtp_settings, follow_redirects=True, ) + assert response.status_code == 200 + assert "SMTP settings updated successfully" in response.text SMTP.assert_called_with(user.smtp_server, user.smtp_port, timeout=ANY) SMTP.return_value.__enter__.return_value.starttls.assert_not_called() SMTP.return_value.__enter__.return_value.login.assert_called_once_with( user.smtp_username, user.smtp_password ) - # Check successful update - assert response.status_code == 200, "Failed to update SMTP settings" - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username="user_smtp_settings_ssl") - ).one() - assert ( - updated_user.email == new_smtp_settings["email_address"] - ), "Email address was not updated correctly" - assert ( - updated_user.smtp_server == new_smtp_settings["smtp_settings-smtp_server"] - ), "SMTP server was not updated correctly" - assert ( - updated_user.smtp_port == new_smtp_settings["smtp_settings-smtp_port"] - ), "SMTP port was not updated correctly" - assert ( - updated_user.smtp_username == new_smtp_settings["smtp_settings-smtp_username"] - ), "SMTP username was not updated correctly" - assert ( - updated_user.smtp_password == new_smtp_settings["smtp_settings-smtp_password"] - ), "SMTP password was not updated correctly" - assert ( - updated_user.smtp_encryption.value == new_smtp_settings["smtp_settings-smtp_encryption"] - ), "SMTP encryption was not updated correctly" - assert ( - updated_user.smtp_sender == new_smtp_settings["smtp_settings-smtp_sender"] - ), "SMTP sender was not updated correctly" - - # Optional: Check for success message in response - assert b"SMTP settings updated successfully" in response.data, "Success message not found" + + updated_user = Username.query.filter_by(_username="user_smtp_settings_ssl").one().user + assert updated_user.email == new_smtp_settings["email_address"] + assert updated_user.smtp_server == new_smtp_settings["smtp_settings-smtp_server"] + assert updated_user.smtp_port == new_smtp_settings["smtp_settings-smtp_port"] + assert updated_user.smtp_username == new_smtp_settings["smtp_settings-smtp_username"] + assert updated_user.smtp_password == new_smtp_settings["smtp_settings-smtp_password"] + assert updated_user.smtp_encryption.value == new_smtp_settings["smtp_settings-smtp_encryption"] + assert updated_user.smtp_sender == new_smtp_settings["smtp_settings-smtp_sender"] @patch("hushline.utils.smtplib.SMTP") def test_update_smtp_settings_default_forwarding(SMTP: MagicMock, client: FlaskClient) -> None: - # Register and log in a user - user = register_user(client, "user_default_forwarding", "SecureTestPass123!") - assert user is not None, "User registration failed" - - login_user(client, "user_default_forwarding", "SecureTestPass123!") + username = "user_default_forwarding" + password = "SecureTestPass123!" + register_user(client, username, password) + login_user(client, username, password) configure_pgp(client) - # Define new SMTP settings new_smtp_settings = { "forwarding_enabled": True, "email_address": "primary@example.com", "smtp_settings-smtp_encryption": "StartTLS", } - # Submit POST request to update SMTP settings response = client.post( - "/settings/update-smtp-settings", # Adjust to your app's correct endpoint + "/settings/update-smtp-settings", data=new_smtp_settings, follow_redirects=True, ) + assert response.status_code == 200 + assert "SMTP settings updated successfully" in response.text SMTP.assert_not_called() SMTP.return_value.__enter__.return_value.starttls.assert_not_called() SMTP.return_value.__enter__.return_value.login.assert_not_called() - # Check successful update - assert response.status_code == 200, "Failed to update SMTP settings" - updated_user = db.session.scalars( - db.select(User).filter_by(primary_username="user_default_forwarding") - ).one() - assert ( - updated_user.email == new_smtp_settings["email_address"] - ), "Email address was not updated correctly" - assert updated_user.smtp_server is None, "SMTP server was not updated correctly" - assert updated_user.smtp_port is None, "SMTP port was not updated correctly" - assert updated_user.smtp_username is None, "SMTP username was not updated correctly" - assert updated_user.smtp_password is None, "SMTP password was not updated correctly" - assert ( - updated_user.smtp_encryption.value == SMTPEncryption.default().value - ), "SMTP encryption was not updated correctly" - assert updated_user.smtp_sender is None, "SMTP sender was not updated correctly" - - # Optional: Check for success message in response - assert b"SMTP settings updated successfully" in response.data, "Success message not found" + + updated_user = Username.query.filter_by(_username=username).one().user + assert updated_user.email == new_smtp_settings["email_address"] + assert updated_user.smtp_server is None + assert updated_user.smtp_port is None + assert updated_user.smtp_username is None + assert updated_user.smtp_password is None + assert updated_user.smtp_encryption.value == SMTPEncryption.default().value + assert updated_user.smtp_sender is None From 2d00273c7f1c3e2c51365954bb7c7137e3458db4 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Fri, 13 Sep 2024 16:15:44 +0000 Subject: [PATCH 07/25] minor routes cleanup --- hushline/routes.py | 65 +++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/hushline/routes.py b/hushline/routes.py index 7c0246f4..b0f62274 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -76,6 +76,30 @@ class LoginForm(FlaskForm): password = PasswordField("Password", validators=[DataRequired()]) +def validate_captcha(captcha_answer: str) -> bool: + if not captcha_answer.isdigit(): + flash("Incorrect CAPTCHA. Please enter a valid number.", "error") + return False + + if captcha_answer != session.get("math_answer"): + flash("Incorrect CAPTCHA. Please try again.", "error") + return False + + return True + + +def get_ip_address() -> str: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("1.1.1.1", 1)) + ip_address = s.getsockname()[0] + except Exception: + ip_address = "127.0.0.1" + finally: + s.close() + return ip_address + + def init_app(app: Flask) -> None: @app.route("/") def index() -> Response: @@ -137,8 +161,8 @@ def profile(username: str) -> Response | str: session.pop(f"{scope}:salt", None) # Generate a simple math problem using secrets module (e.g., "What is 6 + 7?") - num1 = secrets.randbelow(10) + 1 # To get a number between 1 and 10 - num2 = secrets.randbelow(10) + 1 # To get a number between 1 and 10 + num1 = secrets.randbelow(10) + 1 + num2 = secrets.randbelow(10) + 1 math_problem = f"{num1} + {num2} =" session["math_answer"] = str(num1 + num2) # Store the answer in session as a string @@ -252,17 +276,6 @@ def submit_message(username: str) -> Response | str: def redirect_submit_message(username: str) -> Response: return redirect(url_for("profile", username=username), 301) - def validate_captcha(captcha_answer: str) -> bool: - if not captcha_answer.isdigit(): - flash("Incorrect CAPTCHA. Please enter a valid number.", "error") - return False - - if captcha_answer != session.get("math_answer"): - flash("Incorrect CAPTCHA. Please try again.", "error") - return False - - return True - @app.route("/delete_message/", methods=["POST"]) @authentication_required def delete_message(message_id: int) -> Response: @@ -343,7 +356,6 @@ def register() -> Response | str | tuple[Response | str, int]: 409, ) - # Create new user instance user = User(password=password) db.session.add(user) db.session.flush() @@ -384,7 +396,6 @@ def login() -> Response | str: session["is_authenticated"] = False return redirect(url_for("verify_2fa_login")) - # Successful login auth_log = AuthenticationLog(user_id=username.user_id, successful=True) db.session.add(auth_log) db.session.commit() @@ -404,7 +415,6 @@ def verify_2fa_login() -> Response | str | tuple[Response | str, int]: session.clear() return redirect(url_for("login")) - # Redirect to inbox if already authenticated if session.get("is_authenticated", False): return redirect(url_for("inbox")) @@ -419,7 +429,6 @@ def verify_2fa_login() -> Response | str | tuple[Response | str, int]: timecode = totp.timecode(datetime.now()) verification_code = form.verification_code.data - # Rate limit 2FA attempts rate_limit = False # If the most recent successful login was made with the same OTP code, reject this one @@ -456,7 +465,6 @@ def verify_2fa_login() -> Response | str | tuple[Response | str, int]: return render_template("verify_2fa_login.html", form=form), 429 if totp.verify(verification_code): - # Successful login auth_log = AuthenticationLog( user_id=user.id, successful=True, otp_code=verification_code, timecode=timecode ) @@ -466,7 +474,6 @@ def verify_2fa_login() -> Response | str | tuple[Response | str, int]: session["is_authenticated"] = True return redirect(url_for("inbox")) - # Failed login auth_log = AuthenticationLog(user_id=user.id, successful=False) db.session.add(auth_log) db.session.commit() @@ -481,17 +488,8 @@ def verify_2fa_login() -> Response | str | tuple[Response | str, int]: @app.route("/logout") @authentication_required def logout() -> Response: - # Explicitly remove specific session keys related to user authentication - session.pop("user_id", None) - session.pop("is_authenticated", None) - - # Clear the entire session to ensure no leftover data session.clear() - - # Flash a confirmation message for the user flash("👋 You have been logged out successfully.", "info") - - # Redirect to the login page or home page after logout return redirect(url_for("index")) def get_directory_usernames(admin_first: bool = False) -> list[Username]: @@ -536,17 +534,6 @@ def directory_users() -> list[dict[str, str | bool | None]]: def vision() -> str: return render_template("vision.html") - def get_ip_address() -> str: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - s.connect(("1.1.1.1", 1)) - ip_address = s.getsockname()[0] - except Exception: - ip_address = "127.0.0.1" - finally: - s.close() - return ip_address - @app.route("/info") def personal_server_info() -> Response: if app.config.get("IS_PERSONAL_SERVER"): From b4b688cb218763cb75277d0cad446bc703ec353c Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Sat, 14 Sep 2024 06:11:11 +0000 Subject: [PATCH 08/25] factor settings form handlers into their own functions --- hushline/settings/__init__.py | 169 +++++++++++++++++----------------- tests/test_directory.py | 39 ++++---- 2 files changed, 100 insertions(+), 108 deletions(-) diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index 3369389f..e5b83e58 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -87,6 +87,77 @@ async def verify_url( setattr(username, f"extra_field_verified{i}", False) +async def handle_update_bio(username: Username, form: ProfileForm) -> Response: + username.bio = form.bio.data.strip() + + # Define base_url from the environment or config + profile_url = url_for("profile", _external=True, username=username._username) + + async with aiohttp.ClientSession() as client_session: + tasks = [] + for i in range(1, 5): + if (label_field := getattr(form, f"extra_field_label{i}", None)) and ( + label := getattr(label_field, "data", None) + ): + setattr(username, f"extra_field_label{i}", label) + + if (value_field := getattr(form, f"extra_field_value{i}", None)) and ( + value := getattr(value_field, "data", None) + ): + setattr(username, f"extra_field_value{i}", value) + else: + setattr(username, f"extra_field_verified{i}", False) + continue + + # Verify the URL only if it starts with "https://" + if value.startswith("https://"): + task = verify_url(client_session, username, i, value, profile_url) + tasks.append(task) + + # Run all the tasks concurrently + if tasks: # Only gather if there are tasks to run + await asyncio.gather(*tasks) + + db.session.commit() + flash("👍 Bio and fields updated successfully.") + return redirect(url_for("settings.index")) + + +def handle_update_directory_visibility(user: Username, form: DirectoryVisibilityForm) -> Response: + user.primary_username.show_in_directory = form.show_in_directory.data + db.session.commit() + flash("👍 Directory visibility updated successfully.") + return redirect(url_for("settings.index")) + + +def handle_display_name_form(username: Username, form: DisplayNameForm) -> Response: + username.display_name = form.display_name.data.strip() + flash("👍 Display name updated successfully.") + current_app.logger.debug( + f"Display name updated to {username.display_name}, " + f"Verification status: {username.is_verified}" + ) + return redirect(url_for(".index")) + + +def handle_change_username_form(username: Username, form: ChangeUsernameForm) -> Response: + new_username = form.new_username.data + + # TODO a better pattern would be to try to commit, catch the exception, and match + # on the name of the unique index that errored + if db.session.query(exists(Username).where(Username._username == new_username)).scalar(): + flash("💔 This username is already taken.") + else: + username.username = new_username + session["username"] = new_username + flash("👍 Username changed successfully.") + current_app.logger.debug( + f"Username updated to {username.username}, " + f"Verification status: {username.is_verified}" + ) + return redirect(url_for(".index")) + + def create_blueprint() -> Blueprint: bp = Blueprint("settings", __file__, url_prefix="/settings") @@ -114,88 +185,22 @@ async def index() -> str | Response: directory_visibility_form = DirectoryVisibilityForm() profile_form = ProfileForm() - # Handle form submissions if request.method == "POST": # Update bio and custom fields if "update_bio" in request.form and profile_form.validate_on_submit(): - user.primary_username.bio = profile_form.bio.data.strip() - - # Define base_url from the environment or config - profile_url = url_for("profile", _external=True, username=user.primary_username) - - async with aiohttp.ClientSession() as client_session: - tasks = [] - for i in range(1, 5): - if ( - label_field := getattr(profile_form, f"extra_field_label{i}", None) - ) and (label := getattr(label_field, "data", None)): - setattr(user.primary_username, f"extra_field_label{i}", label) - - if ( - value_field := getattr(profile_form, f"extra_field_value{i}", None) - ) and (value := getattr(value_field, "data", None)): - setattr(user.primary_username, f"extra_field_value{i}", value) - else: - setattr(user.primary_username, f"extra_field_verified{i}", False) - continue - - # Verify the URL only if it starts with "https://" - if value.startswith("https://"): - task = verify_url( - client_session, user.primary_username, i, value, profile_url - ) - tasks.append(task) - - # Run all the tasks concurrently - if tasks: # Only gather if there are tasks to run - await asyncio.gather(*tasks) - - db.session.commit() - flash("👍 Bio and fields updated successfully.") - return redirect(url_for("settings.index")) - - # Update directory visibility + return await handle_update_bio(user.primary_username, profile_form) if ( "update_directory_visibility" in request.form and directory_visibility_form.validate_on_submit() ): - user.primary_username.show_in_directory = ( - directory_visibility_form.show_in_directory.data - ) - db.session.commit() - flash("👍 Directory visibility updated successfully.") - return redirect(url_for("settings.index")) - - # Handle Display Name Form Submission + return update_directory_visibility(user.primary_username, directory_visibility_form) if "update_display_name" in request.form and display_name_form.validate_on_submit(): - user.primary_username.display_name = display_name_form.display_name.data.strip() - db.session.commit() - flash("👍 Display name updated successfully.") - current_app.logger.debug( - f"Display name updated to {user.primary_username.display_name}, " - f"Verification status: {user.primary_username.is_verified}" - ) - return redirect(url_for(".index")) - - # Handle Change Username Form Submission + return handle_display_name_form(user.primary_username, display_name_form) if "change_username" in request.form and change_username_form.validate_on_submit(): - new_username = change_username_form.new_username.data - - # TODO a better pattern would be to try to commit, catch the exception, and match - # on the name of the unique index that errored - if db.session.query( - exists(Username).where(Username._username == new_username) - ).scalar(): - flash("💔 This username is already taken.") - else: - user.primary_username.username = new_username - session["username"] = new_username - flash("👍 Username changed successfully.") - current_app.logger.debug( - f"Username updated to {user.primary_username.username}, " - f"Verification status: {user.primary_username.is_verified}" - ) - return redirect(url_for(".index")) + return handle_change_username_form(user.primary_username, change_username_form) + current_app.logger.error( + "Unable to handle form submission on endpoint {request.endpoint!}" + ) # Additional admin-specific data initialization user_count = two_fa_count = pgp_key_count = two_fa_percentage = pgp_key_percentage = None @@ -238,7 +243,7 @@ async def index() -> str | Response: return render_template( "settings.html", user=user, - all_users=all_users, # Pass to the template for admin view + all_users=all_users, email_forwarding_form=email_forwarding_form, change_password_form=change_password_form, change_username_form=change_username_form, @@ -258,7 +263,7 @@ async def index() -> str | Response: default_forwarding_enabled=bool(current_app.config["NOTIFICATIONS_ADDRESS"]), ) - @bp.route("//update_directory_visibility", methods=["POST"]) + @bp.route("/update_directory_visibility", methods=["POST"]) @authentication_required def update_directory_visibility() -> Response: if "user_id" in session: @@ -302,20 +307,18 @@ def change_password() -> str | Response: flash("New password is invalid.") return redirect(url_for("settings.index")) - # Verify the old password if not user.check_password(change_password_form.old_password.data): flash("Incorrect old password.", "error") return redirect(url_for("settings.index")) - # Set the new password user.password_hash = change_password_form.new_password.data db.session.commit() - session.clear() # Clears the session, logging the user out + session.clear() flash( "👍 Password successfully changed. Please log in again.", "success", ) - return redirect(url_for("login")) # Redirect to the login page for re-authentication + return redirect(url_for("login")) @bp.route("/enable-2fa", methods=["GET", "POST"]) @authentication_required @@ -336,7 +339,7 @@ def enable_2fa() -> Response | str: db.session.commit() session.pop("temp_totp_secret", None) flash("👍 2FA setup successful. Please log in again with 2FA.") - return redirect(url_for("logout")) # Redirect to logout + return redirect(url_for("logout")) flash("⛔️ Invalid 2FA code. Please try again.") return redirect(url_for(".enable_2fa")) @@ -354,7 +357,6 @@ def enable_2fa() -> Response | str: img.save(buffered) qr_code_img = "data:image/png;base64," + base64.b64encode(buffered.getvalue()).decode() - # Pass the text-based pairing code and the user to the template return render_template( "enable_2fa.html", form=form, @@ -571,14 +573,11 @@ def delete_account() -> Response | str: user = db.session.get(User, user_id) if user: - # Explicitly delete messages for the user db.session.execute(db.delete(Message).filter_by(user_id=user.id)) - - # Now delete the user db.session.delete(user) db.session.commit() - session.clear() # Clear the session + session.clear() flash("🔥 Your account and all related information have been deleted.") return redirect(url_for("index")) diff --git a/tests/test_directory.py b/tests/test_directory.py index ea60459a..a95ed37a 100644 --- a/tests/test_directory.py +++ b/tests/test_directory.py @@ -5,31 +5,24 @@ def test_directory_accessible(client: FlaskClient) -> None: - # Get the directory page response = client.get("/directory") - - # Check if the page loads successfully assert response.status_code == 200 - assert "User Directory" in response.get_data(as_text=True) + assert "User Directory" in response.text def test_directory_lists_only_opted_in_users(client: FlaskClient) -> None: - """Test that only users who have opted to be shown are listed in the directory.""" - with client.application.app_context(): - # Register and opt-in a user - user_opted_in = register_user(client, "user_optedin", "SecurePassword123!") - user_opted_in.show_in_directory = True - db.session.commit() - - # Register and do not opt-in another user - user_not_opted_in = register_user(client, "user_not_optedin", "SecurePassword123!") - user_not_opted_in.show_in_directory = False - db.session.commit() - - # Access the directory as a logged-in user - login_user(client, "user_optedin", "SecurePassword123!") - response = client.get("/directory") - assert "user_optedin" in response.get_data(as_text=True), "Opted-in user should be listed" - assert "user_not_optedin" not in response.get_data( - as_text=True - ), "Non-opted-in user should not be listed" + # Register and opt-in a user + user_opted_in = register_user(client, "user_optedin", "SecurePassword123!") + user_opted_in.primary_username.show_in_directory = True + db.session.commit() + + # Register and do not opt-in another user + user_not_opted_in = register_user(client, "user_not_optedin", "SecurePassword123!") + user_not_opted_in.primary_username.show_in_directory = False + db.session.commit() + + # Access the directory as a logged-in user + login_user(client, "user_optedin", "SecurePassword123!") + response = client.get("/directory") + assert "user_optedin" in response.text + assert "user_not_optedin" not in response.text From 0d63b6e638c3427eeee7a5b7d61f3d26399c6429 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Sat, 14 Sep 2024 09:10:12 +0000 Subject: [PATCH 09/25] basic support for aliases --- hushline/model.py | 14 +- hushline/routes.py | 5 +- hushline/settings/__init__.py | 137 ++++-- hushline/settings/forms.py | 4 + hushline/static/css/style.css | 23 + hushline/templates/inbox.html | 5 +- hushline/templates/settings.html | 557 ---------------------- hushline/templates/settings/advanced.html | 21 + hushline/templates/settings/alias.html | 181 +++++++ hushline/templates/settings/aliases.html | 33 ++ hushline/templates/settings/auth.html | 77 +++ hushline/templates/settings/email.html | 137 ++++++ hushline/templates/settings/index.html | 51 ++ hushline/templates/settings/profile.html | 175 +++++++ tests/test_settings.py | 2 +- 15 files changed, 828 insertions(+), 594 deletions(-) delete mode 100644 hushline/templates/settings.html create mode 100644 hushline/templates/settings/advanced.html create mode 100644 hushline/templates/settings/alias.html create mode 100644 hushline/templates/settings/aliases.html create mode 100644 hushline/templates/settings/auth.html create mode 100644 hushline/templates/settings/email.html create mode 100644 hushline/templates/settings/index.html create mode 100644 hushline/templates/settings/profile.html diff --git a/hushline/model.py b/hushline/model.py index abc6eebc..096ce077 100644 --- a/hushline/model.py +++ b/hushline/model.py @@ -114,6 +114,16 @@ class User(Model): primaryjoin="and_(Username.user_id == User.id, Username.is_primary)", back_populates="user", ) + messages: Mapped[list["Message"]] = relationship( + secondary="usernames", + primaryjoin="Username.user_id == User.id", + secondaryjoin="Message.username_id == Username.id", + order_by="Message.id.desc()", + backref=db.backref("user", lazy=False, uselist=False, viewonly=True), + lazy=True, + uselist=True, + viewonly=True, + ) _email: Mapped[Optional[str]] = mapped_column("email", db.String(255)) _smtp_server: Mapped[Optional[str]] = mapped_column("smtp_server", db.String(255)) @@ -245,8 +255,8 @@ def __init__( class Message(Model): id: Mapped[int] = mapped_column(primary_key=True) _content: Mapped[str] = mapped_column("content", db.Text) # Encrypted content stored here - username_id: Mapped[int] = mapped_column(db.ForeignKey("users.id")) - user: Mapped["User"] = relationship(backref=db.backref("messages", lazy=True)) + username_id: Mapped[int] = mapped_column(db.ForeignKey("usernames.id")) + username: Mapped["Username"] = relationship(uselist=False) def __init__(self, **kwargs: Any) -> None: if "_content" in kwargs: diff --git a/hushline/routes.py b/hushline/routes.py index b0f62274..2721da2d 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -122,12 +122,9 @@ def inbox() -> Response | str: flash("👉 Please log in to access your inbox.") return redirect(url_for("login")) - messages = Message.query.filter_by(username_id=user.id).order_by(Message.id.desc()).all() - return render_template( "inbox.html", user=user, - messages=messages, is_personal_server=app.config["IS_PERSONAL_SERVER"], ) @@ -230,7 +227,7 @@ def submit_message(username: str) -> Response | str: else: content_to_save = full_content - new_message = Message(content=content_to_save, username_id=uname.user_id) + new_message = Message(content=content_to_save, username_id=uname.id) db.session.add(new_message) db.session.commit() diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index e5b83e58..4b436d29 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -17,6 +17,7 @@ session, url_for, ) +from sqlalchemy.exc import IntegrityError from sqlalchemy.sql import exists from werkzeug.wrappers.response import Response from wtforms import Field @@ -32,6 +33,7 @@ DirectoryVisibilityForm, DisplayNameForm, EmailForwardingForm, + NewAliasForm, PGPKeyForm, PGPProtonForm, ProfileForm, @@ -87,7 +89,7 @@ async def verify_url( setattr(username, f"extra_field_verified{i}", False) -async def handle_update_bio(username: Username, form: ProfileForm) -> Response: +async def handle_update_bio(username: Username, form: ProfileForm, redirect_url: str) -> Response: username.bio = form.bio.data.strip() # Define base_url from the environment or config @@ -120,27 +122,33 @@ async def handle_update_bio(username: Username, form: ProfileForm) -> Response: db.session.commit() flash("👍 Bio and fields updated successfully.") - return redirect(url_for("settings.index")) + return redirect(redirect_url) -def handle_update_directory_visibility(user: Username, form: DirectoryVisibilityForm) -> Response: - user.primary_username.show_in_directory = form.show_in_directory.data +def handle_update_directory_visibility( + user: Username, form: DirectoryVisibilityForm, redirect_url: str +) -> Response: + user.show_in_directory = form.show_in_directory.data db.session.commit() flash("👍 Directory visibility updated successfully.") - return redirect(url_for("settings.index")) + return redirect(redirect_url) -def handle_display_name_form(username: Username, form: DisplayNameForm) -> Response: +def handle_display_name_form( + username: Username, form: DisplayNameForm, redirect_url: str +) -> Response: username.display_name = form.display_name.data.strip() flash("👍 Display name updated successfully.") current_app.logger.debug( f"Display name updated to {username.display_name}, " f"Verification status: {username.is_verified}" ) - return redirect(url_for(".index")) + return redirect(redirect_url) -def handle_change_username_form(username: Username, form: ChangeUsernameForm) -> Response: +def handle_change_username_form( + username: Username, form: ChangeUsernameForm, redirect_url: str +) -> Response: new_username = form.new_username.data # TODO a better pattern would be to try to commit, catch the exception, and match @@ -155,7 +163,25 @@ def handle_change_username_form(username: Username, form: ChangeUsernameForm) -> f"Username updated to {username.username}, " f"Verification status: {username.is_verified}" ) - return redirect(url_for(".index")) + return redirect(redirect_url) + + +def handle_new_alias_form(user: User, new_alias_form: NewAliasForm, redirect_url: str) -> Response: + current_app.logger.debug("Creating alias for {user.primary_username.username}") + # TODO check that users are allowed to add aliases here (is premium, not too many) + # TODO check that alias is not yet taken + uname = Username(_username=new_alias_form.username.data, user_id=user.id, is_primary=False) + db.session.add(uname) + try: + db.session.commit() + except IntegrityError as e: + if 'duplicate key value violates unique constraint "usernames_username_key"' in str(e): + flash("💔 This username is already taken.") + else: + flash("⛔️ Internal server error. Alias not created.") + else: + flash("👍 Alias created successfully.") + return redirect(redirect_url) def create_blueprint() -> Blueprint: @@ -183,25 +209,42 @@ async def index() -> str | Response: email_forwarding_form = EmailForwardingForm() display_name_form = DisplayNameForm() directory_visibility_form = DirectoryVisibilityForm() + new_alias_form = NewAliasForm() profile_form = ProfileForm() if request.method == "POST": # Update bio and custom fields if "update_bio" in request.form and profile_form.validate_on_submit(): - return await handle_update_bio(user.primary_username, profile_form) + return await handle_update_bio( + user.primary_username, profile_form, url_for(".index") + ) if ( "update_directory_visibility" in request.form and directory_visibility_form.validate_on_submit() ): - return update_directory_visibility(user.primary_username, directory_visibility_form) + return handle_update_directory_visibility( + user.primary_username, directory_visibility_form, url_for(".index") + ) if "update_display_name" in request.form and display_name_form.validate_on_submit(): - return handle_display_name_form(user.primary_username, display_name_form) + return handle_display_name_form( + user.primary_username, display_name_form, url_for(".index") + ) if "change_username" in request.form and change_username_form.validate_on_submit(): - return handle_change_username_form(user.primary_username, change_username_form) + return handle_change_username_form( + user.primary_username, change_username_form, url_for(".index") + ) + if "new_alias" in request.form and new_alias_form.validate_on_submit(): + return handle_new_alias_form(user, new_alias_form, url_for(".index")) current_app.logger.error( - "Unable to handle form submission on endpoint {request.endpoint!}" + "Unable to handle form submission on endpoint {request.endpoint!r}" ) + flash("Uh oh. There was an error handling your data. Please notify the admin.") + aliases = ( + Username.query.filter_by(is_primary=False, user_id=user.id) + .order_by(db.func.coalesce(Username._display_name, Username._username)) + .all() + ) # Additional admin-specific data initialization user_count = two_fa_count = pgp_key_count = two_fa_percentage = pgp_key_percentage = None all_users = [] @@ -241,7 +284,7 @@ async def index() -> str | Response: directory_visibility_form.show_in_directory.data = user.primary_username.show_in_directory return render_template( - "settings.html", + "settings/index.html", user=user, all_users=all_users, email_forwarding_form=email_forwarding_form, @@ -251,6 +294,9 @@ async def index() -> str | Response: pgp_key_form=pgp_key_form, display_name_form=display_name_form, profile_form=profile_form, + new_alias_form=new_alias_form, + aliases=aliases, + max_aliases=5, # TODO hardcoded for now # Admin-specific data passed to the template is_admin=user.is_admin, user_count=user_count, @@ -263,19 +309,6 @@ async def index() -> str | Response: default_forwarding_enabled=bool(current_app.config["NOTIFICATIONS_ADDRESS"]), ) - @bp.route("/update_directory_visibility", methods=["POST"]) - @authentication_required - def update_directory_visibility() -> Response: - if "user_id" in session: - user = User.query.get(session.get("user_id")) - if user: - user.primary_username.show_in_directory = "show_in_directory" in request.form - db.session.commit() - flash("👍 Directory visibility updated.") - else: - flash("⛔️ You need to be logged in to update settings.") - return redirect(url_for("settings.index")) - @bp.route("/toggle-2fa", methods=["POST"]) @authentication_required def toggle_2fa() -> Response: @@ -584,4 +617,52 @@ def delete_account() -> Response | str: flash("User not found. Please log in again.") return redirect(url_for("login")) + @authentication_required + @bp.route("/alias/", methods=["GET", "POST"]) + async def alias(username_id: int) -> Response | str: + user = User.query.get(session["user_id"]) + alias = Username.query.filter_by( + id=username_id, user_id=user.id, is_primary=False + ).one_or_none() + if not alias: + flash("Alias not found.") + return redirect(url_for(".index")) + + display_name_form = DisplayNameForm() + profile_form = ProfileForm() + directory_visibility_form = DirectoryVisibilityForm( + show_in_directory=alias.show_in_directory + ) + + if request.method == "POST": + if "update_bio" in request.form and profile_form.validate_on_submit(): + return await handle_update_bio( + alias, profile_form, url_for(".alias", username_id=username_id) + ) + if ( + "update_directory_visibility" in request.form + and directory_visibility_form.validate_on_submit() + ): + return handle_update_directory_visibility( + alias, directory_visibility_form, url_for(".alias", username_id=username_id) + ) + if "update_display_name" in request.form and display_name_form.validate_on_submit(): + return handle_display_name_form( + alias, display_name_form, url_for(".alias", username_id=username_id) + ) + + current_app.logger.error( + "Unable to handle form submission on endpoint {request.endpoint!r}" + ) + flash("Uh oh. There was an error handling your data. Please notify the admin.") + + return render_template( + "settings/alias.html", + user=user, + alias=alias, + display_name_form=display_name_form, + directory_visibility_form=directory_visibility_form, + profile_form=profile_form, + ) + return bp diff --git a/hushline/settings/forms.py b/hushline/settings/forms.py index abddd203..e356c20d 100644 --- a/hushline/settings/forms.py +++ b/hushline/settings/forms.py @@ -117,6 +117,10 @@ class DisplayNameForm(FlaskForm): display_name = StringField("Display Name", validators=[Length(max=100)]) +class NewAliasForm(FlaskForm): + username = StringField("Username", validators=[DataRequired(), Length(min=4, max=25)]) + + class DirectoryVisibilityForm(FlaskForm): show_in_directory = BooleanField("Show on public directory") diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 802f1799..0833c503 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2083,3 +2083,26 @@ p.bio + .extra-fields { width: 100%; } } + +.alias-list { + display: flex; + flex-direction: column; +} + +.alias-entry { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + position: relative; /* needed for stretched-link */ +} + +.stretched-link::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: q; + content: ''; +} diff --git a/hushline/templates/inbox.html b/hushline/templates/inbox.html index 8c25d33e..9f43bfe9 100644 --- a/hushline/templates/inbox.html +++ b/hushline/templates/inbox.html @@ -2,14 +2,15 @@ {% block title %}Inbox{% endblock %} {% block content %} - {% if messages %} + {% if user.messages %}

Inbox for {{ user.primary_username.display_name or user.primary_username.username }}

- {% for message in messages %} + {% for message in user.messages %}
+

To: @{{ message.username.username }}

{{ message.content }}

Settings -
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • - {% if user.is_admin %} -
  • - -
  • - {% endif %} -
-
-
-
-

Update Display Name

- {% if user.is_verified %} -

- ⚠️ Changing your display name will result in losing your verification - status. -

- {% endif %} -
- {{ display_name_form.hidden_tag() }} - - {{ display_name_form.display_name(id='display_name') }} - {% if display_name_form.display_name.errors %} - {% for error in display_name_form.display_name.errors %} - {{ error }} - {% endfor %} - {% endif %} - -
- -

Public User Directory

-
- {{ directory_visibility_form.hidden_tag() }} -
- {{ directory_visibility_form.show_in_directory() }} - -
- -
- -

Add Your Bio

-
- {{ profile_form.hidden_tag() }} -
-
- - 0/250 -
- -
- -

Extra Fields

-

- Add links to social media, your Signal username, your pronouns, or - anything else you want on your profile. -

- -
-
-
- - -
-
- - - {% if user.extra_field_verified1 %} - - {% endif %} -
-
- -
-
- - -
-
- - - {% if user.extra_field_verified2 %} - - {% endif %} -
-
- -
-
- - -
-
- - - {% if user.extra_field_verified3 %} - - {% endif %} -
-
- -
-
- - -
-
- - - {% if user.extra_field_verified4 %} - - {% endif %} -
-
-
- -
-
- - - - - - - - {% if user.is_admin %} - - {% endif %} -
-{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/hushline/templates/settings/advanced.html b/hushline/templates/settings/advanced.html new file mode 100644 index 00000000..2d2fef17 --- /dev/null +++ b/hushline/templates/settings/advanced.html @@ -0,0 +1,21 @@ + diff --git a/hushline/templates/settings/alias.html b/hushline/templates/settings/alias.html new file mode 100644 index 00000000..f760132c --- /dev/null +++ b/hushline/templates/settings/alias.html @@ -0,0 +1,181 @@ +{% extends "base.html" %} + +{% block title %}Alias: @{{ alias.username }}{% endblock %} + +{% block content %} +

< Back to settings

+

Alias: @{{ alias.username }}

+ + {# TODO: much of this is copy/pasted from "settings/profile.html" and will need to be synced with updates there #} +

Update Display Name

+ {% if user.is_verified %} +

+ ⚠️ Changing your display name will result in losing your verification status. +

+ {% endif %} +
+ {{ display_name_form.hidden_tag() }} + + {{ display_name_form.display_name(id='display_name') }} + {% if display_name_form.display_name.errors %} + {% for error in display_name_form.display_name.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+ +

Public User Directory

+
+ {{ directory_visibility_form.hidden_tag() }} +
+ {{ directory_visibility_form.show_in_directory() }} + +
+ +
+ +

Add Your Bio

+
+ {{ profile_form.hidden_tag() }} +
+
+ + 0/250 +
+ +
+ +

Extra Fields

+

+ Add links to social media, your Signal username, your pronouns, or + anything else you want on your profile. +

+ +
+
+
+ + +
+
+ + + {% if alias.extra_field_verified1 %} + + {% endif %} +
+
+ +
+
+ + +
+
+ + + {% if alias.extra_field_verified2 %} + + {% endif %} +
+
+ +
+
+ + +
+
+ + + {% if alias.extra_field_verified3 %} + + {% endif %} +
+
+ +
+
+ + +
+
+ + + {% if alias.extra_field_verified4 %} + + {% endif %} +
+
+
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/hushline/templates/settings/aliases.html b/hushline/templates/settings/aliases.html new file mode 100644 index 00000000..b8683ced --- /dev/null +++ b/hushline/templates/settings/aliases.html @@ -0,0 +1,33 @@ +
+

Add an Alias

+ {% set alias_count = aliases.__len__() %} + {% if alias_count < max_aliases %} +
+ {{ new_alias_form.hidden_tag() }} + + {{ new_alias_form.username(id='alias_username') }} + +
+ {% else %} +

Alias limit reached.

+ {% endif %} + +

Current Aliases

+ {% if aliases %} + + {% else %} +

No alises configured.

+ {% endif %} +
diff --git a/hushline/templates/settings/auth.html b/hushline/templates/settings/auth.html new file mode 100644 index 00000000..eec6b385 --- /dev/null +++ b/hushline/templates/settings/auth.html @@ -0,0 +1,77 @@ + diff --git a/hushline/templates/settings/email.html b/hushline/templates/settings/email.html new file mode 100644 index 00000000..4ffc0ef1 --- /dev/null +++ b/hushline/templates/settings/email.html @@ -0,0 +1,137 @@ + diff --git a/hushline/templates/settings/index.html b/hushline/templates/settings/index.html new file mode 100644 index 00000000..a7b6f845 --- /dev/null +++ b/hushline/templates/settings/index.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}Settings{% endblock %} + +{% block content %} +

Settings

+
+
    + {% with buttons = [ + ('profile', 'Profile', False), + ('aliases', 'Aliases', False), + ('auth', 'Authentication', False), + ('email', 'Email & Encryption', False), + ('advanced', 'Advanced', False), + ('admin', 'Admin', True) + ] %} + {% for (id, display, requires_admin) in buttons %} + {% if not requires_admin or user.is_admin %} +
  • + +
  • + {% endif %} + {% endfor %} + {% endwith %} +
+
+
+ {% include "settings/profile.html" %} + {% include "settings/aliases.html" %} + {% include "settings/auth.html" %} + {% include "settings/email.html" %} + {% include "settings/advanced.html" %} + {% if user.is_admin %} + {% include "settings/admin.html" %} + {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/hushline/templates/settings/profile.html b/hushline/templates/settings/profile.html new file mode 100644 index 00000000..9aaf90ab --- /dev/null +++ b/hushline/templates/settings/profile.html @@ -0,0 +1,175 @@ +
+ {# TODO: much of this was copy/pasted to "settings/alias.html" and will need to have updates synced there #} +

Update Display Name

+ {% if user.is_verified %} +

+ ⚠️ Changing your display name will result in losing your verification status. +

+ {% endif %} +
+ {{ display_name_form.hidden_tag() }} + + {{ display_name_form.display_name(id='display_name') }} + {% if display_name_form.display_name.errors %} + {% for error in display_name_form.display_name.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+ +

Public User Directory

+
+ {{ directory_visibility_form.hidden_tag() }} +
+ {{ directory_visibility_form.show_in_directory() }} + +
+ +
+ +

Add Your Bio

+
+ {{ profile_form.hidden_tag() }} +
+
+ + 0/250 +
+ +
+ +

Extra Fields

+

+ Add links to social media, your Signal username, your pronouns, or + anything else you want on your profile. +

+ +
+
+
+ + +
+
+ + + {% if user.extra_field_verified1 %} + + {% endif %} +
+
+ +
+
+ + +
+
+ + + {% if user.extra_field_verified2 %} + + {% endif %} +
+
+ +
+
+ + +
+
+ + + {% if user.extra_field_verified3 %} + + {% endif %} +
+
+ +
+
+ + +
+
+ + + {% if user.extra_field_verified4 %} + + {% endif %} +
+
+
+ +
+
diff --git a/tests/test_settings.py b/tests/test_settings.py index b9513c01..4e27daf7 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -88,7 +88,7 @@ def test_change_password(client: FlaskClient) -> None: }, follow_redirects=True, ) - assert response.status_code == 200, "Failed to update password" + assert response.status_code == 200 assert "login" in response.request.url assert len(new_password_hash := user.password_hash) > 32 assert new_password_hash.startswith("$scrypt$") From 425792739a39af3e2af943109c60bd0190aa5236 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Tue, 17 Sep 2024 09:06:03 +0000 Subject: [PATCH 10/25] fix minor alias errors --- dev_data.py | 8 ++++++-- hushline/routes.py | 2 +- hushline/static/css/style.css | 2 +- hushline/templates/profile.html | 11 ++++++----- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/dev_data.py b/dev_data.py index 455b2071..eb36c822 100755 --- a/dev_data.py +++ b/dev_data.py @@ -14,8 +14,12 @@ def main() -> None: db.session.add(user) db.session.flush() - un = Username(user_id=user.id, _username=username, is_primary=True) - db.session.add(un) + un1 = Username(user_id=user.id, _username=username, is_primary=True, show_in_directory=True) + un2 = Username( + user_id=user.id, _username=username + "-alias", is_primary=False, show_in_directory=True + ) + db.session.add(un1) + db.session.add(un2) db.session.commit() print(f"User created:\n username = {username}\n password = {password}") diff --git a/hushline/routes.py b/hushline/routes.py index 2721da2d..e4542c9c 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -167,7 +167,7 @@ def profile(username: str) -> Response | str: "profile.html", form=form, user=uname.user, - username=username, + username=uname, display_name_or_username=uname.display_name or uname.username, current_user_id=session.get("user_id"), public_key=uname.user.pgp_key, diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 0833c503..62632cf6 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2104,5 +2104,5 @@ p.bio + .extra-fields { bottom: 0; left: 0; z-index: q; - content: ''; + content: ""; } diff --git a/hushline/templates/profile.html b/hushline/templates/profile.html index c6cfeb76..0160883d 100644 --- a/hushline/templates/profile.html +++ b/hushline/templates/profile.html @@ -35,17 +35,17 @@

{% endif %} - {% if user.primary_username.bio %} -

{{ user.primary_username.bio }}

+ {% if username.bio %} +

{{ username.bio }}

{% endif %} - {% if user.primary_username.valid_fields | length > 0 %} + {% if username.valid_fields | length > 0 %}
- {% for field in user.primary_username.valid_fields %} + {% for field in username.valid_fields %}

{{ field.label }} - {% if field.verified %} + {% if field.is_verified %} {% endif %} {% if field.value.startswith('https://') %} @@ -63,6 +63,7 @@

{% endfor %}

{% endif %} + {% set pgp_required_but_not_set = require_pgp and not user.pgp_key %} {% if current_user_id == user.id %} From 7112e53211cb50de90f6e3cce415267c19de674c Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Tue, 17 Sep 2024 14:57:15 +0000 Subject: [PATCH 11/25] wip --- hushline/admin.py | 2 +- hushline/templates/settings/admin.html | 60 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 hushline/templates/settings/admin.html diff --git a/hushline/admin.py b/hushline/admin.py index c926356a..6a0451d0 100644 --- a/hushline/admin.py +++ b/hushline/admin.py @@ -15,7 +15,7 @@ def toggle_verified(user_id: int) -> Response: user = db.session.get(User, user_id) if user is None: abort(404) - user.is_verified = not user.is_verified + user.primary_username.is_verified = not user.primary_username.is_verified db.session.commit() flash("✅ User verification status toggled.", "success") return redirect(url_for("settings.index")) diff --git a/hushline/templates/settings/admin.html b/hushline/templates/settings/admin.html new file mode 100644 index 00000000..5c93f6eb --- /dev/null +++ b/hushline/templates/settings/admin.html @@ -0,0 +1,60 @@ + From a73e8182a172ec9dce71f1408392720c0b73b4da Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Tue, 17 Sep 2024 14:58:10 +0000 Subject: [PATCH 12/25] better dev_data.py script --- dev_data.py | 55 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/dev_data.py b/dev_data.py index eb36c822..b33233c5 100755 --- a/dev_data.py +++ b/dev_data.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +from sqlalchemy.sql import exists + from hushline import create_app from hushline.db import db from hushline.model import User, Username @@ -7,22 +9,43 @@ def main() -> None: create_app().app_context().push() - username = "test" - password = "Test-testtesttesttest-1" # noqa: S105 - - user = User(password=password) - db.session.add(user) - db.session.flush() - - un1 = Username(user_id=user.id, _username=username, is_primary=True, show_in_directory=True) - un2 = Username( - user_id=user.id, _username=username + "-alias", is_primary=False, show_in_directory=True - ) - db.session.add(un1) - db.session.add(un2) - db.session.commit() - - print(f"User created:\n username = {username}\n password = {password}") + users = [ + { + "username": "test", + "password": "Test-testtesttesttest-1", + "is_admin": False, + }, + { + "username": "admin", + "password": "Test-testtesttesttest-1", + "is_admin": True, + }, + ] + + for data in users: + username = data["username"] + if not db.session.query(exists(Username).where(Username._username == username)).scalar(): + user = User(password=data["password"], is_admin=data["is_admin"]) + db.session.add(user) + db.session.flush() + + un1 = Username( + user_id=user.id, + _username=data["username"], + is_primary=True, + show_in_directory=True, + ) + un2 = Username( + user_id=user.id, + _username=data["username"] + "-alias", + is_primary=False, + show_in_directory=True, + ) + db.session.add(un1) + db.session.add(un2) + db.session.commit() + + print(f"Test user:\n username = {data['username']}\n password = {data['password']}") if __name__ == "__main__": From 92bf51bab99eced5aa9fcb85a8276ec06e4c4173 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Tue, 17 Sep 2024 15:11:33 +0000 Subject: [PATCH 13/25] minor routes improvements --- hushline/routes.py | 2 +- hushline/settings/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hushline/routes.py b/hushline/routes.py index e4542c9c..85573abd 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -342,7 +342,7 @@ def register() -> Response | str | tuple[Response | str, int]: 400, ) - if db.session.query(exists(Username).where(Username.username == username)).scalar(): + if db.session.query(exists(Username).where(Username._username == username)).scalar(): flash("💔 Username already taken.", "error") return ( render_template( diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index 4b436d29..a2df54d8 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -251,7 +251,6 @@ async def index() -> str | Response: # Check if user is admin and add admin-specific data if user.is_admin: - user_count = db.session.scalar(db.func.count(User.id)) two_fa_count = db.session.scalar( db.select(db.func.count(User.id).filter(User._totp_secret.isnot(None))) ) @@ -262,9 +261,10 @@ async def index() -> str | Response: .filter(User._pgp_key != "") ) ) + user_count = len(all_users) two_fa_percentage = (two_fa_count / user_count * 100) if user_count else 0 pgp_key_percentage = (pgp_key_count / user_count * 100) if user_count else 0 - all_users = list(User.query.all()) + all_users = list(User.query.join(Username).order_by(Username._username).all()) # Prepopulate form fields email_forwarding_form.forwarding_enabled.data = user.email is not None From bb524a83bf4466197e3223d70cd73f9a5ab4ecdf Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Tue, 17 Sep 2024 19:33:55 +0000 Subject: [PATCH 14/25] use session interface for sqlalchemy --- hushline/make_admin.py | 15 ++++++-------- hushline/routes.py | 31 +++++++++++++++++----------- hushline/settings/__init__.py | 30 +++++++++++++++------------ tests/auth_helper.py | 24 ++++++++++----------- tests/test_profile.py | 15 +++++++++++--- tests/test_registration_and_login.py | 4 ++-- tests/test_settings.py | 24 +++++++++++---------- 7 files changed, 80 insertions(+), 63 deletions(-) diff --git a/hushline/make_admin.py b/hushline/make_admin.py index 06de475b..1d658f7d 100755 --- a/hushline/make_admin.py +++ b/hushline/make_admin.py @@ -4,20 +4,19 @@ from hushline import create_app from hushline.db import db -from hushline.model import User +from hushline.models import Username def toggle_admin(username: str) -> None: - user = User.query.filter_by(primary_username=username).one_or_none() - if not user: + uname = db.session.scalars(db.select(Username).filter_by(_username=username)).one_or_none() + if not uname: print("User not found.") return - # Toggle admin status - user.is_admin = not user.is_admin + uname.user.is_admin = not uname.user.is_admin db.session.commit() - print(f"User {username} admin status toggled to {user.is_admin}.") + print(f"User {username} admin status toggled to {uname.user.is_admin}.") if __name__ == "__main__": @@ -25,7 +24,5 @@ def toggle_admin(username: str) -> None: print("Usage: python make_admin.py ") sys.exit(1) - username = sys.argv[1] - with create_app().app_context(): - toggle_admin(username) + toggle_admin(sys.argv[1]) diff --git a/hushline/routes.py b/hushline/routes.py index 85573abd..fe760025 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -19,7 +19,6 @@ ) from flask_wtf import FlaskForm from sqlalchemy import select -from sqlalchemy.sql import exists from werkzeug.wrappers.response import Response from wtforms import Field, Form, PasswordField, StringField, TextAreaField from wtforms.validators import DataRequired, Length, Optional, ValidationError @@ -131,7 +130,7 @@ def inbox() -> Response | str: @app.route("/to/", methods=["GET"]) def profile(username: str) -> Response | str: form = MessageForm() - uname = Username.query.filter_by(_username=username).one_or_none() + uname = db.session.scalars(db.select(Username).filter_by(_username=username)).one_or_none() if not uname: flash("🫥 User not found.") return redirect(url_for("index")) @@ -179,7 +178,7 @@ def profile(username: str) -> Response | str: @app.route("/to/", methods=["POST"]) def submit_message(username: str) -> Response | str: form = MessageForm() - uname = Username.query.filter_by(_username=username).one_or_none() + uname = db.session.scalars(db.select(Username).filter_by(_username=username)).one_or_none() if not uname: flash("🫥 User not found.") return redirect(url_for("index")) @@ -280,17 +279,23 @@ def delete_message(message_id: int) -> Response: flash("🔑 Please log in to continue.") return redirect(url_for("login")) - user = User.query.get(session.get("user_id")) + user = db.session.get(User, session.get("user_id")) if not user: flash("🫥 User not found. Please log in again.") return redirect(url_for("login")) - row_count = Message.query.filter( - Message.id == message_id, - Message.username_id.in_( - select(Username.user_id).select_from(Username).filter(Username.user_id == user.id) - ), - ).delete() + row_count = ( + db.delete(Message) + .where( + Message.id == message_id, + Message.username_id.in_( + select(Username.user_id) + .select_from(Username) + .filter(Username.user_id == user.id) + ), + ) + .delete() + ) match row_count: case 1: db.session.commit() @@ -328,7 +333,9 @@ def register() -> Response | str | tuple[Response | str, int]: invite_code_input = form.invite_code.data if require_invite_code else None if invite_code_input: - invite_code = InviteCode.query.filter_by(code=invite_code_input).one_or_none() + invite_code = db.session.scalars( + db.select(InviteCode).filter_by(code=invite_code_input) + ).one_or_none() if not invite_code or invite_code.expiration_date.replace( tzinfo=UTC ) < datetime.now(UTC): @@ -342,7 +349,7 @@ def register() -> Response | str | tuple[Response | str, int]: 400, ) - if db.session.query(exists(Username).where(Username._username == username)).scalar(): + if db.session.query(db.exists(Username).where(Username._username == username)).scalar(): flash("💔 Username already taken.", "error") return ( render_template( diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index a2df54d8..92eed2ae 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -17,8 +17,8 @@ session, url_for, ) +from psycopg.errors import UniqueViolation from sqlalchemy.exc import IntegrityError -from sqlalchemy.sql import exists from werkzeug.wrappers.response import Response from wtforms import Field @@ -153,7 +153,7 @@ def handle_change_username_form( # TODO a better pattern would be to try to commit, catch the exception, and match # on the name of the unique index that errored - if db.session.query(exists(Username).where(Username._username == new_username)).scalar(): + if db.session.query(db.exists(Username).where(Username._username == new_username)).scalar(): flash("💔 This username is already taken.") else: username.username = new_username @@ -169,13 +169,12 @@ def handle_change_username_form( def handle_new_alias_form(user: User, new_alias_form: NewAliasForm, redirect_url: str) -> Response: current_app.logger.debug("Creating alias for {user.primary_username.username}") # TODO check that users are allowed to add aliases here (is premium, not too many) - # TODO check that alias is not yet taken uname = Username(_username=new_alias_form.username.data, user_id=user.id, is_primary=False) db.session.add(uname) try: db.session.commit() except IntegrityError as e: - if 'duplicate key value violates unique constraint "usernames_username_key"' in str(e): + if isinstance(e.orig, UniqueViolation) and '"usernames_username_key"' in str(e.orig): flash("💔 This username is already taken.") else: flash("⛔️ Internal server error. Alias not created.") @@ -240,11 +239,11 @@ async def index() -> str | Response: ) flash("Uh oh. There was an error handling your data. Please notify the admin.") - aliases = ( - Username.query.filter_by(is_primary=False, user_id=user.id) + aliases = db.session.scalars( + db.select(Username) + .filter_by(is_primary=False, user_id=user.id) .order_by(db.func.coalesce(Username._display_name, Username._username)) - .all() - ) + ).all() # Additional admin-specific data initialization user_count = two_fa_count = pgp_key_count = two_fa_percentage = pgp_key_percentage = None all_users = [] @@ -264,7 +263,11 @@ async def index() -> str | Response: user_count = len(all_users) two_fa_percentage = (two_fa_count / user_count * 100) if user_count else 0 pgp_key_percentage = (pgp_key_count / user_count * 100) if user_count else 0 - all_users = list(User.query.join(Username).order_by(Username._username).all()) + all_users = list( + db.session.scalars( + db.select(User).join(Username).order_by(Username._username) + ).all() + ) # Prepopulate form fields email_forwarding_form.forwarding_enabled.data = user.email is not None @@ -620,9 +623,10 @@ def delete_account() -> Response | str: @authentication_required @bp.route("/alias/", methods=["GET", "POST"]) async def alias(username_id: int) -> Response | str: - user = User.query.get(session["user_id"]) - alias = Username.query.filter_by( - id=username_id, user_id=user.id, is_primary=False + alias = db.session.scalars( + db.select(Username).filter_by( + id=username_id, user_id=session["user_id"], is_primary=False + ) ).one_or_none() if not alias: flash("Alias not found.") @@ -658,7 +662,7 @@ async def alias(username_id: int) -> Response | str: return render_template( "settings/alias.html", - user=user, + user=alias.user, alias=alias, display_name_form=display_name_form, directory_visibility_form=directory_visibility_form, diff --git a/tests/auth_helper.py b/tests/auth_helper.py index 139821ac..6f589f3b 100644 --- a/tests/auth_helper.py +++ b/tests/auth_helper.py @@ -5,6 +5,7 @@ import pyotp from flask.testing import FlaskClient +from hushline.db import db from hushline.model import AuthenticationLog, User, Username @@ -12,33 +13,28 @@ def register_user(client: FlaskClient, username: str, password: str) -> User: # Prepare the environment to not require invite codes os.environ["REGISTRATION_CODES_REQUIRED"] = "False" - # User registration data user_data = {"username": username, "password": password} - # Post request to register a new user response = client.post("/register", data=user_data, follow_redirects=True) - - # Validate response assert response.status_code == 200 assert b"Registration successful!" in response.data - # Verify user is added to the database - user = User.query.join(Username).filter(Username._username == username).one_or_none() - assert user is not None + user = db.session.scalars( + db.select(User).join(Username).filter(Username._username == username) + ).one() assert user.primary_username.username == username return user def register_user_2fa(client: FlaskClient, username: str, password: str) -> tuple[User, str]: - # Register a new user user_data = {"username": username, "password": password} response = client.post("/register", data=user_data, follow_redirects=True) assert response.status_code == 200 - # Verify user is added to the database - user = User.query.join(Username).filter(Username._username == username).one_or_none() - assert user is not None + user = db.session.scalars( + db.select(User).join(Username).filter(Username._username == username) + ).one() assert user.primary_username.username == username # And 2FA is disabled @@ -75,7 +71,7 @@ def register_user_2fa(client: FlaskClient, username: str, password: str) -> tupl assert "Enter your 2FA Code" in login_response.text # Modify the timestamps on the AuthenticationLog entries to allow for 2FA verification - for log in AuthenticationLog.query.all(): + for log in db.session.scalars(db.select(AuthenticationLog)).all(): log.timestamp = datetime.now() - timedelta(minutes=5) return (user, totp_secret) @@ -95,7 +91,9 @@ def login_user(client: FlaskClient, username: str, password: str) -> User | None f'href="/inbox?username={username}"'.encode() in response.data ), f"Inbox link should be present for the user {username}" - if username := Username.query.filter_by(_username=username).one_or_none(): + if username := db.session.scalars( + db.select(Username).filter_by(_username=username) + ).one_or_none(): return username.user return None diff --git a/tests/test_profile.py b/tests/test_profile.py index 8dd4505f..964235d3 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -40,7 +40,9 @@ def test_profile_submit_message(client: FlaskClient) -> None: assert response.status_code == 200 assert b"Message submitted successfully." in response.data - message = Message.query.filter_by(username_id=user.primary_username.id).one() + message = db.session.scalars( + db.select(Message).filter_by(username_id=user.primary_username.id) + ).one() assert message.content == msg_content response = client.get(url_for("inbox", unamename=username), follow_redirects=True) @@ -75,7 +77,9 @@ def test_profile_submit_message_with_contact_method(client: FlaskClient) -> None assert response.status_code == 200 assert b"Message submitted successfully." in response.data - message = Message.query.filter_by(username_id=user.primary_username.id).one_or_none() + message = db.session.scalars( + db.select(Message).filter_by(username_id=user.primary_username.id) + ).one() expected_content = f"Contact Method: {contact_method}\n\n{message_content}" assert message.content == expected_content @@ -172,4 +176,9 @@ def test_profile_submit_message_with_invalid_captcha(client: FlaskClient) -> Non assert message_content.encode() in response.data # Verify that the message is not saved in the database - assert not Message.query.filter_by(username_id=user.primary_username.id).one_or_none() + assert ( + db.session.scalars( + db.select(Message).filter_by(username_id=user.primary_username.id) + ).one_or_none() + is None + ) diff --git a/tests/test_registration_and_login.py b/tests/test_registration_and_login.py index 53e84c82..2501a816 100644 --- a/tests/test_registration_and_login.py +++ b/tests/test_registration_and_login.py @@ -18,7 +18,7 @@ def test_user_registration_with_invite_code_disabled(client: FlaskClient) -> Non assert response.status_code == 200 assert "Registration successful!" in response.text - uname = Username.query.filter_by(_username=username).one() + uname = db.session.scalars(db.select(Username).filter_by(_username=username)).one() assert uname.username == username @@ -42,7 +42,7 @@ def test_user_registration_with_invite_code_enabled(client: FlaskClient) -> None assert response.status_code == 200 assert "Registration successful!" in response.text - uname = Username.query.filter_by(_username=username).one() + uname = db.session.scalars(db.select(Username).filter_by(_username=username)).one() assert uname.username == "newuser" diff --git a/tests/test_settings.py b/tests/test_settings.py index 4e27daf7..71faac0b 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -5,6 +5,7 @@ from flask import url_for from flask.testing import FlaskClient +from hushline.db import db from hushline.model import SMTPEncryption, Username @@ -38,7 +39,7 @@ def test_change_display_name(client: FlaskClient) -> None: assert response.status_code == 200, "Failed to update display name" assert "Display name updated successfully" in response.text - updated_user = Username.query.filter_by(_username=username).one() + updated_user = db.session.scalars(db.select(Username).filter_by(_username=username)).one() assert updated_user.display_name == new_display_name @@ -61,7 +62,7 @@ def test_change_username(client: FlaskClient) -> None: assert response.status_code == 200 assert "Username changed successfully" in response.text - updated_user = Username.query.filter_by(_username=new_username).one() + updated_user = db.session.scalars(db.select(Username).filter_by(_username=new_username)).one() assert updated_user.username == new_username assert not updated_user.is_verified @@ -116,8 +117,10 @@ def test_change_password(client: FlaskClient) -> None: def test_add_pgp_key(client: FlaskClient) -> None: - register_user(client, "user_with_pgp", "SecureTestPass123!") - login_user(client, "user_with_pgp", "SecureTestPass123!") + username = "user_with_pgp" + password = "SecureTestPass123!" + register_user(client, username, password) + login_user(client, username, password) with open("tests/test_pgp_key.txt") as file: new_pgp_key = file.read() @@ -130,7 +133,7 @@ def test_add_pgp_key(client: FlaskClient) -> None: assert response.status_code == 200, "Failed to update PGP key" assert "PGP key updated successfully" in response.text - updated_user = Username.query.filter_by(_username="user_with_pgp").one() + updated_user = db.session.scalars(db.select(Username).filter_by(_username=username)).one() assert updated_user.user.pgp_key == new_pgp_key @@ -150,7 +153,7 @@ def test_add_invalid_pgp_key(client: FlaskClient) -> None: assert response.status_code == 200 assert "Invalid PGP key format" in response.text - updated_user = Username.query.filter_by(_username=username).one() + updated_user = db.session.scalars(db.select(Username).filter_by(_username=username)).one() assert updated_user.user.pgp_key != invalid_pgp_key @@ -180,8 +183,7 @@ def test_update_smtp_settings_no_pgp(SMTP: MagicMock, client: FlaskClient) -> No assert response.status_code == 200 assert "Email forwarding requires a configured PGP key" in response.text - updated_user = Username.query.filter_by(_username=username).one().user - + updated_user = db.session.scalars(db.select(Username).filter_by(_username=username)).one().user assert updated_user.email is None assert updated_user.smtp_server is None assert updated_user.smtp_port is None @@ -224,7 +226,7 @@ def test_update_smtp_settings_starttls(SMTP: MagicMock, client: FlaskClient) -> user.smtp_username, user.smtp_password ) - updated_user = Username.query.filter_by(_username="user_smtp_settings_tls").one().user + updated_user = db.session.scalars(db.select(Username).filter_by(_username=username)).one().user assert updated_user.email == new_smtp_settings["email_address"] assert updated_user.smtp_server == new_smtp_settings["smtp_settings-smtp_server"] assert updated_user.smtp_port == new_smtp_settings["smtp_settings-smtp_port"] @@ -268,7 +270,7 @@ def test_update_smtp_settings_ssl(SMTP: MagicMock, client: FlaskClient) -> None: user.smtp_username, user.smtp_password ) - updated_user = Username.query.filter_by(_username="user_smtp_settings_ssl").one().user + updated_user = db.session.scalars(db.select(Username).filter_by(_username=username)).one().user assert updated_user.email == new_smtp_settings["email_address"] assert updated_user.smtp_server == new_smtp_settings["smtp_settings-smtp_server"] assert updated_user.smtp_port == new_smtp_settings["smtp_settings-smtp_port"] @@ -305,7 +307,7 @@ def test_update_smtp_settings_default_forwarding(SMTP: MagicMock, client: FlaskC SMTP.return_value.__enter__.return_value.starttls.assert_not_called() SMTP.return_value.__enter__.return_value.login.assert_not_called() - updated_user = Username.query.filter_by(_username=username).one().user + updated_user = db.session.scalars(db.select(Username).filter_by(_username=username)).one().user assert updated_user.email == new_smtp_settings["email_address"] assert updated_user.smtp_server is None assert updated_user.smtp_port is None From fd59dc70c64ce1c55fd17871ca5fdfe437f56914 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Tue, 17 Sep 2024 20:09:31 +0000 Subject: [PATCH 15/25] fix lint errors --- dev_data.py | 4 ++-- hushline/model.py | 13 +++++++++++-- hushline/routes.py | 2 +- hushline/settings/__init__.py | 10 ++++++---- tests/auth_helper.py | 6 ++---- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/dev_data.py b/dev_data.py index b33233c5..0ff3448f 100755 --- a/dev_data.py +++ b/dev_data.py @@ -31,13 +31,13 @@ def main() -> None: un1 = Username( user_id=user.id, - _username=data["username"], + _username=data["username"], # type: ignore is_primary=True, show_in_directory=True, ) un2 = Username( user_id=user.id, - _username=data["username"] + "-alias", + _username=data["username"] + "-alias", # type: ignore is_primary=False, show_in_directory=True, ) diff --git a/hushline/model.py b/hushline/model.py index 096ce077..63d89a2d 100644 --- a/hushline/model.py +++ b/hushline/model.py @@ -68,6 +68,16 @@ class Username(Model): extra_field_verified3: Mapped[Optional[bool]] = mapped_column(default=False) extra_field_verified4: Mapped[Optional[bool]] = mapped_column(default=False) + def __init__( + self, + _username: str, + is_primary: bool, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._username = _username + self.is_primary = is_primary + @property def username(self) -> str: return self._username @@ -258,10 +268,9 @@ class Message(Model): username_id: Mapped[int] = mapped_column(db.ForeignKey("usernames.id")) username: Mapped["Username"] = relationship(uselist=False) - def __init__(self, **kwargs: Any) -> None: + def __init__(self, content: str, **kwargs: Any) -> None: if "_content" in kwargs: raise ValueError("Cannot set '_content' directly. Use 'content'") - content = kwargs.pop("content", None) super().__init__(**kwargs) self.content = content diff --git a/hushline/routes.py b/hushline/routes.py index fe760025..f2b50f01 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -364,7 +364,7 @@ def register() -> Response | str | tuple[Response | str, int]: db.session.add(user) db.session.flush() - username = Username(username=username, user_id=user.id, is_primary=True) + username = Username(_username=username, user_id=user.id, is_primary=True) db.session.add(username) db.session.commit() diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index 92eed2ae..5e383710 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -246,7 +246,7 @@ async def index() -> str | Response: ).all() # Additional admin-specific data initialization user_count = two_fa_count = pgp_key_count = two_fa_percentage = pgp_key_percentage = None - all_users = [] + all_users: list[User] = [] # Check if user is admin and add admin-specific data if user.is_admin: @@ -260,7 +260,6 @@ async def index() -> str | Response: .filter(User._pgp_key != "") ) ) - user_count = len(all_users) two_fa_percentage = (two_fa_count / user_count * 100) if user_count else 0 pgp_key_percentage = (pgp_key_count / user_count * 100) if user_count else 0 all_users = list( @@ -268,6 +267,7 @@ async def index() -> str | Response: db.select(User).join(Username).order_by(Username._username) ).all() ) + user_count = len(all_users) # Prepopulate form fields email_forwarding_form.forwarding_enabled.data = user.email is not None @@ -343,7 +343,9 @@ def change_password() -> str | Response: flash("New password is invalid.") return redirect(url_for("settings.index")) - if not user.check_password(change_password_form.old_password.data): + if not change_password_form.old_password.data or not user.check_password( + change_password_form.old_password.data + ): flash("Incorrect old password.", "error") return redirect(url_for("settings.index")) @@ -503,7 +505,7 @@ def update_pgp_key() -> Response | str: if form.validate_on_submit(): pgp_key = form.pgp_key.data - if pgp_key.strip() == "": + if pgp_key is None or pgp_key.strip() == "": # If the field is empty, remove the PGP key user.pgp_key = None user.email = None # remove the forwarding email if the PGP key is removed diff --git a/tests/auth_helper.py b/tests/auth_helper.py index 6f589f3b..1b5d47b5 100644 --- a/tests/auth_helper.py +++ b/tests/auth_helper.py @@ -91,10 +91,8 @@ def login_user(client: FlaskClient, username: str, password: str) -> User | None f'href="/inbox?username={username}"'.encode() in response.data ), f"Inbox link should be present for the user {username}" - if username := db.session.scalars( - db.select(Username).filter_by(_username=username) - ).one_or_none(): - return username.user + if uname := db.session.scalars(db.select(Username).filter_by(_username=username)).one_or_none(): + return uname.user return None From 345debb79479dcb2cf1311958aa4adb824596268 Mon Sep 17 00:00:00 2001 From: Glenn Date: Wed, 18 Sep 2024 11:54:51 -0700 Subject: [PATCH 16/25] Various updates - Add chevron icon for Alias list - Add chevron icon for Back button - Update Back button label - Update Alias list to show either the display name OR username - Update alias drill-in page heading to show either the display name OR username - Some spacing updates --- hushline/static/css/style.css | 37 ++++++++++++++++++++ hushline/static/img/app/icon-chevron-dm.png | Bin 0 -> 348 bytes hushline/static/img/app/icon-chevron.png | Bin 0 -> 346 bytes hushline/templates/settings/alias.html | 4 +-- hushline/templates/settings/aliases.html | 10 ++++-- 5 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 hushline/static/img/app/icon-chevron-dm.png create mode 100644 hushline/static/img/app/icon-chevron.png diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 62632cf6..8a548c59 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -93,6 +93,10 @@ background-image: url("../img/app/icon-menu.png"); } + .icon.chevron { + background-image: url("../img/app/icon-chevron.png"); + } + .container { border: var(--border); background-color: white; @@ -645,6 +649,10 @@ .icon.verifiedURL { background-image: url("../img/app/icon-verified-dm.png"); } + + .icon.chevron { + background-image: url("../img/app/icon-chevron-dm.png"); + } } body { @@ -2106,3 +2114,32 @@ p.bio + .extra-fields { z-index: q; content: ""; } + +#aliases { + form + h3 { + margin-top: 2rem; + } +} + +.alias-entry .icon, +.icon.back { + width: 1.25rem; + height: 1.25rem; + background-size: contain; + background-repeat: no-repeat; +} + +.icon.back { + transform: rotate(180deg); + display: inline-block; + position: relative; + top: .175rem; +} + +.back-button { + margin-bottom: 1.5rem; +} + +.alias-heading { + margin-bottom: 1rem; +} \ No newline at end of file diff --git a/hushline/static/img/app/icon-chevron-dm.png b/hushline/static/img/app/icon-chevron-dm.png new file mode 100644 index 0000000000000000000000000000000000000000..69450319a0efdb74c4c7313d441300a38356bda9 GIT binary patch literal 348 zcmeAS@N?(olHy`uVBq!ia0vp^azHG_!3HE3EPS&CNO2Z;L>4nJa0`PlBg3pY55jgR3=A9lx&I`x0{I_3T^vIy7~fv=30)f!i8nA z?i-{_nr)oSSNR2)e~_5Sw#z>=JIXKF##tj{UzNa@`#h(l>+KF6XZWK$(ME6D^OkCd znKoMCks(DJ)zmD4HP7{3x4iOo+h)tsm~u0TjUpnCSBUT*-z!_NFLS+RLwI=Tipx8L zj^}PVoXXpyYsDY*dKmdjTDO02K~dY-}4)z4*}Q$iB}rMiXp literal 0 HcmV?d00001 diff --git a/hushline/static/img/app/icon-chevron.png b/hushline/static/img/app/icon-chevron.png new file mode 100644 index 0000000000000000000000000000000000000000..8ae3737646baa6bea85c115b6ac7f37deb04fea0 GIT binary patch literal 346 zcmeAS@N?(olHy`uVBq!ia0vp^azHG_!3HE3EPS&CNO2Z;L>4nJa0`PlBg3pY55jgR3=A9lx&I`x0{QPfT^vIy7~ftw&DrcA!1mxS%X`aeBUznS z2N#!3;7j6ssiLZ~QsL!+)#3+*zNPRsbx3wLs&d?{l=-XAeE7g|oi`d=k17|+#Bb8+ z7E!itKgvbQ=1huUhUe(C3)qT@8vxUZ03Zy+?Cn-(R{;C z*;6+&7n-Xd6ta+f_DE+&^X5b?J*MCS!-|928aw&>_Qli}{mA`NyWdKK?fi0)uEMA? znd6Icqj#I>f4Xw|uU>7S;EbkCiCU+cEHxybEmDbKay^_m>)BjQ`=kgx@ pu)^;D)SgMq=VPUkmA0}@G(W32yQcQ8fELi}44$rjF6*2UngHgRge3p~ literal 0 HcmV?d00001 diff --git a/hushline/templates/settings/alias.html b/hushline/templates/settings/alias.html index f760132c..d68cb578 100644 --- a/hushline/templates/settings/alias.html +++ b/hushline/templates/settings/alias.html @@ -3,8 +3,8 @@ {% block title %}Alias: @{{ alias.username }}{% endblock %} {% block content %} -

< Back to settings

-

Alias: @{{ alias.username }}

+ Back to Settings +

{{ alias.display_name or alias.username }}

{# TODO: much of this is copy/pasted from "settings/profile.html" and will need to be synced with updates there #}

Update Display Name

diff --git a/hushline/templates/settings/aliases.html b/hushline/templates/settings/aliases.html index b8683ced..874afa54 100644 --- a/hushline/templates/settings/aliases.html +++ b/hushline/templates/settings/aliases.html @@ -22,12 +22,16 @@

Current Aliases

{% else %} -

No alises configured.

+

🙊 No alises configured.

{% endif %}
From e8a7264e35d562c6558938a576d4f9c20fe22eed Mon Sep 17 00:00:00 2001 From: Glenn Date: Wed, 18 Sep 2024 11:57:35 -0700 Subject: [PATCH 17/25] Update style.css --- hushline/static/css/style.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 8a548c59..f7d2de42 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2129,11 +2129,11 @@ p.bio + .extra-fields { background-repeat: no-repeat; } -.icon.back { +.icon.back { transform: rotate(180deg); display: inline-block; position: relative; - top: .175rem; + top: 0.175rem; } .back-button { @@ -2142,4 +2142,4 @@ p.bio + .extra-fields { .alias-heading { margin-bottom: 1rem; -} \ No newline at end of file +} From 67771eb715a88b86e44ee1caf3eef860f18c8f17 Mon Sep 17 00:00:00 2001 From: Glenn Date: Wed, 18 Sep 2024 12:00:38 -0700 Subject: [PATCH 18/25] Update style.css --- hushline/static/css/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index f7d2de42..479421a4 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2138,6 +2138,7 @@ p.bio + .extra-fields { .back-button { margin-bottom: 1.5rem; + margin-top: -0.75rem; } .alias-heading { From c2b3cb00b1bc15ef656b4778186d4db587b61f60 Mon Sep 17 00:00:00 2001 From: Glenn Date: Wed, 18 Sep 2024 12:03:19 -0700 Subject: [PATCH 19/25] Update style.css --- hushline/static/css/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 479421a4..0a69e30c 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2095,6 +2095,7 @@ p.bio + .extra-fields { .alias-list { display: flex; flex-direction: column; + gap: 1rem; } .alias-entry { From b8b1047b25a760ec8992db072d05be733f9cd28e Mon Sep 17 00:00:00 2001 From: Glenn Date: Wed, 18 Sep 2024 12:04:48 -0700 Subject: [PATCH 20/25] Update style.css --- hushline/static/css/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 0a69e30c..5be97841 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2096,6 +2096,7 @@ p.bio + .extra-fields { display: flex; flex-direction: column; gap: 1rem; + margin-top: 1rem; } .alias-entry { From 3d9fa2c7b72ec3f50265446eeb2a2a800f8a2f53 Mon Sep 17 00:00:00 2001 From: Glenn Date: Wed, 18 Sep 2024 12:11:54 -0700 Subject: [PATCH 21/25] fix headings --- hushline/static/css/style.css | 21 ++++++++++++++++++--- hushline/templates/settings/alias.html | 2 ++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 5be97841..9cc8f9ad 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2117,10 +2117,12 @@ p.bio + .extra-fields { content: ""; } -#aliases { - form + h3 { +#aliases form + h3 { margin-top: 2rem; - } +} + +#aliases h3 { + margin-bottom: 0; } .alias-entry .icon, @@ -2146,3 +2148,16 @@ p.bio + .extra-fields { .alias-heading { margin-bottom: 1rem; } + +.drill-in { + display: flex; + flex-direction: column; +} + +.drill-in h3 { + margin-bottom: 0; +} + +.drill-in .checkbox-group { + margin-bottom: 0; +} \ No newline at end of file diff --git a/hushline/templates/settings/alias.html b/hushline/templates/settings/alias.html index d68cb578..1e3425a2 100644 --- a/hushline/templates/settings/alias.html +++ b/hushline/templates/settings/alias.html @@ -3,6 +3,7 @@ {% block title %}Alias: @{{ alias.username }}{% endblock %} {% block content %} +
Back to Settings

{{ alias.display_name or alias.username }}

@@ -174,6 +175,7 @@

Extra Fields

+ {% endblock %} {% block scripts %} From 156aa7573582f02f1622f6259951c2dccfc4941a Mon Sep 17 00:00:00 2001 From: Glenn Date: Wed, 18 Sep 2024 12:12:27 -0700 Subject: [PATCH 22/25] Update style.css --- hushline/static/css/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hushline/static/css/style.css b/hushline/static/css/style.css index 9cc8f9ad..d5f5cbc5 100644 --- a/hushline/static/css/style.css +++ b/hushline/static/css/style.css @@ -2118,7 +2118,7 @@ p.bio + .extra-fields { } #aliases form + h3 { - margin-top: 2rem; + margin-top: 2rem; } #aliases h3 { @@ -2160,4 +2160,4 @@ p.bio + .extra-fields { .drill-in .checkbox-group { margin-bottom: 0; -} \ No newline at end of file +} From ca80798af929258e95105f96250854d2fdd240c6 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Thu, 19 Sep 2024 08:04:46 +0000 Subject: [PATCH 23/25] minor pr fixes --- hushline/routes.py | 33 +++++++++++++++++---------------- hushline/settings/__init__.py | 2 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/hushline/routes.py b/hushline/routes.py index f2b50f01..412a5d1d 100644 --- a/hushline/routes.py +++ b/hushline/routes.py @@ -4,6 +4,7 @@ import secrets import socket from datetime import UTC, datetime, timedelta +from typing import Sequence import pyotp from flask import ( @@ -284,9 +285,8 @@ def delete_message(message_id: int) -> Response: flash("🫥 User not found. Please log in again.") return redirect(url_for("login")) - row_count = ( - db.delete(Message) - .where( + row_count = db.session.execute( + db.delete(Message).where( Message.id == message_id, Message.username_id.in_( select(Username.user_id) @@ -294,15 +294,14 @@ def delete_message(message_id: int) -> Response: .filter(Username.user_id == user.id) ), ) - .delete() - ) + ).rowcount match row_count: case 1: db.session.commit() flash("🗑️ Message deleted successfully.") case 0: db.session.rollback() - flash("⛔️ Message not found or unauthorized access.") + flash("⛔️ Message not found.") case _: db.session.rollback() current_app.logger.error( @@ -349,7 +348,9 @@ def register() -> Response | str | tuple[Response | str, int]: 400, ) - if db.session.query(db.exists(Username).where(Username._username == username)).scalar(): + if db.session.scalar( + db.exists(Username).where(Username._username == username).select() + ): flash("💔 Username already taken.", "error") return ( render_template( @@ -386,8 +387,8 @@ def login() -> Response | str: form = LoginForm() if form.validate_on_submit(): - username = Username.query.filter_by( - _username=form.username.data.strip(), is_primary=True + username = db.session.scalars( + select(Username).filter_by(_username=form.username.data.strip(), is_primary=True) ).one_or_none() if username and username.user.check_password(form.password.data): session.permanent = True @@ -436,12 +437,12 @@ def verify_2fa_login() -> Response | str | tuple[Response | str, int]: rate_limit = False # If the most recent successful login was made with the same OTP code, reject this one - last_login = ( - AuthenticationLog.query.filter_by(user_id=user.id, successful=True) + last_login = db.session.scalars( + db.select(AuthenticationLog) + .filter_by(user_id=user.id, successful=True) .order_by(AuthenticationLog.timestamp.desc()) .limit(1) - .first() - ) + ).first() if ( last_login and last_login.timecode == timecode @@ -496,14 +497,14 @@ def logout() -> Response: flash("👋 You have been logged out successfully.", "info") return redirect(url_for("index")) - def get_directory_usernames(admin_first: bool = False) -> list[Username]: - query = Username.query.filter_by(show_in_directory=True) + def get_directory_usernames(admin_first: bool = False) -> Sequence[Username]: + query = select(Username).filter_by(show_in_directory=True) display_ordering = db.func.coalesce(Username._display_name, Username._username) if admin_first: query = query.order_by(Username.user.is_admin.desc(), display_ordering) else: query = query.order_by(display_ordering) - return query.all() + return db.session.scalars(query).all() @app.route("/directory") def directory() -> Response | str: diff --git a/hushline/settings/__init__.py b/hushline/settings/__init__.py index 5e383710..832f4469 100644 --- a/hushline/settings/__init__.py +++ b/hushline/settings/__init__.py @@ -153,7 +153,7 @@ def handle_change_username_form( # TODO a better pattern would be to try to commit, catch the exception, and match # on the name of the unique index that errored - if db.session.query(db.exists(Username).where(Username._username == new_username)).scalar(): + if db.session.scalar(db.exists(Username).where(Username._username == new_username).select()): flash("💔 This username is already taken.") else: username.username = new_username From e9c0fc3af8750220b42f846cde3d9ec0ee4589e0 Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Thu, 19 Sep 2024 08:16:37 +0000 Subject: [PATCH 24/25] make targets for dev/prod migrations --- Makefile | 18 +++++++++++------- docs/DEV.md | 5 +++-- dev_data.py => scripts/dev_data.py | 0 scripts/dev_migrations.py | 12 ++++++++++++ 4 files changed, 26 insertions(+), 9 deletions(-) rename dev_data.py => scripts/dev_data.py (100%) create mode 100755 scripts/dev_migrations.py diff --git a/Makefile b/Makefile index b3816395..353304ef 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,18 @@ install: .PHONY: run run: ## Run the app . ./dev_env.sh && \ - poetry run python -c 'from hushline import create_app; from hushline.db import db; from sqlalchemy import text; create_app().app_context().push(); db.session.execute(text("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")); db.session.commit(); db.create_all()' && \ poetry run flask run --debug -h localhost -p 8080 +.PHONY: migrate-dev +migrate-dev: ## Run dev env migrations + . ./dev_env.sh && \ + poetry run ./scripts/dev_migrations.py + +.PHONY: migrate-prod +migrate-prod: ## Run prod env (alembic) migrations + . ./dev_env.sh && \ + poetry run flask db upgrade + .PHONY: lint lint: ## Lint the code poetry run ruff format --check && \ @@ -31,13 +40,8 @@ fix: ## Format the code poetry run ruff check --fix npx prettier --write . -.PHONY: migrate -migrate: ## Apply migrations - . ./dev_env.sh && \ - poetry run flask db upgrade - .PHONY: revision -revision: ## Create a new migration +revision: migrate-prod ## Create a new migration ifndef message $(error 'message' must be set when invoking the revision target, eg `make revision message="short message"`) endif diff --git a/docs/DEV.md b/docs/DEV.md index 3c5e2468..d0a28f41 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -41,10 +41,11 @@ Install Poetry dependencies: make install ``` -Run the database migrations: +Run one of the database migrations: ```sh -make migrate +make migrate-dev # for current dev DB settings +make migrate-prod # for current alembic migrations ``` Run the app in debug mode: diff --git a/dev_data.py b/scripts/dev_data.py similarity index 100% rename from dev_data.py rename to scripts/dev_data.py diff --git a/scripts/dev_migrations.py b/scripts/dev_migrations.py new file mode 100755 index 00000000..01f069a5 --- /dev/null +++ b/scripts/dev_migrations.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +from hushline import create_app +from hushline.db import db + + +def main() -> None: + with create_app().app_context(): + db.create_all() + + +if __name__ == "__main__": + main() From a6d4e96349b7fbaf09ca854277aa494ff5463dfb Mon Sep 17 00:00:00 2001 From: brassy endomorph Date: Thu, 19 Sep 2024 10:14:42 +0000 Subject: [PATCH 25/25] aliases migration --- hushline/model.py | 2 +- migrations/script.py.mako | 4 +- .../46aedec8fd9b_create_alias_tables.py | 268 ++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 migrations/versions/46aedec8fd9b_create_alias_tables.py diff --git a/hushline/model.py b/hushline/model.py index 63d89a2d..a67b4c90 100644 --- a/hushline/model.py +++ b/hushline/model.py @@ -49,7 +49,7 @@ class Username(Model): user_id: Mapped[int] = mapped_column(db.ForeignKey("users.id")) user: Mapped["User"] = relationship() _username: Mapped[str] = mapped_column("username", unique=True) - _display_name: Mapped[Optional[str]] = mapped_column(db.String(80)) + _display_name: Mapped[Optional[str]] = mapped_column("display_name", db.String(80)) is_primary: Mapped[bool] = mapped_column() is_verified: Mapped[bool] = mapped_column(default=False) show_in_directory: Mapped[bool] = mapped_column(default=False) diff --git a/migrations/script.py.mako b/migrations/script.py.mako index 2c015630..55df2863 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -16,9 +16,9 @@ branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} -def upgrade(): +def upgrade() -> None: ${upgrades if upgrades else "pass"} -def downgrade(): +def downgrade() -> None: ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/46aedec8fd9b_create_alias_tables.py b/migrations/versions/46aedec8fd9b_create_alias_tables.py new file mode 100644 index 00000000..c4d76cb4 --- /dev/null +++ b/migrations/versions/46aedec8fd9b_create_alias_tables.py @@ -0,0 +1,268 @@ +"""create alias tables + +Revision ID: 46aedec8fd9b +Revises: c2b6eff6e213 +Create Date: 2024-09-19 10:15:41.889874 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "46aedec8fd9b" +down_revision = "c2b6eff6e213" +branch_labels = None +depends_on = None + +user_common_fields = ["display_name", "is_verified", "show_in_directory", "bio"] +for i in range(1, 5): + user_common_fields.extend( + [ + f"extra_field_label{i}", + f"extra_field_value{i}", + f"extra_field_verified{i}", + ] + ) + +user_common_fields_str = ", ".join(user_common_fields) + + +def upgrade() -> None: + op.create_table( + "usernames", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("display_name", sa.String(length=80), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.Column("show_in_directory", sa.Boolean(), nullable=False), + sa.Column("bio", sa.Text(), nullable=True), + sa.Column("extra_field_label1", sa.String(), nullable=True), + sa.Column("extra_field_value1", sa.String(), nullable=True), + sa.Column("extra_field_label2", sa.String(), nullable=True), + sa.Column("extra_field_value2", sa.String(), nullable=True), + sa.Column("extra_field_label3", sa.String(), nullable=True), + sa.Column("extra_field_value3", sa.String(), nullable=True), + sa.Column("extra_field_label4", sa.String(), nullable=True), + sa.Column("extra_field_value4", sa.String(), nullable=True), + sa.Column("extra_field_verified1", sa.Boolean(), nullable=True), + sa.Column("extra_field_verified2", sa.Boolean(), nullable=True), + sa.Column("extra_field_verified3", sa.Boolean(), nullable=True), + sa.Column("extra_field_verified4", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("username"), + ) + + op.execute( + sa.text( + f""" + INSERT INTO usernames (user_id, is_primary, username, {user_common_fields_str}) + SELECT id, true AS is_primary, primary_username, {user_common_fields_str} + FROM users + """ + ) + ) + + op.execute( + sa.text( + """ + INSERT INTO usernames ( + user_id, is_primary, username, display_name, is_verified, show_in_directory) + SELECT + user_id, false AS is_primary, username, display_name, false AS is_verified, + false AS show_in_directory + FROM secondary_usernames + """ + ) + ) + + with op.batch_alter_table("message", schema=None) as batch_op: + batch_op.add_column(sa.Column("username_id", sa.Integer(), nullable=True)) + + op.execute( + sa.text( + """ + UPDATE message + SET username_id = q.username_id + FROM ( + SELECT id AS username_id, user_id + FROM usernames + ) AS q + WHERE message.user_id = q.user_id + """ + ) + ) + + with op.batch_alter_table("message", schema=None) as batch_op: + batch_op.alter_column("username_id", existing_type=sa.Integer(), nullable=False) + batch_op.drop_constraint("message_secondary_user_id_fkey", type_="foreignkey") + batch_op.drop_constraint("message_user_id_fkey", type_="foreignkey") + batch_op.create_foreign_key(None, "usernames", ["username_id"], ["id"]) + batch_op.drop_column("user_id") + batch_op.drop_column("secondary_user_id") + + op.drop_table("secondary_usernames") + + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_constraint("users_primary_username_key", type_="unique") + batch_op.drop_column("primary_username") + batch_op.drop_column("display_name") + batch_op.drop_column("bio") + batch_op.drop_column("is_verified") + batch_op.drop_column("show_in_directory") + + for i in range(1, 5): + batch_op.drop_column(f"extra_field_value{i}") + batch_op.drop_column(f"extra_field_label{i}") + batch_op.drop_column(f"extra_field_verified{i}") + + +def downgrade() -> None: + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column("display_name", sa.VARCHAR(length=80), autoincrement=False, nullable=True) + ) + batch_op.add_column( + sa.Column( + "primary_username", + sa.VARCHAR(length=80), + autoincrement=False, + nullable=True, + ) + ) + batch_op.add_column( + sa.Column("is_verified", sa.BOOLEAN(), autoincrement=False, nullable=True) + ) + batch_op.add_column(sa.Column("bio", sa.TEXT(), autoincrement=False, nullable=True)) + batch_op.add_column( + sa.Column("show_in_directory", sa.BOOLEAN(), autoincrement=False, nullable=True) + ) + + for i in range(1, 5): + batch_op.add_column( + sa.Column(f"extra_field_value{i}", sa.VARCHAR(), autoincrement=False, nullable=True) + ) + batch_op.add_column( + sa.Column(f"extra_field_label{i}", sa.VARCHAR(), autoincrement=False, nullable=True) + ) + batch_op.add_column( + sa.Column( + f"extra_field_verified{i}", sa.BOOLEAN(), autoincrement=False, nullable=True + ) + ) + + batch_op.create_unique_constraint("users_primary_username_key", ["primary_username"]) + + users_insert_str = "" + for field in user_common_fields: + users_insert_str += field + "=q." + field + ",\n" + users_insert_str = users_insert_str[0:-2] # trim last comma + + op.execute( + sa.text( + f""" + UPDATE users + SET primary_username=q.username, + {users_insert_str} + FROM ( + SELECT user_id, username, {user_common_fields_str} + FROM usernames + WHERE is_primary = true + ) AS q + WHERE users.id = q.user_id + """ + ) + ) + + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.alter_column( + "is_verified", existing_type=sa.BOOLEAN(), autoincrement=False, nullable=False + ) + batch_op.alter_column( + "primary_username", + existing_type=sa.VARCHAR(length=80), + autoincrement=False, + nullable=False, + ) + batch_op.alter_column( + "show_in_directory", existing_type=sa.BOOLEAN(), autoincrement=False, nullable=False + ) + + with op.batch_alter_table("message", schema=None) as batch_op: + batch_op.add_column(sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=True)) + batch_op.add_column( + sa.Column("secondary_user_id", sa.INTEGER(), autoincrement=False, nullable=True) + ) + + op.execute( + sa.text( + """ + UPDATE message + SET user_id = q.user_id + FROM ( + SELECT id AS username_id, user_id + FROM usernames + ) AS q + WHERE message.username_id = q.username_id + """ + ) + ) + + op.execute( + sa.text( + """ + UPDATE message + SET secondary_user_id = q.user_id + FROM ( + SELECT id AS username_id, user_id + FROM usernames + WHERE is_primary = false + ) AS q + WHERE message.username_id = q.username_id + """ + ) + ) + + with op.batch_alter_table("message", schema=None) as batch_op: + batch_op.alter_column( + "user_id", existing_type=sa.INTEGER(), autoincrement=False, nullable=False + ) + + op.create_table( + "secondary_usernames", + sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("username", sa.VARCHAR(length=80), autoincrement=False, nullable=False), + sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("display_name", sa.VARCHAR(length=80), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="secondary_usernames_user_id_fkey"), + sa.PrimaryKeyConstraint("id", name="secondary_usernames_pkey"), + sa.UniqueConstraint("username", name="secondary_usernames_username_key"), + ) + + op.execute( + sa.text( + """ + INSERT INTO secondary_usernames (user_id, username, display_name) + SELECT user_id, username, display_name + FROM usernames + WHERE is_primary = false + """ + ) + ) + + with op.batch_alter_table("message", schema=None) as batch_op: + batch_op.drop_constraint("message_username_id_fkey", type_="foreignkey") + batch_op.create_foreign_key("message_user_id_fkey", "users", ["user_id"], ["id"]) + batch_op.create_foreign_key( + "message_secondary_user_id_fkey", "secondary_usernames", ["secondary_user_id"], ["id"] + ) + batch_op.drop_column("username_id") + + op.drop_table("usernames") diff --git a/pyproject.toml b/pyproject.toml index 54263d07..9cb6d7cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ ignore = [ "migrations/versions/*.py" = [ # https://docs.astral.sh/ruff/rules/unsorted-imports/ "I001", + "S608", # sql injection via string-based query construction ] [tool.mypy]