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

Added TOTP-like alias generation and included some extra information on CONTRIBUTING.md #996

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ Install npm packages
cd static && npm install
```

To run the code locally, please create a local setting file based on `example.env`:
To run the code locally, go back to the root folder, and please create a local setting file based on `example.env`:

```
cd ..
cp example.env .env
```

Expand Down
14 changes: 8 additions & 6 deletions app/api/views/new_random_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,14 @@ def new_random_alias():
scheme = user.alias_generator
mode = request.args.get("mode")
if mode:
if mode == "word":
scheme = AliasGeneratorEnum.word.value
elif mode == "uuid":
scheme = AliasGeneratorEnum.uuid.value
else:
return jsonify(error=f"{mode} must be either word or uuid"), 400
try:
scheme = AliasGeneratorEnum[mode].value
except KeyError:
modes = ", ".join(AliasGeneratorEnum.get_names())
return (
jsonify(error=f"{mode} must be one of {modes}"),
400,
)

alias = Alias.create_new_random(user=user, scheme=scheme, note=note)
Session.commit()
Expand Down
18 changes: 8 additions & 10 deletions app/api/views/setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
def setting_to_dict(user: User):
ret = {
"notification": user.notification,
"alias_generator": "word"
if user.alias_generator == AliasGeneratorEnum.word.value
else "uuid",
# return the default alias generator in case user uses a non-supported alias generator
"alias_generator": user.alias_generator
if AliasGeneratorEnum.has_name(user.alias_generator)
else AliasGeneratorEnum.get_name(AliasGeneratorEnum.word.value),
"random_alias_default_domain": user.default_random_alias_domain(),
# return the default sender format (AT) in case user uses a non-supported sender format
"sender_format": SenderFormatEnum.get_name(user.sender_format)
Expand Down Expand Up @@ -48,7 +49,7 @@ def update_setting():
Update user setting
Input:
- notification: bool
- alias_generator: word|uuid
- alias_generator: word|uuid|random_string
- random_alias_default_domain: str
"""
user = g.user
Expand All @@ -59,14 +60,11 @@ def update_setting():

if "alias_generator" in data:
alias_generator = data["alias_generator"]
if alias_generator not in ["word", "uuid"]:
try:
user.alias_generator = AliasGeneratorEnum[alias_generator].value
except KeyError:
return jsonify(error="Invalid alias_generator"), 400

if alias_generator == "word":
user.alias_generator = AliasGeneratorEnum.word.value
else:
user.alias_generator = AliasGeneratorEnum.uuid.value

if "sender_format" in data:
sender_format = data["sender_format"]
if not SenderFormatEnum.has_name(sender_format):
Expand Down
45 changes: 26 additions & 19 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import enum
import os
import random
import string
import uuid
from email.utils import formataddr
from typing import List, Tuple, Optional
from typing import List, Set, Tuple, Optional

import arrow
import sqlalchemy as sa
Expand Down Expand Up @@ -170,33 +171,35 @@ def __repr__(self):


class EnumE(enum.Enum):
@classmethod
def has_value(cls, value: int) -> bool:
return value in set(item.value for item in cls)

@classmethod
def get_name(cls, value: int) -> Optional[str]:
for item in cls:
if item.value == value:
return item.name
try:
return cls(value).name
except ValueError:
return None

return None
@classmethod
def get_names(cls) -> List[str]:
return [item.name for item in cls]

@classmethod
def has_name(cls, name: str) -> bool:
for item in cls:
if item.name == name:
return True

return False
return not cls.get_value(name) is None

@classmethod
def get_value(cls, name: str) -> Optional[int]:
for item in cls:
if item.name == name:
return item.value
try:
return cls[name].value
except KeyError:
return None

return None
@classmethod
def get_values(cls) -> List[int]:
return [item.value for item in cls]

@classmethod
def has_value(cls, value: int) -> bool:
return not cls.get_name(value) is None


class PlanEnum(EnumE):
Expand All @@ -216,6 +219,7 @@ class SenderFormatEnum(EnumE):
class AliasGeneratorEnum(EnumE):
word = 1 # aliases are generated based on random words
uuid = 2 # aliases are generated based on uuid
random_string = 3 # aliases are generated based on a completely random string


class AliasSuffixEnum(EnumE):
Expand Down Expand Up @@ -1188,7 +1192,10 @@ def generate_email(
if scheme == AliasGeneratorEnum.uuid.value:
name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
random_email = name + "@" + alias_domain
else:
elif scheme == AliasGeneratorEnum.random_string.value:
name = "".join(random.choices(string.digits + string.ascii_lowercase, k=9))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could reuse exisiting util function here

def random_string(length=10, include_digits=False):

random_email = name + "@" + alias_domain
else: # use word.value as the default just like the original code
random_email = random_words() + "@" + alias_domain

random_email = random_email.lower().strip()
Expand Down
5 changes: 4 additions & 1 deletion templates/dashboard/setting.html
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@
<option value="{{ AliasGeneratorEnum.uuid.value }}"
{% if current_user.alias_generator == AliasGeneratorEnum.uuid.value %} selected {% endif %} >Based
on {{ AliasGeneratorEnum.uuid.name.upper() }}</option>
<option value="{{ AliasGeneratorEnum.random_string.value }}"
{% if current_user.alias_generator == AliasGeneratorEnum.random_string.value %} selected {% endif %} >Based
on Random combination of 9 letters and digits</option>
</select>
<button class="btn btn-outline-primary">Update</button>
</form>
Expand Down Expand Up @@ -297,7 +300,7 @@
Random word from our dictionary
</option>
<option value="1" {% if current_user.random_alias_suffix==1 %} selected {% endif %}>
Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letter and digits
Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letters and digits
</option>
</select>
<button class="btn btn-outline-primary">Update</button>
Expand Down
26 changes: 26 additions & 0 deletions tests/api/test_new_random_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ def test_custom_mode(flask_client):
assert ge.note == "test note"


def test_random_string_mode(flask_client):
login(flask_client)

# without note
r = flask_client.post(
url_for("api.new_random_alias", mode="random_string"),
)

assert r.status_code == 201
# extract the uuid part
alias = r.json["alias"]
random_string_part: str = alias[: len(alias) - len(EMAIL_DOMAIN) - 1]
assert random_string_part.isalnum()

# with note
r = flask_client.post(
url_for("api.new_random_alias", mode="random_string"),
json={"note": "test note"},
)

assert r.status_code == 201
alias = r.json["alias"]
ge = Alias.get_by(email=alias)
assert ge.note == "test note"


def test_out_of_quota(flask_client):
user = login(flask_client)
user.trial_end = None
Expand Down
10 changes: 9 additions & 1 deletion tests/api/test_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ def test_get_setting(flask_client):

r = flask_client.get("/api/setting")
assert r.status_code == 200
assert r.json == {
e_json = {
"alias_generator": "word",
"notification": True,
"random_alias_default_domain": "sl.local",
"sender_format": "AT",
"random_alias_suffix": "random_string",
}
for key in e_json:
# Assert on a per-key basis for better error reporting
assert r.json[key] == e_json[key]
assert len(r.json) == len(e_json)


def test_update_settings_notification(flask_client):
Expand All @@ -41,6 +45,10 @@ def test_update_settings_alias_generator(flask_client):
assert r.status_code == 200
assert user.alias_generator == AliasGeneratorEnum.uuid.value

r = flask_client.patch("/api/setting", json={"alias_generator": "random_string"})
assert r.status_code == 200
assert user.alias_generator == AliasGeneratorEnum.random_string.value


def test_update_settings_random_alias_default_domain(flask_client):
user = login(flask_client)
Expand Down
9 changes: 9 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from app.db import Session
from app.email_utils import parse_full_address
from app.models import (
AliasGeneratorEnum,
generate_email,
Alias,
Contact,
Expand All @@ -32,6 +33,14 @@ def test_generate_email(flask_client):
assert UUID(email_uuid.split("@")[0], version=4)


def test_generate_email_with_random_string(flask_client):
email = generate_email(scheme=AliasGeneratorEnum.random_string.value)
assert email.endswith("@" + EMAIL_DOMAIN)

email_random_string = generate_email(scheme=3)
assert (email_random_string.split("@")[0]).isalnum()


def test_profile_picture_url(flask_client):
user = create_new_user()

Expand Down