Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
ead425e0c2 | |||
6c910d62c5 | |||
99ffd1ec0c |
@ -214,6 +214,20 @@ class UserAdmin(SLModelView):
|
|||||||
|
|
||||||
Session.commit()
|
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(
|
@action(
|
||||||
"disable_otp_fido",
|
"disable_otp_fido",
|
||||||
"Disable OTP & FIDO",
|
"Disable OTP & FIDO",
|
||||||
|
@ -17,9 +17,14 @@ from app.models import PlanEnum, AppleSubscription
|
|||||||
_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly"
|
_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly"
|
||||||
_YEARLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.yearly"
|
_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_MONTHLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.monthly"
|
||||||
_MACAPP_YEARLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.yearly"
|
_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
|
# Apple API URL
|
||||||
_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"
|
_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"
|
||||||
_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
|
_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
|
||||||
@ -263,7 +268,11 @@ def apple_update_notification():
|
|||||||
plan = (
|
plan = (
|
||||||
PlanEnum.monthly
|
PlanEnum.monthly
|
||||||
if transaction["product_id"]
|
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
|
else PlanEnum.yearly
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -517,7 +526,11 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||||||
plan = (
|
plan = (
|
||||||
PlanEnum.monthly
|
PlanEnum.monthly
|
||||||
if latest_transaction["product_id"]
|
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
|
else PlanEnum.yearly
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -492,6 +492,31 @@ NAMESERVERS = setup_nameservers()
|
|||||||
DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = os.environ.get(
|
DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = os.environ.get(
|
||||||
"DISABLE_CREATE_CONTACTS_FOR_FREE_USERS", False
|
"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 (
|
PARTNER_API_TOKEN_SECRET = os.environ.get("PARTNER_API_TOKEN_SECRET") or (
|
||||||
FLASK_SECRET + "partnerapitoken"
|
FLASK_SECRET + "partnerapitoken"
|
||||||
)
|
)
|
||||||
|
@ -27,7 +27,7 @@ from sqlalchemy.orm import deferred
|
|||||||
from sqlalchemy.sql import and_
|
from sqlalchemy.sql import and_
|
||||||
from sqlalchemy_utils import ArrowType
|
from sqlalchemy_utils import ArrowType
|
||||||
|
|
||||||
from app import config
|
from app import config, rate_limiter
|
||||||
from app import s3
|
from app import s3
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.dns_utils import get_mx_domains
|
from app.dns_utils import get_mx_domains
|
||||||
@ -235,6 +235,7 @@ class AuditLogActionEnum(EnumE):
|
|||||||
download_provider_complaint = 8
|
download_provider_complaint = 8
|
||||||
disable_user = 9
|
disable_user = 9
|
||||||
enable_user = 10
|
enable_user = 10
|
||||||
|
stop_trial = 11
|
||||||
|
|
||||||
|
|
||||||
class Phase(EnumE):
|
class Phase(EnumE):
|
||||||
@ -907,7 +908,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
return sub
|
return sub
|
||||||
|
|
||||||
def verified_custom_domains(self) -> List["CustomDomain"]:
|
def verified_custom_domains(self) -> List["CustomDomain"]:
|
||||||
return CustomDomain.filter_by(user_id=self.id, ownership_verified=True).all()
|
return (
|
||||||
|
CustomDomain.filter_by(user_id=self.id, ownership_verified=True)
|
||||||
|
.order_by(CustomDomain.domain.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
def mailboxes(self) -> List["Mailbox"]:
|
def mailboxes(self) -> List["Mailbox"]:
|
||||||
"""list of mailbox that user own"""
|
"""list of mailbox that user own"""
|
||||||
@ -1563,6 +1568,15 @@ class Alias(Base, ModelMixin):
|
|||||||
flush = kw.pop("flush", False)
|
flush = kw.pop("flush", False)
|
||||||
|
|
||||||
new_alias = cls(**kw)
|
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"]
|
email = kw["email"]
|
||||||
# make sure email is lowercase and doesn't have any whitespace
|
# make sure email is lowercase and doesn't have any whitespace
|
||||||
@ -3330,6 +3344,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
|
@classmethod
|
||||||
def disable_otp_fido(
|
def disable_otp_fido(
|
||||||
cls, admin_user_id: int, user_id: int, had_otp: bool, had_fido: bool
|
cls, admin_user_id: int, user_id: int, had_otp: bool, had_fido: bool
|
||||||
|
38
app/app/rate_limiter.py
Normal file
38
app/app/rate_limiter.py
Normal 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")
|
@ -2,6 +2,7 @@ import flask
|
|||||||
import limits.storage
|
import limits.storage
|
||||||
|
|
||||||
from app.parallel_limiter import set_redis_concurrent_lock
|
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
|
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)
|
storage = limits.storage.RedisStorage(redis_url)
|
||||||
app.session_interface = RedisSessionStore(storage.storage, storage.storage, app)
|
app.session_interface = RedisSessionStore(storage.storage, storage.storage, app)
|
||||||
set_redis_concurrent_lock(storage)
|
set_redis_concurrent_lock(storage)
|
||||||
|
rate_limit_set_redis(storage)
|
||||||
elif redis_url.startswith("redis+sentinel://"):
|
elif redis_url.startswith("redis+sentinel://"):
|
||||||
storage = limits.storage.RedisSentinelStorage(redis_url)
|
storage = limits.storage.RedisSentinelStorage(redis_url)
|
||||||
app.session_interface = RedisSessionStore(
|
app.session_interface = RedisSessionStore(
|
||||||
storage.storage, storage.storage_slave, app
|
storage.storage, storage.storage_slave, app
|
||||||
)
|
)
|
||||||
set_redis_concurrent_lock(storage)
|
set_redis_concurrent_lock(storage)
|
||||||
|
rate_limit_set_redis(storage)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Tried to set_redis_session with an invalid redis url: ${redis_url}"
|
f"Tried to set_redis_session with an invalid redis url: ${redis_url}"
|
||||||
|
@ -89,86 +89,87 @@
|
|||||||
Github repo
|
Github repo
|
||||||
<i class="fa fa-external-link" aria-hidden="true"></i>
|
<i class="fa fa-external-link" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-item">
|
</div>
|
||||||
<a href="https://forum.simplelogin.io"
|
<div class="dropdown-item">
|
||||||
target="_blank"
|
<a href="https://forum.simplelogin.io"
|
||||||
rel="noopener noreferrer">
|
target="_blank"
|
||||||
Forum
|
rel="noopener noreferrer">
|
||||||
<i class="fa fa-external-link" aria-hidden="true"></i>
|
Forum
|
||||||
</a>
|
<i class="fa fa-external-link" aria-hidden="true"></i>
|
||||||
</div>
|
</a>
|
||||||
<div class="dropdown-item">
|
</div>
|
||||||
<a href="/dashboard/support">Support</a>
|
<div class="dropdown-item">
|
||||||
</div>
|
<a href="/dashboard/support">Support</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</div>
|
||||||
<div class="nav-item">
|
{% else %}
|
||||||
<a href="https://simplelogin.io/docs/"
|
<div class="nav-item">
|
||||||
target="_blank"
|
<a href="https://simplelogin.io/docs/"
|
||||||
rel="noopener noreferrer">
|
target="_blank"
|
||||||
Docs
|
rel="noopener noreferrer">
|
||||||
<i class="fa fa-external-link" aria-hidden="true"></i>
|
Docs
|
||||||
</a>
|
<i class="fa fa-external-link" aria-hidden="true"></i>
|
||||||
</div>
|
</a>
|
||||||
{% endif %}
|
</div>
|
||||||
{% if current_user.should_show_upgrade_button() %}
|
{% endif %}
|
||||||
|
{% if current_user.should_show_upgrade_button() %}
|
||||||
<div class="nav-item">
|
|
||||||
<a href="{{ url_for('dashboard.pricing') }}"
|
<div class="nav-item">
|
||||||
class="btn btn-sm btn-outline-primary">Upgrade</a>
|
<a href="{{ url_for('dashboard.pricing') }}"
|
||||||
</div>
|
class="btn btn-sm btn-outline-primary">Upgrade</a>
|
||||||
{% endif %}
|
</div>
|
||||||
<div class="dropdown">
|
{% endif %}
|
||||||
<a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown">
|
<div class="dropdown">
|
||||||
{% if current_user.profile_picture_id %}
|
<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>
|
<span class="avatar"
|
||||||
{% else %}
|
style="background-image: url('{{ current_user.profile_picture_url() }}')"></span>
|
||||||
<span class="avatar avatar-blue">{{ current_user.get_name_initial() or "👻" }}</span>
|
{% else %}
|
||||||
{% endif %}
|
<span class="avatar avatar-blue">{{ current_user.get_name_initial() or "👻" }}</span>
|
||||||
<span class="ml-2 d-none d-lg-block">
|
{% endif %}
|
||||||
<span class="text-default text-break">{{ current_user.name or current_user.email }}</span>
|
<span class="ml-2 d-none d-lg-block">
|
||||||
{% if current_user.in_trial() %}
|
<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"
|
<small class="text-success d-block mt-1"
|
||||||
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.">
|
data-toggle="tooltip"
|
||||||
Premium expires {{ current_user.trial_end|dt }}
|
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.">
|
||||||
<i class="fe fe-info"></i>
|
Premium expires {{ current_user.trial_end|dt }}
|
||||||
</small>
|
<i class="fe fe-info"></i>
|
||||||
{% elif current_user.is_premium() %}
|
</small>
|
||||||
<small class="text-success d-block mt-1">Premium</small>
|
{% elif current_user.is_premium() %}
|
||||||
{% endif %}
|
<small class="text-success d-block mt-1">Premium</small>
|
||||||
</span>
|
{% 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>
|
</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>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
<div class="header collapse d-lg-flex p-0" id="headerMenuCollapse">
|
</div>
|
||||||
<div class="container">
|
<div class="header collapse d-lg-flex p-0" id="headerMenuCollapse">
|
||||||
<div class="row align-items-center">
|
<div class="container">
|
||||||
<div class="col-lg order-lg-first">
|
<div class="row align-items-center">
|
||||||
{% include "menu.html" %}
|
<div class="col-lg order-lg-first">
|
||||||
|
{% include "menu.html" %}
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
Reference in New Issue
Block a user