Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Username aliases #588

Merged
merged 26 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3ab8298
use db.create_all() in dev env instead of alembic migrations
brassy-endomorph Sep 11, 2024
dae86de
added basic dev_data.py script to populate the db with test data
brassy-endomorph Sep 11, 2024
c72ffa8
skip reformatting jinja templates
brassy-endomorph Sep 17, 2024
e8fd5c3
split settings into routes/form modules
brassy-endomorph Sep 12, 2024
ce3fbb1
remove secondary users in preparation for refactor
brassy-endomorph Sep 13, 2024
3f09a70
refactored current code to support multiple usernames
brassy-endomorph Sep 13, 2024
2d00273
minor routes cleanup
brassy-endomorph Sep 13, 2024
b4b688c
factor settings form handlers into their own functions
brassy-endomorph Sep 14, 2024
0d63b6e
basic support for aliases
brassy-endomorph Sep 14, 2024
4257927
fix minor alias errors
brassy-endomorph Sep 17, 2024
7112e53
wip
brassy-endomorph Sep 17, 2024
a73e818
better dev_data.py script
brassy-endomorph Sep 17, 2024
92bf51b
minor routes improvements
brassy-endomorph Sep 17, 2024
bb524a8
use session interface for sqlalchemy
brassy-endomorph Sep 17, 2024
fd59dc7
fix lint errors
brassy-endomorph Sep 17, 2024
345debb
Various updates
glenn-sorrentino Sep 18, 2024
e8a7264
Update style.css
glenn-sorrentino Sep 18, 2024
67771eb
Update style.css
glenn-sorrentino Sep 18, 2024
c2b3cb0
Update style.css
glenn-sorrentino Sep 18, 2024
b8b1047
Update style.css
glenn-sorrentino Sep 18, 2024
3d9fa2c
fix headings
glenn-sorrentino Sep 18, 2024
156aa75
Update style.css
glenn-sorrentino Sep 18, 2024
2475da3
Merge pull request #594 from scidsg/glenn-alias-updates
brassy-endomorph Sep 19, 2024
ca80798
minor pr fixes
brassy-endomorph Sep 19, 2024
e9c0fc3
make targets for dev/prod migrations
brassy-endomorph Sep 19, 2024
a6d4e96
aliases migration
brassy-endomorph Sep 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
build
coverage
hushline/static/vendor/*
hushline/templates/*
.pytest_cache
11 changes: 0 additions & 11 deletions .prettierrc

This file was deleted.

19 changes: 12 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@ install:


.PHONY: run
run: migrate ## Run the app
run: ## Run the app
. ./dev_env.sh && \
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 && \
Expand All @@ -30,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
Expand Down
6 changes: 3 additions & 3 deletions docs/3-managed-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ The account's primary inbox will aggregate and label messages in a single view.

<img src="img/paid.primary.inbox.png">

#### 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.

<img src="img/paid.secondary.inbox.png">
<img src="img/paid.alias.inbox.png">
5 changes: 3 additions & 2 deletions docs/DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
File renamed without changes
2 changes: 1 addition & 1 deletion hushline/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
30 changes: 9 additions & 21 deletions hushline/make_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,25 @@

from hushline import create_app
from hushline.db import db
from hushline.model import SecondaryUsername, User
from hushline.models import Username


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
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

# Toggle admin status
user.is_admin = not user.is_admin
uname = db.session.scalars(db.select(Username).filter_by(_username=username)).one_or_none()
if not uname:
print("User not found.")
return

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__":
if len(sys.argv) != 2: # noqa: PLR2004
print("Usage: python make_admin.py <username>")
sys.exit(1)

username = sys.argv[1]

with create_app().app_context():
toggle_admin(username)
toggle_admin(sys.argv[1])
191 changes: 114 additions & 77 deletions hushline/model.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,6 +19,7 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship


@enum.unique
class SMTPEncryption(enum.Enum):
SSL = "SSL"
StartTLS = "StartTLS"
Expand All @@ -28,32 +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("display_name", 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)
# 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
)
smtp_sender: Mapped[Optional[str]]

extra_field_label1: Mapped[Optional[str]]
extra_field_value1: Mapped[Optional[str]]
extra_field_label2: Mapped[Optional[str]]
Expand All @@ -67,6 +68,84 @@ class User(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

@username.setter
def username(self, username: str) -> None:
self._username = username
self.is_verified = False
db.session.commit()
brassy-endomorph marked this conversation as resolved.
Show resolved Hide resolved

@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()
brassy-endomorph marked this conversation as resolved.
Show resolved Hide resolved

@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",
)
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))
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."""
Expand Down Expand Up @@ -135,40 +214,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):
Expand Down Expand Up @@ -210,32 +262,17 @@ 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"
)
username_id: Mapped[int] = mapped_column(db.ForeignKey("usernames.id"))
username: Mapped["Username"] = relationship(uselist=False)
brassy-endomorph marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, content: str, user_id: int) -> None:
super().__init__()
def __init__(self, content: str, **kwargs: Any) -> None:
jeremywmoore marked this conversation as resolved.
Show resolved Hide resolved
if "_content" in kwargs:
raise ValueError("Cannot set '_content' directly. Use 'content'")
super().__init__(**kwargs)
self.content = content
self.user_id = user_id

@property
def content(self) -> str | None:
Expand Down
Loading
Loading