Compare commits

...

4 Commits

Author SHA1 Message Date
6c910d62c5 4.38.2 2024-02-06 12:00:07 +00:00
99ffd1ec0c 4.38.0 2024-02-03 16:55:23 +00:00
eda940f8b2 4.37.2 2024-01-27 12:00:07 +00:00
1dad582523 4.37.1 2024-01-25 12:00:08 +00:00
12 changed files with 249 additions and 83 deletions

View File

@ -510,7 +510,8 @@ server {
server_name app.mydomain.com;
location / {
proxy_pass http://localhost:7777;
proxy_pass http://localhost:7777;
proxy_set_header Host $host;
}
}
```

View File

@ -214,6 +214,20 @@ class UserAdmin(SLModelView):
Session.commit()
@action(
"remove trial",
"Stop trial period",
"Remove trial for this user?",
)
def stop_trial(self, ids):
for user in User.filter(User.id.in_(ids)):
user.trial_end = None
flash(f"Stopped trial for {user}", "success")
AdminAuditLog.stop_trial(current_user.id, user.id)
Session.commit()
@action(
"disable_otp_fido",
"Disable OTP & FIDO",

View File

@ -17,9 +17,14 @@ from app.models import PlanEnum, AppleSubscription
_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly"
_YEARLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.yearly"
# SL Mac app used to be in SL account
_MACAPP_MONTHLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.monthly"
_MACAPP_YEARLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.yearly"
# SL Mac app is moved to Proton account
_MACAPP_MONTHLY_PRODUCT_ID_NEW = "me.proton.simplelogin.macos.premium.monthly"
_MACAPP_YEARLY_PRODUCT_ID_NEW = "me.proton.simplelogin.macos.premium.yearly"
# Apple API URL
_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"
_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
@ -263,7 +268,11 @@ def apple_update_notification():
plan = (
PlanEnum.monthly
if transaction["product_id"]
in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
in (
_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID_NEW,
)
else PlanEnum.yearly
)
@ -517,7 +526,11 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
plan = (
PlanEnum.monthly
if latest_transaction["product_id"]
in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
in (
_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID_NEW,
)
else PlanEnum.yearly
)

View File

@ -492,6 +492,31 @@ NAMESERVERS = setup_nameservers()
DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = os.environ.get(
"DISABLE_CREATE_CONTACTS_FOR_FREE_USERS", False
)
# Expect format hits,seconds:hits,seconds...
# Example 1,10:4,60 means 1 in the last 10 secs or 4 in the last 60 secs
def getRateLimitFromConfig(
env_var: string, default: string = ""
) -> list[tuple[int, int]]:
value = os.environ.get(env_var, default)
if not value:
return []
entries = [entry for entry in value.split(":")]
limits = []
for entry in entries:
fields = entry.split(",")
limit = (int(fields[0]), int(fields[1]))
limits.append(limit)
return limits
ALIAS_CREATE_RATE_LIMIT_FREE = getRateLimitFromConfig(
"ALIAS_CREATE_RATE_LIMIT_FREE", "10,900:50,3600"
)
ALIAS_CREATE_RATE_LIMIT_PAID = getRateLimitFromConfig(
"ALIAS_CREATE_RATE_LIMIT_PAID", "50,900:200,3600"
)
PARTNER_API_TOKEN_SECRET = os.environ.get("PARTNER_API_TOKEN_SECRET") or (
FLASK_SECRET + "partnerapitoken"
)

View File

@ -583,6 +583,26 @@ def email_can_be_used_as_mailbox(email_address: str) -> bool:
LOG.d("MX Domain %s %s is invalid mailbox domain", mx_domain, domain)
return False
existing_user = User.get_by(email=email_address)
if existing_user and existing_user.disabled:
LOG.d(
f"User {existing_user} is disabled. {email_address} cannot be used for other mailbox"
)
return False
for existing_user in (
User.query()
.join(Mailbox, User.id == Mailbox.user_id)
.filter(Mailbox.email == email_address)
.group_by(User.id)
.all()
):
if existing_user.disabled:
LOG.d(
f"User {existing_user} is disabled and has a mailbox with {email_address}. Id cannot be used for other mailbox"
)
return False
return True

View File

@ -27,7 +27,7 @@ from sqlalchemy.orm import deferred
from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType
from app import config
from app import config, rate_limiter
from app import s3
from app.db import Session
from app.dns_utils import get_mx_domains
@ -235,6 +235,7 @@ class AuditLogActionEnum(EnumE):
download_provider_complaint = 8
disable_user = 9
enable_user = 10
stop_trial = 11
class Phase(EnumE):
@ -1563,6 +1564,15 @@ class Alias(Base, ModelMixin):
flush = kw.pop("flush", False)
new_alias = cls(**kw)
user = User.get(new_alias.user_id)
if user.is_premium():
limits = config.ALIAS_CREATE_RATE_LIMIT_PAID
else:
limits = config.ALIAS_CREATE_RATE_LIMIT_FREE
# limits is array of (hits,days)
for limit in limits:
key = f"alias_create_{limit[1]}d:{user.id}"
rate_limiter.check_bucket_limit(key, limit[0], limit[1])
email = kw["email"]
# make sure email is lowercase and doesn't have any whitespace
@ -3330,6 +3340,15 @@ class AdminAuditLog(Base):
},
)
@classmethod
def stop_trial(cls, admin_user_id: int, user_id: int):
cls.create(
admin_user_id=admin_user_id,
action=AuditLogActionEnum.stop_trial.value,
model="User",
model_id=user_id,
)
@classmethod
def disable_otp_fido(
cls, admin_user_id: int, user_id: int, had_otp: bool, had_fido: bool

38
app/app/rate_limiter.py Normal file
View File

@ -0,0 +1,38 @@
from datetime import datetime
from typing import Optional
import newrelic.agent
import redis.exceptions
import werkzeug.exceptions
from limits.storage import RedisStorage
from app.log import log
lock_redis: Optional[RedisStorage] = None
def set_redis_concurrent_lock(redis: RedisStorage):
global lock_redis
lock_redis = redis
def check_bucket_limit(
lock_name: Optional[str] = None,
max_hits: int = 5,
bucket_seconds: int = 3600,
):
# Calculate current bucket time
bucket_id = int(datetime.utcnow().timestamp()) % bucket_seconds
bucket_lock_name = f"bl:{lock_name}:{bucket_id}"
if not lock_redis:
return
try:
value = lock_redis.incr(bucket_lock_name, bucket_seconds)
if value > max_hits:
newrelic.agent.record_custom_event(
"BucketRateLimit",
{"lock_name": lock_name, "bucket_seconds": bucket_seconds},
)
raise werkzeug.exceptions.TooManyRequests()
except (redis.exceptions.RedisError, AttributeError):
log.e("Cannot connect to redis")

View File

@ -2,6 +2,7 @@ import flask
import limits.storage
from app.parallel_limiter import set_redis_concurrent_lock
from app.rate_limiter import set_redis_concurrent_lock as rate_limit_set_redis
from app.session import RedisSessionStore
@ -10,12 +11,14 @@ def initialize_redis_services(app: flask.Flask, redis_url: str):
storage = limits.storage.RedisStorage(redis_url)
app.session_interface = RedisSessionStore(storage.storage, storage.storage, app)
set_redis_concurrent_lock(storage)
rate_limit_set_redis(storage)
elif redis_url.startswith("redis+sentinel://"):
storage = limits.storage.RedisSentinelStorage(redis_url)
app.session_interface = RedisSessionStore(
storage.storage, storage.storage_slave, app
)
set_redis_concurrent_lock(storage)
rate_limit_set_redis(storage)
else:
raise RuntimeError(
f"Tried to set_redis_session with an invalid redis url: ${redis_url}"

View File

@ -49,11 +49,11 @@ def random_string(length=10, include_digits=False):
def convert_to_id(s: str):
"""convert a string to id-like: remove space, remove special accent"""
s = s.replace(" ", "")
s = s.lower()
s = unidecode(s)
s = s.replace(" ", "")
return s
return s[:256]
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."

View File

@ -9,7 +9,7 @@
<a href='https://simplelogin.io/' aria-label="SimpleLogin">
<img src="/static/logo-white.svg"
height="30px"
class="mb-3"
class="mt-3 mb-3"
alt="SimpleLogin logo">
</a>
<!-- End Logo -->

View File

@ -89,86 +89,87 @@
Github repo
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
<div class="dropdown-item">
<a href="https://forum.simplelogin.io"
target="_blank"
rel="noopener noreferrer">
Forum
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</div>
<div class="dropdown-item">
<a href="/dashboard/support">Support</a>
</div>
</div>
<div class="dropdown-item">
<a href="https://forum.simplelogin.io"
target="_blank"
rel="noopener noreferrer">
Forum
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</div>
<div class="dropdown-item">
<a href="/dashboard/support">Support</a>
</div>
</div>
{% else %}
<div class="nav-item">
<a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</div>
{% endif %}
{% if current_user.should_show_upgrade_button() %}
<div class="nav-item">
<a href="{{ url_for('dashboard.pricing') }}"
class="btn btn-sm btn-outline-primary">Upgrade</a>
</div>
{% endif %}
<div class="dropdown">
<a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown">
{% if current_user.profile_picture_id %}
<span class="avatar"
style="background-image: url('{{ current_user.profile_picture_url() }}')"></span>
{% else %}
<span class="avatar avatar-blue">{{ current_user.get_name_initial() or "👻" }}</span>
{% endif %}
<span class="ml-2 d-none d-lg-block">
<span class="text-default text-break">{{ current_user.name or current_user.email }}</span>
{% if current_user.in_trial() %}
<small class="text-success d-block mt-1"
data-toggle="tooltip"
title="When you signed up, you have a free 7-day Premium trial. After that your account will automatically be downgraded to the Free plan. During the trial, the only limit is you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases.">
Premium expires {{ current_user.trial_end|dt }}
<i class="fe fe-info"></i>
</small>
{% elif current_user.is_premium() %}
<small class="text-success d-block mt-1">Premium</small>
{% endif %}
</span>
</div>
{% else %}
<div class="nav-item">
<a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</div>
{% endif %}
{% if current_user.should_show_upgrade_button() %}
<div class="nav-item">
<a href="{{ url_for('dashboard.pricing') }}"
class="btn btn-sm btn-outline-primary">Upgrade</a>
</div>
{% endif %}
<div class="dropdown">
<a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown">
{% if current_user.profile_picture_id %}
<span class="avatar"
style="background-image: url('{{ current_user.profile_picture_url() }}')"></span>
{% else %}
<span class="avatar avatar-blue">{{ current_user.get_name_initial() or "👻" }}</span>
{% endif %}
<span class="ml-2 d-none d-lg-block">
<span class="text-default text-break">{{ current_user.name or current_user.email }}</span>
{% if current_user.in_trial() %}
<small class="text-success d-block mt-1"
data-toggle="tooltip"
title="When you signed up, you have a free 7-day Premium trial. After that your account will automatically be downgraded to the Free plan. During the trial, the only limit is you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases.">
Premium expires {{ current_user.trial_end|dt }}
<i class="fe fe-info"></i>
</small>
{% elif current_user.is_premium() %}
<small class="text-success d-block mt-1">Premium</small>
{% endif %}
</span>
</a>
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
<a class="dropdown-item mb-3" href="{{ url_for('dashboard.api_key') }}">
<i class="dropdown-icon fa fa-key"></i> API Keys
</a>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="dropdown-icon fe fe-log-out"></i> Sign out
</a>
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
<a class="dropdown-item mb-3" href="{{ url_for('dashboard.api_key') }}">
<i class="dropdown-icon fa fa-key"></i> API Keys
</a>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="dropdown-icon fe fe-log-out"></i> Sign out
</a>
</div>
</div>
</div>
<a href="#"
class="header-toggler d-lg-none ml-3 ml-lg-0"
data-toggle="collapse"
data-target="#headerMenuCollapse">
<span class="header-toggler-icon"></span>
</a>
</div>
<a href="#"
class="header-toggler d-lg-none ml-3 ml-lg-0"
data-toggle="collapse"
data-target="#headerMenuCollapse">
<span class="header-toggler-icon"></span>
</a>
</div>
</div>
<div class="header collapse d-lg-flex p-0" id="headerMenuCollapse">
<div class="container">
<div class="row align-items-center">
<div class="col-lg order-lg-first">
{% include "menu.html" %}
</div>
<div class="header collapse d-lg-flex p-0" id="headerMenuCollapse">
<div class="container">
<div class="row align-items-center">
<div class="col-lg order-lg-first">
{% include "menu.html" %}
</div>
</div>
</div>
</div>
</div>

View File

@ -49,10 +49,25 @@ from app.models import (
VerpType,
AliasGeneratorEnum,
SLDomain,
Mailbox,
)
# flake8: noqa: E101, W191
from tests.utils import login, load_eml_file, create_new_user, random_domain
from tests.utils import (
login,
load_eml_file,
create_new_user,
random_domain,
random_token,
)
def setup_module(module):
config.SKIP_MX_LOOKUP_ON_CHECK = True
def teardown_module(module):
config.SKIP_MX_LOOKUP_ON_CHECK = False
def test_get_email_domain_part():
@ -68,10 +83,6 @@ def test_email_belongs_to_alias_domains():
assert not can_create_directory_for_address("hey@d3.test")
@pytest.mark.skipif(
"GITHUB_ACTIONS_TEST" in os.environ,
reason="this test requires DNS lookup that does not work on Github CI",
)
def test_can_be_used_as_personal_email(flask_client):
# default alias domain
assert not email_can_be_used_as_mailbox("ab@sl.local")
@ -94,6 +105,27 @@ def test_can_be_used_as_personal_email(flask_client):
assert email_can_be_used_as_mailbox("abcd@gmail.com")
def test_disabled_user_prevents_email_from_being_used_as_mailbox():
email = f"user_{random_token(10)}@mailbox.test"
assert email_can_be_used_as_mailbox(email)
user = create_new_user(email)
user.disabled = True
Session.flush()
assert not email_can_be_used_as_mailbox(email)
def test_disabled_user_with_secondary_mailbox_prevents_email_from_being_used_as_mailbox():
email = f"user_{random_token(10)}@mailbox.test"
assert email_can_be_used_as_mailbox(email)
user = create_new_user()
Mailbox.create(user_id=user.id, email=email)
Session.flush()
assert email_can_be_used_as_mailbox(email)
user.disabled = True
Session.flush()
assert not email_can_be_used_as_mailbox(email)
def test_delete_header():
msg = EmailMessage()
assert msg._headers == []