mirror of
https://github.com/simple-login/app.git
synced 2025-10-03 03:45:53 +08:00
feat: user audit log (#2266)
* feat: set up UserAuditLog * refactor: extract payment callbacks into their own files + handle subscription user_audit_log * feat: handle account linking for user audit log * chore: user_audit_log for mailboxes * chore: user_audit_log for custom domains * chore: user_audit_log for contacts * chore: user_audit_log for directories * fix: do not enforce cronjob being defined in choices + enable user deletion * chore: user_audit_log for user deletion * refactor: change emit_user_audit_log function to receive the full user object * feat: add user_audit_log migration * test: fix tests * test: add some tests for user_audit_log * fix: spf record verification user_audit_log * chore: add missing index to user_audit_log.created_at * chore: add missing index to alias_audit_log.created_at
This commit is contained in:
parent
b8be0f470a
commit
35f6e67053
33 changed files with 1055 additions and 393 deletions
|
@ -9,6 +9,7 @@ from sqlalchemy import or_
|
|||
|
||||
from app.db import Session
|
||||
from app.email_utils import send_welcome_email
|
||||
from app.partner_user_utils import create_partner_user, create_partner_subscription
|
||||
from app.utils import sanitize_email, canonicalize_email
|
||||
from app.errors import (
|
||||
AccountAlreadyLinkedToAnotherPartnerException,
|
||||
|
@ -23,6 +24,7 @@ from app.models import (
|
|||
User,
|
||||
Alias,
|
||||
)
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
|
@ -66,9 +68,10 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
|
|||
LOG.i(
|
||||
f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
|
||||
)
|
||||
PartnerSubscription.create(
|
||||
partner_user_id=partner_user.id,
|
||||
end_at=plan.expiration,
|
||||
create_partner_subscription(
|
||||
partner_user=partner_user,
|
||||
expiration=plan.expiration,
|
||||
msg="Upgraded via partner. User did not have a previous partner subscription",
|
||||
)
|
||||
agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"})
|
||||
else:
|
||||
|
@ -80,6 +83,11 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
|
|||
"PlanChange", {"plan": "premium", "type": "extension"}
|
||||
)
|
||||
sub.end_at = plan.expiration
|
||||
emit_user_audit_log(
|
||||
user=partner_user.user,
|
||||
action=UserAuditLogAction.SubscriptionExtended,
|
||||
message="Extended partner subscription",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
|
||||
|
@ -98,8 +106,8 @@ def ensure_partner_user_exists_for_user(
|
|||
if res and res.partner_id != partner.id:
|
||||
raise AccountAlreadyLinkedToAnotherPartnerException()
|
||||
if not res:
|
||||
res = PartnerUser.create(
|
||||
user_id=sl_user.id,
|
||||
res = create_partner_user(
|
||||
user=sl_user,
|
||||
partner_id=partner.id,
|
||||
partner_email=link_request.email,
|
||||
external_user_id=link_request.external_user_id,
|
||||
|
@ -140,8 +148,8 @@ class NewUserStrategy(ClientMergeStrategy):
|
|||
activated=True,
|
||||
from_partner=self.link_request.from_partner,
|
||||
)
|
||||
partner_user = PartnerUser.create(
|
||||
user_id=new_user.id,
|
||||
partner_user = create_partner_user(
|
||||
user=new_user,
|
||||
partner_id=self.partner.id,
|
||||
external_user_id=self.link_request.external_user_id,
|
||||
partner_email=self.link_request.email,
|
||||
|
@ -200,7 +208,7 @@ def get_login_strategy(
|
|||
return ExistingUnlinkedUserStrategy(link_request, user, partner)
|
||||
|
||||
|
||||
def check_alias(email: str) -> bool:
|
||||
def check_alias(email: str):
|
||||
alias = Alias.get_by(email=email)
|
||||
if alias is not None:
|
||||
raise AccountIsUsingAliasAsEmail()
|
||||
|
@ -275,10 +283,26 @@ def switch_already_linked_user(
|
|||
LOG.i(
|
||||
f"Deleting previous partner_user:{other_partner_user.id} from user:{current_user.id}"
|
||||
)
|
||||
|
||||
emit_user_audit_log(
|
||||
user=other_partner_user.user,
|
||||
action=UserAuditLogAction.UnlinkAccount,
|
||||
message=f"Deleting partner_user {other_partner_user.id} (external_user_id={other_partner_user.external_user_id} | partner_email={other_partner_user.partner_email}) from user {current_user.id}, as we received a new link request for the same partner",
|
||||
)
|
||||
PartnerUser.delete(other_partner_user.id)
|
||||
LOG.i(f"Linking partner_user:{partner_user.id} to user:{current_user.id}")
|
||||
# Link this partner_user to the current user
|
||||
emit_user_audit_log(
|
||||
user=partner_user.user,
|
||||
action=UserAuditLogAction.UnlinkAccount,
|
||||
message=f"Unlinking from partner, as user will now be tied to another external account. old=(id={partner_user.user.id} | email={partner_user.user.email}) | new=(id={current_user.id} | email={current_user.email})",
|
||||
)
|
||||
partner_user.user_id = current_user.id
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.LinkAccount,
|
||||
message=f"Linking user {current_user.id} ({current_user.email}) to partner_user:{partner_user.id} (external_user_id={partner_user.external_user_id} | partner_email={partner_user.partner_email})",
|
||||
)
|
||||
# Set plan
|
||||
set_plan_for_partner_user(partner_user, link_request.plan)
|
||||
Session.commit()
|
||||
|
|
|
@ -6,6 +6,7 @@ from app import config
|
|||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import Job, ApiToCookieToken
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
@api_bp.route("/user", methods=["DELETE"])
|
||||
|
@ -16,6 +17,11 @@ def delete_user():
|
|||
|
||||
"""
|
||||
# Schedule delete account job
|
||||
emit_user_audit_log(
|
||||
user=g.user,
|
||||
action=UserAuditLogAction.UserMarkedForDeletion,
|
||||
message=f"Marked user {g.user.id} ({g.user.email}) for deletion from API",
|
||||
)
|
||||
LOG.w("schedule delete account job for %s", g.user)
|
||||
Job.create(
|
||||
name=config.JOB_DELETE_ACCOUNT,
|
||||
|
|
|
@ -9,6 +9,7 @@ from app.email_utils import generate_reply_email, parse_full_address
|
|||
from app.email_validation import is_valid_email
|
||||
from app.log import LOG
|
||||
from app.models import Contact, Alias
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import sanitize_email
|
||||
|
||||
|
||||
|
@ -100,6 +101,12 @@ def create_contact(
|
|||
invalid_email=email == "",
|
||||
commit=True,
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=alias.user,
|
||||
action=UserAuditLogAction.CreateContact,
|
||||
message=f"Created contact {contact.id} ({contact.email})",
|
||||
commit=True,
|
||||
)
|
||||
LOG.d(
|
||||
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}"
|
||||
)
|
||||
|
|
|
@ -10,6 +10,7 @@ from app.db import Session
|
|||
from app.email_utils import get_email_domain_part
|
||||
from app.log import LOG
|
||||
from app.models import User, CustomDomain, SLDomain, Mailbox, Job, DomainMailbox
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
_ALLOWED_DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
|
||||
_MAX_MAILBOXES_PER_DOMAIN = 20
|
||||
|
@ -137,6 +138,11 @@ def create_custom_domain(
|
|||
if partner_id is not None:
|
||||
new_custom_domain.partner_id = partner_id
|
||||
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.CreateCustomDomain,
|
||||
message=f"Created custom domain {new_custom_domain.id} ({new_domain})",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
return CreateCustomDomainResult(
|
||||
|
@ -190,5 +196,11 @@ def set_custom_domain_mailboxes(
|
|||
for mailbox in mailboxes:
|
||||
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
|
||||
|
||||
mailboxes_as_str = ",".join(map(str, mailbox_ids))
|
||||
emit_user_audit_log(
|
||||
user=custom_domain.user,
|
||||
action=UserAuditLogAction.UpdateCustomDomain,
|
||||
message=f"Updated custom domain {custom_domain.id} mailboxes (domain={custom_domain.domain}) (mailboxes={mailboxes_as_str})",
|
||||
)
|
||||
Session.commit()
|
||||
return SetCustomDomainMailboxesResult(success=True)
|
||||
|
|
|
@ -11,6 +11,7 @@ from app.dns_utils import (
|
|||
get_network_dns_client,
|
||||
)
|
||||
from app.models import CustomDomain
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
|
@ -121,6 +122,12 @@ class CustomDomainValidation:
|
|||
# Original DKIM record is not there, which means the DKIM config is not finished. Proceed with the
|
||||
# rest of the code path, returning the invalid records and clearing the flag
|
||||
custom_domain.dkim_verified = len(invalid_records) == 0
|
||||
if custom_domain.dkim_verified:
|
||||
emit_user_audit_log(
|
||||
user=custom_domain.user,
|
||||
action=UserAuditLogAction.VerifyCustomDomain,
|
||||
message=f"Verified DKIM records for custom domain {custom_domain.id} ({custom_domain.domain})",
|
||||
)
|
||||
Session.commit()
|
||||
return invalid_records
|
||||
|
||||
|
@ -137,6 +144,11 @@ class CustomDomainValidation:
|
|||
|
||||
if expected_verification_record in txt_records:
|
||||
custom_domain.ownership_verified = True
|
||||
emit_user_audit_log(
|
||||
user=custom_domain.user,
|
||||
action=UserAuditLogAction.VerifyCustomDomain,
|
||||
message=f"Verified ownership for custom domain {custom_domain.id} ({custom_domain.domain})",
|
||||
)
|
||||
Session.commit()
|
||||
return DomainValidationResult(success=True, errors=[])
|
||||
else:
|
||||
|
@ -155,6 +167,11 @@ class CustomDomainValidation:
|
|||
)
|
||||
else:
|
||||
custom_domain.verified = True
|
||||
emit_user_audit_log(
|
||||
user=custom_domain.user,
|
||||
action=UserAuditLogAction.VerifyCustomDomain,
|
||||
message=f"Verified MX records for custom domain {custom_domain.id} ({custom_domain.domain})",
|
||||
)
|
||||
Session.commit()
|
||||
return DomainValidationResult(success=True, errors=[])
|
||||
|
||||
|
@ -165,6 +182,11 @@ class CustomDomainValidation:
|
|||
expected_spf_domain = self.get_expected_spf_domain(custom_domain)
|
||||
if expected_spf_domain in spf_domains:
|
||||
custom_domain.spf_verified = True
|
||||
emit_user_audit_log(
|
||||
user=custom_domain.user,
|
||||
action=UserAuditLogAction.VerifyCustomDomain,
|
||||
message=f"Verified SPF records for custom domain {custom_domain.id} ({custom_domain.domain})",
|
||||
)
|
||||
Session.commit()
|
||||
return DomainValidationResult(success=True, errors=[])
|
||||
else:
|
||||
|
@ -183,6 +205,11 @@ class CustomDomainValidation:
|
|||
txt_records = self._dns_client.get_txt_record("_dmarc." + custom_domain.domain)
|
||||
if DMARC_RECORD in txt_records:
|
||||
custom_domain.dmarc_verified = True
|
||||
emit_user_audit_log(
|
||||
user=custom_domain.user,
|
||||
action=UserAuditLogAction.VerifyCustomDomain,
|
||||
message=f"Verified DMARC records for custom domain {custom_domain.id} ({custom_domain.domain})",
|
||||
)
|
||||
Session.commit()
|
||||
return DomainValidationResult(success=True, errors=[])
|
||||
else:
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from dataclasses import dataclass
|
||||
from operator import or_
|
||||
from typing import Optional
|
||||
|
||||
from flask import render_template, request, redirect, flash
|
||||
from flask import url_for
|
||||
|
@ -22,6 +23,7 @@ from app.errors import (
|
|||
)
|
||||
from app.log import LOG
|
||||
from app.models import Alias, Contact, EmailLog
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import CSRFValidationForm
|
||||
|
||||
|
||||
|
@ -190,7 +192,7 @@ def get_contact_infos(
|
|||
|
||||
|
||||
def delete_contact(alias: Alias, contact_id: int):
|
||||
contact = Contact.get(contact_id)
|
||||
contact: Optional[Contact] = Contact.get(contact_id)
|
||||
|
||||
if not contact:
|
||||
flash("Unknown error. Refresh the page", "warning")
|
||||
|
@ -198,6 +200,11 @@ def delete_contact(alias: Alias, contact_id: int):
|
|||
flash("You cannot delete reverse-alias", "warning")
|
||||
else:
|
||||
delete_contact_email = contact.website_email
|
||||
emit_user_audit_log(
|
||||
user=alias.user,
|
||||
action=UserAuditLogAction.DeleteContact,
|
||||
message=f"Delete contact {contact_id} ({contact.email})",
|
||||
)
|
||||
Contact.delete(contact_id)
|
||||
Session.commit()
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from typing import Optional
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
|
@ -7,6 +9,7 @@ from app.dashboard.base import dashboard_bp
|
|||
from app.db import Session
|
||||
from app.models import Contact
|
||||
from app.pgp_utils import PGPException, load_public_key_and_check
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
class PGPContactForm(FlaskForm):
|
||||
|
@ -20,7 +23,7 @@ class PGPContactForm(FlaskForm):
|
|||
@dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def contact_detail_route(contact_id):
|
||||
contact = Contact.get(contact_id)
|
||||
contact: Optional[Contact] = Contact.get(contact_id)
|
||||
if not contact or contact.user_id != current_user.id:
|
||||
flash("You cannot see this page", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
@ -50,6 +53,11 @@ def contact_detail_route(contact_id):
|
|||
except PGPException:
|
||||
flash("Cannot add the public key, please verify it", "error")
|
||||
else:
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateContact,
|
||||
message=f"Added PGP key {contact.pgp_public_key} for contact {contact_id} ({contact.email})",
|
||||
)
|
||||
Session.commit()
|
||||
flash(
|
||||
f"PGP public key for {contact.email} is saved successfully",
|
||||
|
@ -62,6 +70,11 @@ def contact_detail_route(contact_id):
|
|||
)
|
||||
elif pgp_form.action.data == "remove":
|
||||
# Free user can decide to remove contact PGP key
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateContact,
|
||||
message=f"Removed PGP key {contact.pgp_public_key} for contact {contact_id} ({contact.email})",
|
||||
)
|
||||
contact.pgp_public_key = None
|
||||
contact.pgp_finger_print = None
|
||||
Session.commit()
|
||||
|
|
|
@ -8,6 +8,7 @@ from app.dashboard.base import dashboard_bp
|
|||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.log import LOG
|
||||
from app.models import Subscription, Job
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
class DeleteDirForm(FlaskForm):
|
||||
|
@ -33,6 +34,11 @@ def delete_account():
|
|||
|
||||
# Schedule delete account job
|
||||
LOG.w("schedule delete account job for %s", current_user)
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UserMarkedForDeletion,
|
||||
message=f"User {current_user.id} ({current_user.email}) marked for deletion via webapp",
|
||||
)
|
||||
Job.create(
|
||||
name=JOB_DELETE_ACCOUNT,
|
||||
payload={"user_id": current_user.id},
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from typing import Optional
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
|
@ -20,6 +22,7 @@ from app.dashboard.base import dashboard_bp
|
|||
from app.db import Session
|
||||
from app.errors import DirectoryInTrashError
|
||||
from app.models import Directory, Mailbox, DirectoryMailbox
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
class NewDirForm(FlaskForm):
|
||||
|
@ -69,7 +72,9 @@ def directory():
|
|||
if not delete_dir_form.validate():
|
||||
flash("Invalid request", "warning")
|
||||
return redirect(url_for("dashboard.directory"))
|
||||
dir_obj = Directory.get(delete_dir_form.directory_id.data)
|
||||
dir_obj: Optional[Directory] = Directory.get(
|
||||
delete_dir_form.directory_id.data
|
||||
)
|
||||
|
||||
if not dir_obj:
|
||||
flash("Unknown error. Refresh the page", "warning")
|
||||
|
@ -79,6 +84,11 @@ def directory():
|
|||
return redirect(url_for("dashboard.directory"))
|
||||
|
||||
name = dir_obj.name
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.DeleteDirectory,
|
||||
message=f"Delete directory {dir_obj.id} ({dir_obj.name})",
|
||||
)
|
||||
Directory.delete(dir_obj.id)
|
||||
Session.commit()
|
||||
flash(f"Directory {name} has been deleted", "success")
|
||||
|
@ -90,7 +100,7 @@ def directory():
|
|||
flash("Invalid request", "warning")
|
||||
return redirect(url_for("dashboard.directory"))
|
||||
dir_id = toggle_dir_form.directory_id.data
|
||||
dir_obj = Directory.get(dir_id)
|
||||
dir_obj: Optional[Directory] = Directory.get(dir_id)
|
||||
|
||||
if not dir_obj or dir_obj.user_id != current_user.id:
|
||||
flash("Unknown error. Refresh the page", "warning")
|
||||
|
@ -103,6 +113,11 @@ def directory():
|
|||
dir_obj.disabled = True
|
||||
flash(f"On-the-fly is disabled for {dir_obj.name}", "warning")
|
||||
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateDirectory,
|
||||
message=f"Updated directory {dir_obj.id} ({dir_obj.name}) set disabled = {dir_obj.disabled}",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
return redirect(url_for("dashboard.directory"))
|
||||
|
@ -112,7 +127,7 @@ def directory():
|
|||
flash("Invalid request", "warning")
|
||||
return redirect(url_for("dashboard.directory"))
|
||||
dir_id = update_dir_form.directory_id.data
|
||||
dir_obj = Directory.get(dir_id)
|
||||
dir_obj: Optional[Directory] = Directory.get(dir_id)
|
||||
|
||||
if not dir_obj or dir_obj.user_id != current_user.id:
|
||||
flash("Unknown error. Refresh the page", "warning")
|
||||
|
@ -143,6 +158,12 @@ def directory():
|
|||
for mailbox in mailboxes:
|
||||
DirectoryMailbox.create(directory_id=dir_obj.id, mailbox_id=mailbox.id)
|
||||
|
||||
mailboxes_as_str = ",".join(map(str, mailbox_ids))
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateDirectory,
|
||||
message=f"Updated directory {dir_obj.id} ({dir_obj.name}) mailboxes ({mailboxes_as_str})",
|
||||
)
|
||||
Session.commit()
|
||||
flash(f"Directory {dir_obj.name} has been updated", "success")
|
||||
|
||||
|
@ -181,6 +202,11 @@ def directory():
|
|||
new_dir = Directory.create(
|
||||
name=new_dir_name, user_id=current_user.id
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.CreateDirectory,
|
||||
message=f"New directory {new_dir.name} ({new_dir.name})",
|
||||
)
|
||||
except DirectoryInTrashError:
|
||||
flash(
|
||||
f"{new_dir_name} has been used before and cannot be reused",
|
||||
|
|
|
@ -20,6 +20,7 @@ from app.models import (
|
|||
AutoCreateRuleMailbox,
|
||||
)
|
||||
from app.regex_utils import regex_match
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import random_string, CSRFValidationForm
|
||||
|
||||
|
||||
|
@ -164,6 +165,11 @@ def domain_detail(custom_domain_id):
|
|||
return redirect(request.url)
|
||||
if request.form.get("form-name") == "switch-catch-all":
|
||||
custom_domain.catch_all = not custom_domain.catch_all
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateCustomDomain,
|
||||
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) catch all to {custom_domain.catch_all}",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
if custom_domain.catch_all:
|
||||
|
@ -182,6 +188,11 @@ def domain_detail(custom_domain_id):
|
|||
elif request.form.get("form-name") == "set-name":
|
||||
if request.form.get("action") == "save":
|
||||
custom_domain.name = request.form.get("alias-name").replace("\n", "")
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateCustomDomain,
|
||||
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) name",
|
||||
)
|
||||
Session.commit()
|
||||
flash(
|
||||
f"Default alias name for Domain {custom_domain.domain} has been set",
|
||||
|
@ -189,6 +200,11 @@ def domain_detail(custom_domain_id):
|
|||
)
|
||||
else:
|
||||
custom_domain.name = None
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateCustomDomain,
|
||||
message=f"Cleared custom domain {custom_domain.id} ({custom_domain.domain}) name",
|
||||
)
|
||||
Session.commit()
|
||||
flash(
|
||||
f"Default alias name for Domain {custom_domain.domain} has been removed",
|
||||
|
@ -202,6 +218,11 @@ def domain_detail(custom_domain_id):
|
|||
custom_domain.random_prefix_generation = (
|
||||
not custom_domain.random_prefix_generation
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateCustomDomain,
|
||||
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) random prefix generation to {custom_domain.random_prefix_generation}",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
if custom_domain.random_prefix_generation:
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
|
@ -15,6 +16,7 @@ from app.dashboard.base import dashboard_bp
|
|||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import Mailbox
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import CSRFValidationForm
|
||||
|
||||
|
||||
|
@ -151,7 +153,7 @@ def verify_with_signed_secret(request: str):
|
|||
flash("Invalid link. Please delete and re-add your mailbox", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
mailbox_id = mailbox_data[0]
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
|
||||
if not mailbox:
|
||||
flash("Invalid link", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
|
@ -161,6 +163,11 @@ def verify_with_signed_secret(request: str):
|
|||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
|
||||
mailbox.verified = True
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.VerifyMailbox,
|
||||
message=f"Verified mailbox {mailbox.id} ({mailbox.email})",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
LOG.d("Mailbox %s is verified", mailbox)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from smtplib import SMTPRecipientsRefused
|
||||
from typing import Optional
|
||||
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
|
@ -20,6 +21,7 @@ from app.log import LOG
|
|||
from app.models import Alias, AuthorizedAddress
|
||||
from app.models import Mailbox
|
||||
from app.pgp_utils import PGPException, load_public_key_and_check
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import sanitize_email, CSRFValidationForm
|
||||
|
||||
|
||||
|
@ -88,8 +90,12 @@ def mailbox_detail_route(mailbox_id):
|
|||
flash("SPF enforcement globally not enabled", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
mailbox.force_spf = (
|
||||
True if request.form.get("spf-status") == "on" else False
|
||||
force_spf_value = request.form.get("spf-status") == "on"
|
||||
mailbox.force_spf = force_spf_value
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Set force_spf to {force_spf_value} on mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
Session.commit()
|
||||
flash(
|
||||
|
@ -113,6 +119,11 @@ def mailbox_detail_route(mailbox_id):
|
|||
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
|
||||
flash(f"{address} already added", "error")
|
||||
else:
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Add authorized address {address} to mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
AuthorizedAddress.create(
|
||||
user_id=current_user.id,
|
||||
mailbox_id=mailbox.id,
|
||||
|
@ -133,6 +144,11 @@ def mailbox_detail_route(mailbox_id):
|
|||
flash("Unknown error. Refresh the page", "warning")
|
||||
else:
|
||||
address = authorized_address.email
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Remove authorized address {address} from mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
AuthorizedAddress.delete(authorized_address_id)
|
||||
Session.commit()
|
||||
flash(f"{address} has been deleted", "success")
|
||||
|
@ -165,6 +181,11 @@ def mailbox_detail_route(mailbox_id):
|
|||
except PGPException:
|
||||
flash("Cannot add the public key, please verify it", "error")
|
||||
else:
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Add PGP Key {mailbox.pgp_finger_print} to mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
Session.commit()
|
||||
flash("Your PGP public key is saved successfully", "success")
|
||||
return redirect(
|
||||
|
@ -172,6 +193,11 @@ def mailbox_detail_route(mailbox_id):
|
|||
)
|
||||
elif request.form.get("action") == "remove":
|
||||
# Free user can decide to remove their added PGP key
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Remove PGP Key {mailbox.pgp_finger_print} from mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
mailbox.pgp_public_key = None
|
||||
mailbox.pgp_finger_print = None
|
||||
mailbox.disable_pgp = False
|
||||
|
@ -191,9 +217,19 @@ def mailbox_detail_route(mailbox_id):
|
|||
)
|
||||
else:
|
||||
mailbox.disable_pgp = False
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Enabled PGP for mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
flash(f"PGP is enabled on {mailbox.email}", "info")
|
||||
else:
|
||||
mailbox.disable_pgp = True
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Disabled PGP for mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
flash(f"PGP is disabled on {mailbox.email}", "info")
|
||||
|
||||
Session.commit()
|
||||
|
@ -203,6 +239,11 @@ def mailbox_detail_route(mailbox_id):
|
|||
elif request.form.get("form-name") == "generic-subject":
|
||||
if request.form.get("action") == "save":
|
||||
mailbox.generic_subject = request.form.get("generic-subject")
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Set generic subject for mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
Session.commit()
|
||||
flash("Generic subject is enabled", "success")
|
||||
return redirect(
|
||||
|
@ -210,6 +251,11 @@ def mailbox_detail_route(mailbox_id):
|
|||
)
|
||||
elif request.form.get("action") == "remove":
|
||||
mailbox.generic_subject = None
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Remove generic subject for mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
Session.commit()
|
||||
flash("Generic subject is disabled", "success")
|
||||
return redirect(
|
||||
|
@ -282,7 +328,7 @@ def mailbox_confirm_change_route():
|
|||
flash("Invalid link", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
else:
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
|
||||
|
||||
# new_email can be None if user cancels change in the meantime
|
||||
if mailbox and mailbox.new_email:
|
||||
|
@ -293,6 +339,11 @@ def mailbox_confirm_change_route():
|
|||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
|
||||
)
|
||||
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Change mailbox email for mailbox {mailbox_id} (old={mailbox.email} | new={mailbox.new_email})",
|
||||
)
|
||||
mailbox.email = mailbox.new_email
|
||||
mailbox.new_email = None
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ from app.dashboard.base import dashboard_bp
|
|||
from app.errors import SubdomainInTrashError
|
||||
from app.log import LOG
|
||||
from app.models import CustomDomain, Mailbox, SLDomain
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
# Only lowercase letters, numbers, dashes (-) are currently supported
|
||||
_SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}"
|
||||
|
@ -102,6 +103,12 @@ def subdomain_route():
|
|||
ownership_verified=True,
|
||||
commit=True,
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.CreateCustomDomain,
|
||||
message=f"Create subdomain {new_custom_domain.id} ({full_domain})",
|
||||
commit=True,
|
||||
)
|
||||
except SubdomainInTrashError:
|
||||
flash(
|
||||
f"{full_domain} has been used before and cannot be reused",
|
||||
|
|
|
@ -16,6 +16,7 @@ from app.email_utils import (
|
|||
from app.email_validation import is_valid_email
|
||||
from app.log import LOG
|
||||
from app.models import User, Mailbox, Job, MailboxActivation
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
|
@ -70,9 +71,15 @@ def create_mailbox(
|
|||
f"User {user} has tried to create mailbox with {email} but email is invalid"
|
||||
)
|
||||
raise MailboxError("Invalid email")
|
||||
new_mailbox = Mailbox.create(
|
||||
new_mailbox: Mailbox = Mailbox.create(
|
||||
email=email, user_id=user.id, verified=verified, commit=True
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.CreateMailbox,
|
||||
message=f"Create mailbox {new_mailbox.id} ({new_mailbox.email}). Verified={verified}",
|
||||
commit=True,
|
||||
)
|
||||
|
||||
if verified:
|
||||
LOG.i(f"User {user} as created a pre-verified mailbox with {email}")
|
||||
|
@ -129,7 +136,7 @@ def delete_mailbox(
|
|||
|
||||
if not transfer_mailbox.verified:
|
||||
LOG.i(f"User {user} has tried to transfer to a non verified mailbox")
|
||||
MailboxError("Your new mailbox is not verified")
|
||||
raise MailboxError("Your new mailbox is not verified")
|
||||
|
||||
# Schedule delete account job
|
||||
LOG.i(
|
||||
|
@ -204,6 +211,11 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
|
|||
raise CannotVerifyError("Invalid activation code")
|
||||
LOG.i(f"User {user} has verified mailbox {mailbox_id}")
|
||||
mailbox.verified = True
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.VerifyMailbox,
|
||||
message=f"Verify mailbox {mailbox_id} ({mailbox.email})",
|
||||
)
|
||||
clear_activation_codes_for_mailbox(mailbox)
|
||||
return mailbox
|
||||
|
||||
|
|
|
@ -3822,4 +3822,22 @@ class AliasAuditLog(Base, ModelMixin):
|
|||
sa.Index("ix_alias_audit_log_user_id", "user_id"),
|
||||
sa.Index("ix_alias_audit_log_alias_id", "alias_id"),
|
||||
sa.Index("ix_alias_audit_log_alias_email", "alias_email"),
|
||||
sa.Index("ix_alias_audit_log_created_at", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class UserAuditLog(Base, ModelMixin):
|
||||
"""This model holds an audit log for all the actions performed by a user"""
|
||||
|
||||
__tablename__ = "user_audit_log"
|
||||
|
||||
user_id = sa.Column(sa.Integer, nullable=False)
|
||||
user_email = sa.Column(sa.String(255), nullable=False)
|
||||
action = sa.Column(sa.String(255), nullable=False)
|
||||
message = sa.Column(sa.Text, default=None, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sa.Index("ix_user_audit_log_user_id", "user_id"),
|
||||
sa.Index("ix_user_audit_log_user_email", "user_email"),
|
||||
sa.Index("ix_user_audit_log_created_at", "created_at"),
|
||||
)
|
||||
|
|
46
app/partner_user_utils.py
Normal file
46
app/partner_user_utils.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
from typing import Optional
|
||||
|
||||
from arrow import Arrow
|
||||
|
||||
from app.models import PartnerUser, PartnerSubscription, User
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
def create_partner_user(
|
||||
user: User, partner_id: int, partner_email: str, external_user_id: str
|
||||
) -> PartnerUser:
|
||||
instance = PartnerUser.create(
|
||||
user_id=user.id,
|
||||
partner_id=partner_id,
|
||||
partner_email=partner_email,
|
||||
external_user_id=external_user_id,
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.LinkAccount,
|
||||
message=f"Linked account to partner_id={partner_id} | partner_email={partner_email} | external_user_id={external_user_id}",
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
def create_partner_subscription(
|
||||
partner_user: PartnerUser,
|
||||
expiration: Optional[Arrow],
|
||||
msg: Optional[str] = None,
|
||||
) -> PartnerSubscription:
|
||||
instance = PartnerSubscription.create(
|
||||
partner_user_id=partner_user.id,
|
||||
end_at=expiration,
|
||||
)
|
||||
|
||||
message = "User upgraded through partner subscription"
|
||||
if msg:
|
||||
message += f" | {msg}"
|
||||
emit_user_audit_log(
|
||||
user=partner_user.user,
|
||||
action=UserAuditLogAction.Upgrade,
|
||||
message=message,
|
||||
)
|
||||
|
||||
return instance
|
0
app/payments/__init__.py
Normal file
0
app/payments/__init__.py
Normal file
121
app/payments/coinbase.py
Normal file
121
app/payments/coinbase.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
from typing import Optional
|
||||
|
||||
import arrow
|
||||
|
||||
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
|
||||
from coinbase_commerce.webhook import Webhook
|
||||
from flask import Flask, request
|
||||
|
||||
from app.config import COINBASE_WEBHOOK_SECRET
|
||||
from app.db import Session
|
||||
from app.email_utils import send_email, render
|
||||
from app.log import LOG
|
||||
from app.models import CoinbaseSubscription, User
|
||||
from app.subscription_webhook import execute_subscription_webhook
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
def setup_coinbase_commerce(app: Flask):
|
||||
@app.route("/coinbase", methods=["POST"])
|
||||
def coinbase_webhook():
|
||||
# event payload
|
||||
request_data = request.data.decode("utf-8")
|
||||
# webhook signature
|
||||
request_sig = request.headers.get("X-CC-Webhook-Signature", None)
|
||||
|
||||
try:
|
||||
# signature verification and event object construction
|
||||
event = Webhook.construct_event(
|
||||
request_data, request_sig, COINBASE_WEBHOOK_SECRET
|
||||
)
|
||||
except (WebhookInvalidPayload, SignatureVerificationError) as e:
|
||||
LOG.e("Invalid Coinbase webhook")
|
||||
return str(e), 400
|
||||
|
||||
LOG.d("Coinbase event %s", event)
|
||||
|
||||
if event["type"] == "charge:confirmed":
|
||||
if handle_coinbase_event(event):
|
||||
return "success", 200
|
||||
else:
|
||||
return "error", 400
|
||||
|
||||
return "success", 200
|
||||
|
||||
|
||||
def handle_coinbase_event(event) -> bool:
|
||||
server_user_id = event["data"]["metadata"]["user_id"]
|
||||
try:
|
||||
user_id = int(server_user_id)
|
||||
except ValueError:
|
||||
user_id = int(float(server_user_id))
|
||||
|
||||
code = event["data"]["code"]
|
||||
user: Optional[User] = User.get(user_id)
|
||||
if not user:
|
||||
LOG.e("User not found %s", user_id)
|
||||
return False
|
||||
|
||||
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if not coinbase_subscription:
|
||||
LOG.d("Create a coinbase subscription for %s", user)
|
||||
coinbase_subscription = CoinbaseSubscription.create(
|
||||
user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.Upgrade,
|
||||
message="Upgraded though Coinbase",
|
||||
commit=True,
|
||||
)
|
||||
send_email(
|
||||
user.email,
|
||||
"Your SimpleLogin account has been upgraded",
|
||||
render(
|
||||
"transactional/coinbase/new-subscription.txt",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
render(
|
||||
"transactional/coinbase/new-subscription.html",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
)
|
||||
else:
|
||||
if coinbase_subscription.code != code:
|
||||
LOG.d("Update code from %s to %s", coinbase_subscription.code, code)
|
||||
coinbase_subscription.code = code
|
||||
|
||||
if coinbase_subscription.is_active():
|
||||
coinbase_subscription.end_at = coinbase_subscription.end_at.shift(years=1)
|
||||
else: # already expired subscription
|
||||
coinbase_subscription.end_at = arrow.now().shift(years=1)
|
||||
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.SubscriptionExtended,
|
||||
message="Extended coinbase subscription",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
send_email(
|
||||
user.email,
|
||||
"Your SimpleLogin account has been extended",
|
||||
render(
|
||||
"transactional/coinbase/extend-subscription.txt",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
render(
|
||||
"transactional/coinbase/extend-subscription.html",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
)
|
||||
execute_subscription_webhook(user)
|
||||
|
||||
return True
|
286
app/payments/paddle.py
Normal file
286
app/payments/paddle.py
Normal file
|
@ -0,0 +1,286 @@
|
|||
import arrow
|
||||
import json
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
from flask import Flask, request
|
||||
|
||||
from app import paddle_utils, paddle_callback
|
||||
from app.config import (
|
||||
PADDLE_MONTHLY_PRODUCT_ID,
|
||||
PADDLE_MONTHLY_PRODUCT_IDS,
|
||||
PADDLE_YEARLY_PRODUCT_IDS,
|
||||
PADDLE_COUPON_ID,
|
||||
)
|
||||
from app.db import Session
|
||||
from app.email_utils import send_email, render
|
||||
from app.log import LOG
|
||||
from app.models import Subscription, PlanEnum, User, Coupon
|
||||
from app.subscription_webhook import execute_subscription_webhook
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import random_string
|
||||
|
||||
|
||||
def setup_paddle_callback(app: Flask):
|
||||
@app.route("/paddle", methods=["GET", "POST"])
|
||||
def paddle():
|
||||
LOG.d(f"paddle callback {request.form.get('alert_name')} {request.form}")
|
||||
|
||||
# make sure the request comes from Paddle
|
||||
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
||||
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
|
||||
return "KO", 400
|
||||
|
||||
if (
|
||||
request.form.get("alert_name") == "subscription_created"
|
||||
): # new user subscribes
|
||||
# the passthrough is json encoded, e.g.
|
||||
# request.form.get("passthrough") = '{"user_id": 88 }'
|
||||
passthrough = json.loads(request.form.get("passthrough"))
|
||||
user_id = passthrough.get("user_id")
|
||||
user = User.get(user_id)
|
||||
|
||||
subscription_plan_id = int(request.form.get("subscription_plan_id"))
|
||||
|
||||
if subscription_plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
|
||||
plan = PlanEnum.monthly
|
||||
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
|
||||
plan = PlanEnum.yearly
|
||||
else:
|
||||
LOG.e(
|
||||
"Unknown subscription_plan_id %s %s",
|
||||
subscription_plan_id,
|
||||
request.form,
|
||||
)
|
||||
return "No such subscription", 400
|
||||
|
||||
sub = Subscription.get_by(user_id=user.id)
|
||||
|
||||
if not sub:
|
||||
LOG.d(f"create a new Subscription for user {user}")
|
||||
Subscription.create(
|
||||
user_id=user.id,
|
||||
cancel_url=request.form.get("cancel_url"),
|
||||
update_url=request.form.get("update_url"),
|
||||
subscription_id=request.form.get("subscription_id"),
|
||||
event_time=arrow.now(),
|
||||
next_bill_date=arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date(),
|
||||
plan=plan,
|
||||
)
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.Upgrade,
|
||||
message="Upgraded through Paddle",
|
||||
)
|
||||
else:
|
||||
LOG.d(f"Update an existing Subscription for user {user}")
|
||||
sub.cancel_url = request.form.get("cancel_url")
|
||||
sub.update_url = request.form.get("update_url")
|
||||
sub.subscription_id = request.form.get("subscription_id")
|
||||
sub.event_time = arrow.now()
|
||||
sub.next_bill_date = arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date()
|
||||
sub.plan = plan
|
||||
|
||||
# make sure to set the new plan as not-cancelled
|
||||
# in case user cancels a plan and subscribes a new plan
|
||||
sub.cancelled = False
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.SubscriptionExtended,
|
||||
message="Extended Paddle subscription",
|
||||
)
|
||||
|
||||
execute_subscription_webhook(user)
|
||||
LOG.d("User %s upgrades!", user)
|
||||
|
||||
Session.commit()
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_payment_succeeded":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
LOG.d("Update subscription %s", subscription_id)
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
|
||||
# at that time, subscription object does not exist yet
|
||||
if sub:
|
||||
sub.event_time = arrow.now()
|
||||
sub.next_bill_date = arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date()
|
||||
|
||||
Session.commit()
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_cancelled":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
if sub:
|
||||
# cancellation_effective_date should be the same as next_bill_date
|
||||
LOG.w(
|
||||
"Cancel subscription %s %s on %s, next bill date %s",
|
||||
subscription_id,
|
||||
sub.user,
|
||||
request.form.get("cancellation_effective_date"),
|
||||
sub.next_bill_date,
|
||||
)
|
||||
sub.event_time = arrow.now()
|
||||
|
||||
sub.cancelled = True
|
||||
emit_user_audit_log(
|
||||
user=sub.user,
|
||||
action=UserAuditLogAction.SubscriptionCancelled,
|
||||
message="Cancelled Paddle subscription",
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
user = sub.user
|
||||
|
||||
send_email(
|
||||
user.email,
|
||||
"SimpleLogin - your subscription is canceled",
|
||||
render(
|
||||
"transactional/subscription-cancel.txt",
|
||||
user=user,
|
||||
end_date=request.form.get("cancellation_effective_date"),
|
||||
),
|
||||
)
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
else:
|
||||
# user might have deleted their account
|
||||
LOG.i(f"Cancel non-exist subscription {subscription_id}")
|
||||
return "OK"
|
||||
elif request.form.get("alert_name") == "subscription_updated":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
if sub:
|
||||
next_bill_date = request.form.get("next_bill_date")
|
||||
if not next_bill_date:
|
||||
paddle_callback.failed_payment(sub, subscription_id)
|
||||
return "OK"
|
||||
|
||||
LOG.d(
|
||||
"Update subscription %s %s on %s, next bill date %s",
|
||||
subscription_id,
|
||||
sub.user,
|
||||
request.form.get("cancellation_effective_date"),
|
||||
sub.next_bill_date,
|
||||
)
|
||||
if (
|
||||
int(request.form.get("subscription_plan_id"))
|
||||
== PADDLE_MONTHLY_PRODUCT_ID
|
||||
):
|
||||
plan = PlanEnum.monthly
|
||||
else:
|
||||
plan = PlanEnum.yearly
|
||||
|
||||
sub.cancel_url = request.form.get("cancel_url")
|
||||
sub.update_url = request.form.get("update_url")
|
||||
sub.event_time = arrow.now()
|
||||
sub.next_bill_date = arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date()
|
||||
sub.plan = plan
|
||||
|
||||
# make sure to set the new plan as not-cancelled
|
||||
sub.cancelled = False
|
||||
emit_user_audit_log(
|
||||
user=sub.user,
|
||||
action=UserAuditLogAction.SubscriptionExtended,
|
||||
message="Extended Paddle subscription",
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
execute_subscription_webhook(sub.user)
|
||||
else:
|
||||
LOG.w(
|
||||
f"update non-exist subscription {subscription_id}. {request.form}"
|
||||
)
|
||||
return "No such subscription", 400
|
||||
elif request.form.get("alert_name") == "payment_refunded":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
LOG.d("Refund request for subscription %s", subscription_id)
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
|
||||
if sub:
|
||||
user = sub.user
|
||||
Subscription.delete(sub.id)
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.SubscriptionCancelled,
|
||||
message="Paddle subscription cancelled as user requested a refund",
|
||||
)
|
||||
Session.commit()
|
||||
LOG.e("%s requests a refund", user)
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_payment_refunded":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
LOG.d(
|
||||
"Handle subscription_payment_refunded for subscription %s",
|
||||
subscription_id,
|
||||
)
|
||||
|
||||
if not sub:
|
||||
LOG.w(
|
||||
"No such subscription for %s, payload %s",
|
||||
subscription_id,
|
||||
request.form,
|
||||
)
|
||||
return "No such subscription"
|
||||
|
||||
plan_id = int(request.form["subscription_plan_id"])
|
||||
if request.form["refund_type"] == "full":
|
||||
if plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
|
||||
LOG.d("subtract 1 month from next_bill_date %s", sub.next_bill_date)
|
||||
sub.next_bill_date = sub.next_bill_date - relativedelta(months=1)
|
||||
LOG.d("next_bill_date is %s", sub.next_bill_date)
|
||||
Session.commit()
|
||||
elif plan_id in PADDLE_YEARLY_PRODUCT_IDS:
|
||||
LOG.d("subtract 1 year from next_bill_date %s", sub.next_bill_date)
|
||||
sub.next_bill_date = sub.next_bill_date - relativedelta(years=1)
|
||||
LOG.d("next_bill_date is %s", sub.next_bill_date)
|
||||
Session.commit()
|
||||
else:
|
||||
LOG.e("Unknown plan_id %s", plan_id)
|
||||
else:
|
||||
LOG.w("partial subscription_payment_refunded, not handled")
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
return "OK"
|
||||
|
||||
@app.route("/paddle_coupon", methods=["GET", "POST"])
|
||||
def paddle_coupon():
|
||||
LOG.d("paddle coupon callback %s", request.form)
|
||||
|
||||
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
||||
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
|
||||
return "KO", 400
|
||||
|
||||
product_id = request.form.get("p_product_id")
|
||||
if product_id != PADDLE_COUPON_ID:
|
||||
LOG.e("product_id %s not match with %s", product_id, PADDLE_COUPON_ID)
|
||||
return "KO", 400
|
||||
|
||||
email = request.form.get("email")
|
||||
LOG.d("Paddle coupon request for %s", email)
|
||||
|
||||
coupon = Coupon.create(
|
||||
code=random_string(30),
|
||||
comment="For 1-year coupon",
|
||||
expires_date=arrow.now().shift(years=1, days=-1),
|
||||
commit=True,
|
||||
)
|
||||
|
||||
return (
|
||||
f"Your 1-year coupon is <b>{coupon.code}</b> <br> "
|
||||
f"It's valid until <b>{coupon.expires_date.date().isoformat()}</b>"
|
||||
)
|
|
@ -5,6 +5,7 @@ from app.db import Session
|
|||
from app.log import LOG
|
||||
from app.errors import ProtonPartnerNotSetUp
|
||||
from app.models import Partner, PartnerUser, User
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
PROTON_PARTNER_NAME = "Proton"
|
||||
_PROTON_PARTNER: Optional[Partner] = None
|
||||
|
@ -32,6 +33,11 @@ def perform_proton_account_unlink(current_user: User):
|
|||
)
|
||||
if partner_user is not None:
|
||||
LOG.info(f"User {current_user} has unlinked the account from {partner_user}")
|
||||
emit_user_audit_log(
|
||||
user=current_user,
|
||||
action=UserAuditLogAction.UnlinkAccount,
|
||||
message=f"User has unlinked the account (email={partner_user.partner_email} | external_user_id={partner_user.external_user_id})",
|
||||
)
|
||||
PartnerUser.delete(partner_user.id)
|
||||
Session.commit()
|
||||
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})
|
||||
|
|
44
app/user_audit_log_utils.py
Normal file
44
app/user_audit_log_utils.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
from enum import Enum
|
||||
|
||||
from app.models import User, UserAuditLog
|
||||
|
||||
|
||||
class UserAuditLogAction(Enum):
|
||||
Upgrade = "upgrade"
|
||||
SubscriptionExtended = "subscription_extended"
|
||||
SubscriptionCancelled = "subscription_cancelled"
|
||||
LinkAccount = "link_account"
|
||||
UnlinkAccount = "unlink_account"
|
||||
|
||||
CreateMailbox = "create_mailbox"
|
||||
VerifyMailbox = "verify_mailbox"
|
||||
UpdateMailbox = "update_mailbox"
|
||||
DeleteMailbox = "delete_mailbox"
|
||||
|
||||
CreateCustomDomain = "create_custom_domain"
|
||||
VerifyCustomDomain = "verify_custom_domain"
|
||||
UpdateCustomDomain = "update_custom_domain"
|
||||
DeleteCustomDomain = "delete_custom_domain"
|
||||
|
||||
CreateContact = "create_contact"
|
||||
UpdateContact = "update_contact"
|
||||
DeleteContact = "delete_contact"
|
||||
|
||||
CreateDirectory = "create_directory"
|
||||
UpdateDirectory = "update_directory"
|
||||
DeleteDirectory = "delete_directory"
|
||||
|
||||
UserMarkedForDeletion = "user_marked_for_deletion"
|
||||
DeleteUser = "delete_user"
|
||||
|
||||
|
||||
def emit_user_audit_log(
|
||||
user: User, action: UserAuditLogAction, message: str, commit: bool = False
|
||||
):
|
||||
UserAuditLog.create(
|
||||
user_id=user.id,
|
||||
user_email=user.email,
|
||||
action=action.value,
|
||||
message=message,
|
||||
commit=commit,
|
||||
)
|
|
@ -3,6 +3,7 @@ from typing import Optional
|
|||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import User, SLDomain, CustomDomain, Mailbox
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
|
||||
|
||||
class CannotSetAlias(Exception):
|
||||
|
@ -54,7 +55,7 @@ def set_default_alias_domain(user: User, domain_name: Optional[str]):
|
|||
|
||||
|
||||
def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox:
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
|
||||
|
||||
if not mailbox or mailbox.user_id != user.id:
|
||||
raise CannotSetMailbox("Invalid mailbox")
|
||||
|
@ -67,5 +68,11 @@ def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox:
|
|||
LOG.i(f"User {user} has set mailbox {mailbox} as his default one")
|
||||
|
||||
user.default_mailbox_id = mailbox.id
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.UpdateMailbox,
|
||||
message=f"Set mailbox {mailbox.id} ({mailbox.email}) as default",
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
return mailbox
|
||||
|
|
35
cron.py
35
cron.py
|
@ -60,9 +60,11 @@ from app.models import (
|
|||
)
|
||||
from app.pgp_utils import load_public_key_and_check, PGPException
|
||||
from app.proton.utils import get_proton_partner
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.utils import sanitize_email
|
||||
from server import create_light_app
|
||||
from tasks.clean_alias_audit_log import cleanup_alias_audit_log
|
||||
from tasks.clean_user_audit_log import cleanup_user_audit_log
|
||||
from tasks.cleanup_old_imports import cleanup_old_imports
|
||||
from tasks.cleanup_old_jobs import cleanup_old_jobs
|
||||
from tasks.cleanup_old_notifications import cleanup_old_notifications
|
||||
|
@ -1219,7 +1221,7 @@ def notify_hibp():
|
|||
|
||||
|
||||
def clear_users_scheduled_to_be_deleted(dry_run=False):
|
||||
users = User.filter(
|
||||
users: List[User] = User.filter(
|
||||
and_(
|
||||
User.delete_on.isnot(None),
|
||||
User.delete_on <= arrow.now().shift(days=-DELETE_GRACE_DAYS),
|
||||
|
@ -1231,6 +1233,11 @@ def clear_users_scheduled_to_be_deleted(dry_run=False):
|
|||
)
|
||||
if dry_run:
|
||||
continue
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.DeleteUser,
|
||||
message=f"Delete user {user.id} ({user.email})",
|
||||
)
|
||||
User.delete(user.id)
|
||||
Session.commit()
|
||||
|
||||
|
@ -1247,6 +1254,11 @@ def clear_alias_audit_log():
|
|||
cleanup_alias_audit_log(oldest_valid)
|
||||
|
||||
|
||||
def clear_user_audit_log():
|
||||
oldest_valid = arrow.now().shift(days=-config.AUDIT_LOG_MAX_DAYS)
|
||||
cleanup_user_audit_log(oldest_valid)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
LOG.d("Start running cronjob")
|
||||
parser = argparse.ArgumentParser()
|
||||
|
@ -1255,22 +1267,6 @@ if __name__ == "__main__":
|
|||
"--job",
|
||||
help="Choose a cron job to run",
|
||||
type=str,
|
||||
choices=[
|
||||
"stats",
|
||||
"notify_trial_end",
|
||||
"notify_manual_subscription_end",
|
||||
"notify_premium_end",
|
||||
"delete_logs",
|
||||
"delete_old_data",
|
||||
"poll_apple_subscription",
|
||||
"sanity_check",
|
||||
"delete_old_monitoring",
|
||||
"check_custom_domain",
|
||||
"check_hibp",
|
||||
"notify_hibp",
|
||||
"cleanup_tokens",
|
||||
"send_undelivered_mails",
|
||||
],
|
||||
)
|
||||
args = parser.parse_args()
|
||||
# wrap in an app context to benefit from app setup like database cleanup, sentry integration, etc
|
||||
|
@ -1319,7 +1315,10 @@ if __name__ == "__main__":
|
|||
load_unsent_mails_from_fs_and_resend()
|
||||
elif args.job == "delete_scheduled_users":
|
||||
LOG.d("Deleting users scheduled to be deleted")
|
||||
clear_users_scheduled_to_be_deleted(dry_run=True)
|
||||
clear_users_scheduled_to_be_deleted()
|
||||
elif args.job == "clear_alias_audit_log":
|
||||
LOG.d("Clearing alias audit log")
|
||||
clear_alias_audit_log()
|
||||
elif args.job == "clear_user_audit_log":
|
||||
LOG.d("Clearing user audit log")
|
||||
clear_user_audit_log()
|
||||
|
|
|
@ -87,3 +87,10 @@ jobs:
|
|||
schedule: "0 * * * *" # Once every hour
|
||||
captureStderr: true
|
||||
concurrencyPolicy: Forbid
|
||||
|
||||
- name: SimpleLogin clear user_audit_log old entries
|
||||
command: python /code/cron.py -j clear_user_audit_log
|
||||
shell: /bin/bash
|
||||
schedule: "0 * * * *" # Once every hour
|
||||
captureStderr: true
|
||||
concurrencyPolicy: Forbid
|
||||
|
|
|
@ -20,6 +20,7 @@ from app.jobs.event_jobs import send_alias_creation_events_for_user
|
|||
from app.jobs.export_user_data_job import ExportUserDataJob
|
||||
from app.log import LOG
|
||||
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from server import create_light_app
|
||||
|
||||
|
||||
|
@ -128,7 +129,7 @@ def welcome_proton(user):
|
|||
|
||||
def delete_mailbox_job(job: Job):
|
||||
mailbox_id = job.payload.get("mailbox_id")
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
|
||||
if not mailbox:
|
||||
return
|
||||
|
||||
|
@ -152,6 +153,12 @@ def delete_mailbox_job(job: Job):
|
|||
|
||||
mailbox_email = mailbox.email
|
||||
user = mailbox.user
|
||||
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.DeleteMailbox,
|
||||
message=f"Delete mailbox {mailbox.id} ({mailbox.email})",
|
||||
)
|
||||
Mailbox.delete(mailbox_id)
|
||||
Session.commit()
|
||||
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
|
||||
|
@ -244,6 +251,7 @@ def process_job(job: Job):
|
|||
if not custom_domain:
|
||||
return
|
||||
|
||||
is_subdomain = custom_domain.is_sl_subdomain
|
||||
domain_name = custom_domain.domain
|
||||
user = custom_domain.user
|
||||
|
||||
|
@ -251,6 +259,16 @@ def process_job(job: Job):
|
|||
CustomDomain.delete(custom_domain.id)
|
||||
Session.commit()
|
||||
|
||||
if is_subdomain:
|
||||
message = f"Delete subdomain {custom_domain_id} ({domain_name})"
|
||||
else:
|
||||
message = f"Delete custom domain {custom_domain_id} ({domain_name})"
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=UserAuditLogAction.DeleteCustomDomain,
|
||||
message=message,
|
||||
)
|
||||
|
||||
LOG.d("Domain %s deleted", domain_name)
|
||||
|
||||
if custom_domain_partner_id is None:
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
"""user_audit_log
|
||||
|
||||
Revision ID: 7d7b84779837
|
||||
Revises: 91ed7f46dc81
|
||||
Create Date: 2024-10-16 11:52:49.128644
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7d7b84779837'
|
||||
down_revision = '91ed7f46dc81'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('user_audit_log',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_email', sa.String(length=255), nullable=False),
|
||||
sa.Column('action', sa.String(length=255), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_user_audit_log_user_email', 'user_audit_log', ['user_email'], unique=False)
|
||||
op.create_index('ix_user_audit_log_user_id', 'user_audit_log', ['user_id'], unique=False)
|
||||
op.create_index('ix_user_audit_log_created_at', 'user_audit_log', ['created_at'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_user_audit_log_user_id', table_name='user_audit_log')
|
||||
op.drop_index('ix_user_audit_log_user_email', table_name='user_audit_log')
|
||||
op.drop_index('ix_user_audit_log_created_at', table_name='user_audit_log')
|
||||
op.drop_table('user_audit_log')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,27 @@
|
|||
"""alias_audit_log_index_created_at
|
||||
|
||||
Revision ID: 32f25cbf12f6
|
||||
Revises: 7d7b84779837
|
||||
Create Date: 2024-10-16 16:45:36.827161
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '32f25cbf12f6'
|
||||
down_revision = '7d7b84779837'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.get_context().autocommit_block():
|
||||
op.create_index('ix_alias_audit_log_created_at', 'alias_audit_log', ['created_at'], unique=False, postgresql_concurrently=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.get_context().autocommit_block():
|
||||
op.drop_index('ix_alias_audit_log_created_at', table_name='alias_audit_log', postgresql_concurrently=True)
|
355
server.py
355
server.py
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
@ -9,9 +8,7 @@ import flask_limiter
|
|||
import flask_profiler
|
||||
import newrelic.agent
|
||||
import sentry_sdk
|
||||
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
|
||||
from coinbase_commerce.webhook import Webhook
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
redirect,
|
||||
|
@ -30,7 +27,7 @@ from sentry_sdk.integrations.flask import FlaskIntegration
|
|||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from app import paddle_utils, config, paddle_callback, constants
|
||||
from app import config, constants
|
||||
from app.admin_model import (
|
||||
SLAdminIndexView,
|
||||
UserAdmin,
|
||||
|
@ -56,7 +53,6 @@ from app.config import (
|
|||
FLASK_SECRET,
|
||||
SENTRY_DSN,
|
||||
URL,
|
||||
PADDLE_MONTHLY_PRODUCT_ID,
|
||||
FLASK_PROFILER_PATH,
|
||||
FLASK_PROFILER_PASSWORD,
|
||||
SENTRY_FRONT_END_DSN,
|
||||
|
@ -70,22 +66,16 @@ from app.config import (
|
|||
LANDING_PAGE_URL,
|
||||
STATUS_PAGE_URL,
|
||||
SUPPORT_EMAIL,
|
||||
PADDLE_MONTHLY_PRODUCT_IDS,
|
||||
PADDLE_YEARLY_PRODUCT_IDS,
|
||||
PGP_SIGNER,
|
||||
COINBASE_WEBHOOK_SECRET,
|
||||
PAGE_LIMIT,
|
||||
PADDLE_COUPON_ID,
|
||||
ZENDESK_ENABLED,
|
||||
MAX_NB_EMAIL_FREE_PLAN,
|
||||
MEM_STORE_URI,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.subscription_webhook import execute_subscription_webhook
|
||||
from app.db import Session
|
||||
from app.developer.base import developer_bp
|
||||
from app.discover.base import discover_bp
|
||||
from app.email_utils import send_email, render
|
||||
from app.extensions import login_manager, limiter
|
||||
from app.fake_data import fake_data
|
||||
from app.internal.base import internal_bp
|
||||
|
@ -94,11 +84,8 @@ from app.log import LOG
|
|||
from app.models import (
|
||||
User,
|
||||
Alias,
|
||||
Subscription,
|
||||
PlanEnum,
|
||||
CustomDomain,
|
||||
Mailbox,
|
||||
CoinbaseSubscription,
|
||||
EmailLog,
|
||||
Contact,
|
||||
ManualSubscription,
|
||||
|
@ -115,10 +102,11 @@ from app.monitor.base import monitor_bp
|
|||
from app.newsletter_utils import send_newsletter_to_user
|
||||
from app.oauth.base import oauth_bp
|
||||
from app.onboarding.base import onboarding_bp
|
||||
from app.payments.coinbase import setup_coinbase_commerce
|
||||
from app.payments.paddle import setup_paddle_callback
|
||||
from app.phone.base import phone_bp
|
||||
from app.redis_services import initialize_redis_services
|
||||
from app.sentry_utils import sentry_before_send
|
||||
from app.utils import random_string
|
||||
|
||||
if SENTRY_DSN:
|
||||
LOG.d("enable sentry")
|
||||
|
@ -446,341 +434,6 @@ def jinja2_filter(app):
|
|||
)
|
||||
|
||||
|
||||
def setup_paddle_callback(app: Flask):
|
||||
@app.route("/paddle", methods=["GET", "POST"])
|
||||
def paddle():
|
||||
LOG.d(f"paddle callback {request.form.get('alert_name')} {request.form}")
|
||||
|
||||
# make sure the request comes from Paddle
|
||||
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
||||
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
|
||||
return "KO", 400
|
||||
|
||||
if (
|
||||
request.form.get("alert_name") == "subscription_created"
|
||||
): # new user subscribes
|
||||
# the passthrough is json encoded, e.g.
|
||||
# request.form.get("passthrough") = '{"user_id": 88 }'
|
||||
passthrough = json.loads(request.form.get("passthrough"))
|
||||
user_id = passthrough.get("user_id")
|
||||
user = User.get(user_id)
|
||||
|
||||
subscription_plan_id = int(request.form.get("subscription_plan_id"))
|
||||
|
||||
if subscription_plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
|
||||
plan = PlanEnum.monthly
|
||||
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
|
||||
plan = PlanEnum.yearly
|
||||
else:
|
||||
LOG.e(
|
||||
"Unknown subscription_plan_id %s %s",
|
||||
subscription_plan_id,
|
||||
request.form,
|
||||
)
|
||||
return "No such subscription", 400
|
||||
|
||||
sub = Subscription.get_by(user_id=user.id)
|
||||
|
||||
if not sub:
|
||||
LOG.d(f"create a new Subscription for user {user}")
|
||||
Subscription.create(
|
||||
user_id=user.id,
|
||||
cancel_url=request.form.get("cancel_url"),
|
||||
update_url=request.form.get("update_url"),
|
||||
subscription_id=request.form.get("subscription_id"),
|
||||
event_time=arrow.now(),
|
||||
next_bill_date=arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date(),
|
||||
plan=plan,
|
||||
)
|
||||
else:
|
||||
LOG.d(f"Update an existing Subscription for user {user}")
|
||||
sub.cancel_url = request.form.get("cancel_url")
|
||||
sub.update_url = request.form.get("update_url")
|
||||
sub.subscription_id = request.form.get("subscription_id")
|
||||
sub.event_time = arrow.now()
|
||||
sub.next_bill_date = arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date()
|
||||
sub.plan = plan
|
||||
|
||||
# make sure to set the new plan as not-cancelled
|
||||
# in case user cancels a plan and subscribes a new plan
|
||||
sub.cancelled = False
|
||||
|
||||
execute_subscription_webhook(user)
|
||||
LOG.d("User %s upgrades!", user)
|
||||
|
||||
Session.commit()
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_payment_succeeded":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
LOG.d("Update subscription %s", subscription_id)
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
|
||||
# at that time, subscription object does not exist yet
|
||||
if sub:
|
||||
sub.event_time = arrow.now()
|
||||
sub.next_bill_date = arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date()
|
||||
|
||||
Session.commit()
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_cancelled":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
if sub:
|
||||
# cancellation_effective_date should be the same as next_bill_date
|
||||
LOG.w(
|
||||
"Cancel subscription %s %s on %s, next bill date %s",
|
||||
subscription_id,
|
||||
sub.user,
|
||||
request.form.get("cancellation_effective_date"),
|
||||
sub.next_bill_date,
|
||||
)
|
||||
sub.event_time = arrow.now()
|
||||
|
||||
sub.cancelled = True
|
||||
Session.commit()
|
||||
|
||||
user = sub.user
|
||||
|
||||
send_email(
|
||||
user.email,
|
||||
"SimpleLogin - your subscription is canceled",
|
||||
render(
|
||||
"transactional/subscription-cancel.txt",
|
||||
user=user,
|
||||
end_date=request.form.get("cancellation_effective_date"),
|
||||
),
|
||||
)
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
else:
|
||||
# user might have deleted their account
|
||||
LOG.i(f"Cancel non-exist subscription {subscription_id}")
|
||||
return "OK"
|
||||
elif request.form.get("alert_name") == "subscription_updated":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
if sub:
|
||||
next_bill_date = request.form.get("next_bill_date")
|
||||
if not next_bill_date:
|
||||
paddle_callback.failed_payment(sub, subscription_id)
|
||||
return "OK"
|
||||
|
||||
LOG.d(
|
||||
"Update subscription %s %s on %s, next bill date %s",
|
||||
subscription_id,
|
||||
sub.user,
|
||||
request.form.get("cancellation_effective_date"),
|
||||
sub.next_bill_date,
|
||||
)
|
||||
if (
|
||||
int(request.form.get("subscription_plan_id"))
|
||||
== PADDLE_MONTHLY_PRODUCT_ID
|
||||
):
|
||||
plan = PlanEnum.monthly
|
||||
else:
|
||||
plan = PlanEnum.yearly
|
||||
|
||||
sub.cancel_url = request.form.get("cancel_url")
|
||||
sub.update_url = request.form.get("update_url")
|
||||
sub.event_time = arrow.now()
|
||||
sub.next_bill_date = arrow.get(
|
||||
request.form.get("next_bill_date"), "YYYY-MM-DD"
|
||||
).date()
|
||||
sub.plan = plan
|
||||
|
||||
# make sure to set the new plan as not-cancelled
|
||||
sub.cancelled = False
|
||||
|
||||
Session.commit()
|
||||
execute_subscription_webhook(sub.user)
|
||||
else:
|
||||
LOG.w(
|
||||
f"update non-exist subscription {subscription_id}. {request.form}"
|
||||
)
|
||||
return "No such subscription", 400
|
||||
elif request.form.get("alert_name") == "payment_refunded":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
LOG.d("Refund request for subscription %s", subscription_id)
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
|
||||
if sub:
|
||||
user = sub.user
|
||||
Subscription.delete(sub.id)
|
||||
Session.commit()
|
||||
LOG.e("%s requests a refund", user)
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_payment_refunded":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
LOG.d(
|
||||
"Handle subscription_payment_refunded for subscription %s",
|
||||
subscription_id,
|
||||
)
|
||||
|
||||
if not sub:
|
||||
LOG.w(
|
||||
"No such subscription for %s, payload %s",
|
||||
subscription_id,
|
||||
request.form,
|
||||
)
|
||||
return "No such subscription"
|
||||
|
||||
plan_id = int(request.form["subscription_plan_id"])
|
||||
if request.form["refund_type"] == "full":
|
||||
if plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
|
||||
LOG.d("subtract 1 month from next_bill_date %s", sub.next_bill_date)
|
||||
sub.next_bill_date = sub.next_bill_date - relativedelta(months=1)
|
||||
LOG.d("next_bill_date is %s", sub.next_bill_date)
|
||||
Session.commit()
|
||||
elif plan_id in PADDLE_YEARLY_PRODUCT_IDS:
|
||||
LOG.d("subtract 1 year from next_bill_date %s", sub.next_bill_date)
|
||||
sub.next_bill_date = sub.next_bill_date - relativedelta(years=1)
|
||||
LOG.d("next_bill_date is %s", sub.next_bill_date)
|
||||
Session.commit()
|
||||
else:
|
||||
LOG.e("Unknown plan_id %s", plan_id)
|
||||
else:
|
||||
LOG.w("partial subscription_payment_refunded, not handled")
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
return "OK"
|
||||
|
||||
@app.route("/paddle_coupon", methods=["GET", "POST"])
|
||||
def paddle_coupon():
|
||||
LOG.d("paddle coupon callback %s", request.form)
|
||||
|
||||
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
||||
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
|
||||
return "KO", 400
|
||||
|
||||
product_id = request.form.get("p_product_id")
|
||||
if product_id != PADDLE_COUPON_ID:
|
||||
LOG.e("product_id %s not match with %s", product_id, PADDLE_COUPON_ID)
|
||||
return "KO", 400
|
||||
|
||||
email = request.form.get("email")
|
||||
LOG.d("Paddle coupon request for %s", email)
|
||||
|
||||
coupon = Coupon.create(
|
||||
code=random_string(30),
|
||||
comment="For 1-year coupon",
|
||||
expires_date=arrow.now().shift(years=1, days=-1),
|
||||
commit=True,
|
||||
)
|
||||
|
||||
return (
|
||||
f"Your 1-year coupon is <b>{coupon.code}</b> <br> "
|
||||
f"It's valid until <b>{coupon.expires_date.date().isoformat()}</b>"
|
||||
)
|
||||
|
||||
|
||||
def setup_coinbase_commerce(app):
|
||||
@app.route("/coinbase", methods=["POST"])
|
||||
def coinbase_webhook():
|
||||
# event payload
|
||||
request_data = request.data.decode("utf-8")
|
||||
# webhook signature
|
||||
request_sig = request.headers.get("X-CC-Webhook-Signature", None)
|
||||
|
||||
try:
|
||||
# signature verification and event object construction
|
||||
event = Webhook.construct_event(
|
||||
request_data, request_sig, COINBASE_WEBHOOK_SECRET
|
||||
)
|
||||
except (WebhookInvalidPayload, SignatureVerificationError) as e:
|
||||
LOG.e("Invalid Coinbase webhook")
|
||||
return str(e), 400
|
||||
|
||||
LOG.d("Coinbase event %s", event)
|
||||
|
||||
if event["type"] == "charge:confirmed":
|
||||
if handle_coinbase_event(event):
|
||||
return "success", 200
|
||||
else:
|
||||
return "error", 400
|
||||
|
||||
return "success", 200
|
||||
|
||||
|
||||
def handle_coinbase_event(event) -> bool:
|
||||
server_user_id = event["data"]["metadata"]["user_id"]
|
||||
try:
|
||||
user_id = int(server_user_id)
|
||||
except ValueError:
|
||||
user_id = int(float(server_user_id))
|
||||
|
||||
code = event["data"]["code"]
|
||||
user = User.get(user_id)
|
||||
if not user:
|
||||
LOG.e("User not found %s", user_id)
|
||||
return False
|
||||
|
||||
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if not coinbase_subscription:
|
||||
LOG.d("Create a coinbase subscription for %s", user)
|
||||
coinbase_subscription = CoinbaseSubscription.create(
|
||||
user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True
|
||||
)
|
||||
send_email(
|
||||
user.email,
|
||||
"Your SimpleLogin account has been upgraded",
|
||||
render(
|
||||
"transactional/coinbase/new-subscription.txt",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
render(
|
||||
"transactional/coinbase/new-subscription.html",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
)
|
||||
else:
|
||||
if coinbase_subscription.code != code:
|
||||
LOG.d("Update code from %s to %s", coinbase_subscription.code, code)
|
||||
coinbase_subscription.code = code
|
||||
|
||||
if coinbase_subscription.is_active():
|
||||
coinbase_subscription.end_at = coinbase_subscription.end_at.shift(years=1)
|
||||
else: # already expired subscription
|
||||
coinbase_subscription.end_at = arrow.now().shift(years=1)
|
||||
|
||||
Session.commit()
|
||||
|
||||
send_email(
|
||||
user.email,
|
||||
"Your SimpleLogin account has been extended",
|
||||
render(
|
||||
"transactional/coinbase/extend-subscription.txt",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
render(
|
||||
"transactional/coinbase/extend-subscription.html",
|
||||
user=user,
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
)
|
||||
execute_subscription_webhook(user)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def init_extensions(app: Flask):
|
||||
login_manager.init_app(app)
|
||||
|
||||
|
|
12
tasks/clean_user_audit_log.py
Normal file
12
tasks/clean_user_audit_log.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
import arrow
|
||||
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import UserAuditLog
|
||||
|
||||
|
||||
def cleanup_user_audit_log(oldest_allowed: arrow.Arrow):
|
||||
LOG.i(f"Deleting user_audit_log older than {oldest_allowed}")
|
||||
count = UserAuditLog.filter(UserAuditLog.created_at < oldest_allowed).delete()
|
||||
Session.commit()
|
||||
LOG.i(f"Deleted {count} user_audit_log entries")
|
|
@ -1,3 +1,5 @@
|
|||
from typing import List
|
||||
|
||||
import pytest
|
||||
from arrow import Arrow
|
||||
|
||||
|
@ -16,8 +18,9 @@ from app.account_linking import (
|
|||
)
|
||||
from app.db import Session
|
||||
from app.errors import AccountAlreadyLinkedToAnotherPartnerException
|
||||
from app.models import Partner, PartnerUser, User
|
||||
from app.models import Partner, PartnerUser, User, UserAuditLog
|
||||
from app.proton.utils import get_proton_partner
|
||||
from app.user_audit_log_utils import UserAuditLogAction
|
||||
from app.utils import random_string, canonicalize_email
|
||||
from tests.utils import random_email
|
||||
|
||||
|
@ -91,6 +94,11 @@ def test_login_case_from_partner():
|
|||
)
|
||||
assert res.user.activated is True
|
||||
|
||||
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all()
|
||||
assert len(audit_logs) == 1
|
||||
assert audit_logs[0].user_id == res.user.id
|
||||
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
|
||||
|
||||
|
||||
def test_login_case_from_partner_with_uppercase_email():
|
||||
partner = get_proton_partner()
|
||||
|
@ -125,6 +133,11 @@ def test_login_case_from_web():
|
|||
assert 0 == (res.user.flags & User.FLAG_CREATED_FROM_PARTNER)
|
||||
assert res.user.activated is True
|
||||
|
||||
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all()
|
||||
assert len(audit_logs) == 1
|
||||
assert audit_logs[0].user_id == res.user.id
|
||||
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
|
||||
|
||||
|
||||
def test_get_strategy_existing_sl_user():
|
||||
email = random_email()
|
||||
|
@ -205,6 +218,10 @@ def test_link_account_with_proton_account_same_address(flask_client):
|
|||
)
|
||||
assert partner_user.partner_id == get_proton_partner().id
|
||||
assert partner_user.external_user_id == partner_user_id
|
||||
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all()
|
||||
assert len(audit_logs) == 1
|
||||
assert audit_logs[0].user_id == res.user.id
|
||||
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
|
||||
|
||||
|
||||
def test_link_account_with_proton_account_different_address(flask_client):
|
||||
|
@ -229,6 +246,11 @@ def test_link_account_with_proton_account_different_address(flask_client):
|
|||
assert partner_user.partner_id == get_proton_partner().id
|
||||
assert partner_user.external_user_id == partner_user_id
|
||||
|
||||
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all()
|
||||
assert len(audit_logs) == 1
|
||||
assert audit_logs[0].user_id == res.user.id
|
||||
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
|
||||
|
||||
|
||||
def test_link_account_with_proton_account_same_address_but_linked_to_other_user(
|
||||
flask_client,
|
||||
|
@ -248,22 +270,54 @@ def test_link_account_with_proton_account_same_address_but_linked_to_other_user(
|
|||
partner_user_id, email=random_email()
|
||||
) # User already linked with the proton account
|
||||
|
||||
# START Ensure sl_user_2 has a partner_user with the right data
|
||||
partner_user = PartnerUser.get_by(
|
||||
partner_id=get_proton_partner().id, user_id=sl_user_2.id
|
||||
)
|
||||
assert partner_user is not None
|
||||
assert partner_user.partner_id == get_proton_partner().id
|
||||
assert partner_user.external_user_id == partner_user_id
|
||||
assert partner_user.partner_email == sl_user_2.email
|
||||
assert partner_user.user_id == sl_user_2.id
|
||||
# END Ensure sl_user_2 has a partner_user with the right data
|
||||
|
||||
# Proceed to link sl_user_1
|
||||
res = process_link_case(link_request, sl_user_1, get_proton_partner())
|
||||
|
||||
# Check that the result is linking sl_user_1
|
||||
assert res.user.id == sl_user_1.id
|
||||
assert res.user.email == partner_email
|
||||
assert res.strategy == "Link"
|
||||
|
||||
# Ensure partner_user for sl_user_1 exists
|
||||
partner_user = PartnerUser.get_by(
|
||||
partner_id=get_proton_partner().id, user_id=sl_user_1.id
|
||||
)
|
||||
assert partner_user.partner_id == get_proton_partner().id
|
||||
assert partner_user.external_user_id == partner_user_id
|
||||
|
||||
# Ensure partner_user for sl_user_2 does not exist anymore
|
||||
partner_user = PartnerUser.get_by(
|
||||
partner_id=get_proton_partner().id, user_id=sl_user_2.id
|
||||
)
|
||||
assert partner_user is None
|
||||
|
||||
# Ensure audit logs for sl_user_1 show the link action
|
||||
sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
|
||||
user_id=sl_user_1.id
|
||||
).all()
|
||||
assert len(sl_user_1_audit_logs) == 1
|
||||
assert sl_user_1_audit_logs[0].user_id == sl_user_1.id
|
||||
assert sl_user_1_audit_logs[0].action == UserAuditLogAction.LinkAccount.value
|
||||
|
||||
# Ensure audit logs for sl_user_2 show the unlink action
|
||||
sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
|
||||
user_id=sl_user_2.id
|
||||
).all()
|
||||
assert len(sl_user_2_audit_logs) == 1
|
||||
assert sl_user_2_audit_logs[0].user_id == sl_user_2.id
|
||||
assert sl_user_2_audit_logs[0].action == UserAuditLogAction.UnlinkAccount.value
|
||||
|
||||
|
||||
def test_link_account_with_proton_account_different_address_and_linked_to_other_user(
|
||||
flask_client,
|
||||
|
@ -300,6 +354,22 @@ def test_link_account_with_proton_account_different_address_and_linked_to_other_
|
|||
)
|
||||
assert partner_user_2 is None
|
||||
|
||||
# Ensure audit logs for sl_user_1 show the link action
|
||||
sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
|
||||
user_id=sl_user_1.id
|
||||
).all()
|
||||
assert len(sl_user_1_audit_logs) == 1
|
||||
assert sl_user_1_audit_logs[0].user_id == sl_user_1.id
|
||||
assert sl_user_1_audit_logs[0].action == UserAuditLogAction.LinkAccount.value
|
||||
|
||||
# Ensure audit logs for sl_user_2 show the unlink action
|
||||
sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
|
||||
user_id=sl_user_2.id
|
||||
).all()
|
||||
assert len(sl_user_2_audit_logs) == 1
|
||||
assert sl_user_2_audit_logs[0].user_id == sl_user_2.id
|
||||
assert sl_user_2_audit_logs[0].action == UserAuditLogAction.UnlinkAccount.value
|
||||
|
||||
|
||||
def test_cannot_create_instance_of_base_strategy():
|
||||
with pytest.raises(Exception):
|
||||
|
|
|
@ -218,7 +218,11 @@ def test_delete_with_transfer():
|
|||
user, random_email(), use_digit_codes=True, send_link=False
|
||||
).mailbox
|
||||
transfer_mailbox = mailbox_utils.create_mailbox(
|
||||
user, random_email(), use_digit_codes=True, send_link=False
|
||||
user,
|
||||
random_email(),
|
||||
use_digit_codes=True,
|
||||
send_link=False,
|
||||
verified=True,
|
||||
).mailbox
|
||||
mailbox_utils.delete_mailbox(
|
||||
user, mailbox.id, transfer_mailbox_id=transfer_mailbox.id
|
||||
|
@ -236,6 +240,28 @@ def test_delete_with_transfer():
|
|||
assert job.payload["transfer_mailbox_id"] is None
|
||||
|
||||
|
||||
def test_cannot_delete_with_transfer_to_unverified_mailbox():
|
||||
mailbox = mailbox_utils.create_mailbox(
|
||||
user, random_email(), use_digit_codes=True, send_link=False
|
||||
).mailbox
|
||||
transfer_mailbox = mailbox_utils.create_mailbox(
|
||||
user,
|
||||
random_email(),
|
||||
use_digit_codes=True,
|
||||
send_link=False,
|
||||
verified=False,
|
||||
).mailbox
|
||||
|
||||
with pytest.raises(mailbox_utils.MailboxError):
|
||||
mailbox_utils.delete_mailbox(
|
||||
user, mailbox.id, transfer_mailbox_id=transfer_mailbox.id
|
||||
)
|
||||
|
||||
# Verify mailbox still exists
|
||||
db_mailbox = Mailbox.get_by(id=mailbox.id)
|
||||
assert db_mailbox is not None
|
||||
|
||||
|
||||
def test_verify_non_existing_mailbox():
|
||||
with pytest.raises(mailbox_utils.MailboxError):
|
||||
mailbox_utils.verify_mailbox_code(user, 999999999, "9999999")
|
||||
|
|
|
@ -2,7 +2,7 @@ import arrow
|
|||
|
||||
from app.db import Session
|
||||
from app.models import CoinbaseSubscription
|
||||
from server import handle_coinbase_event
|
||||
from app.payments.coinbase import handle_coinbase_event
|
||||
from tests.utils import create_new_user
|
||||
|
||||
|
||||
|
|
52
tests/test_user_audit_log_utils.py
Normal file
52
tests/test_user_audit_log_utils.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
from typing import List
|
||||
|
||||
from app import config, mailbox_utils
|
||||
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
|
||||
from app.models import UserAuditLog
|
||||
from app.utils import random_string
|
||||
from tests.utils import create_new_user, random_email
|
||||
|
||||
|
||||
def setup_module():
|
||||
config.SKIP_MX_LOOKUP_ON_CHECK = True
|
||||
|
||||
|
||||
def teardown_module():
|
||||
config.SKIP_MX_LOOKUP_ON_CHECK = False
|
||||
|
||||
|
||||
def test_emit_alias_audit_log_for_random_data():
|
||||
user = create_new_user()
|
||||
|
||||
message = random_string()
|
||||
action = UserAuditLogAction.CreateMailbox
|
||||
emit_user_audit_log(
|
||||
user=user,
|
||||
action=action,
|
||||
message=message,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(user_id=user.id).all()
|
||||
assert len(logs_for_user) == 1
|
||||
assert logs_for_user[0].user_id == user.id
|
||||
assert logs_for_user[0].user_email == user.email
|
||||
assert logs_for_user[0].action == action.value
|
||||
assert logs_for_user[0].message == message
|
||||
|
||||
|
||||
def test_emit_audit_log_on_mailbox_creation():
|
||||
user = create_new_user()
|
||||
output = mailbox_utils.create_mailbox(
|
||||
user=user, email=random_email(), verified=True
|
||||
)
|
||||
|
||||
logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(user_id=user.id).all()
|
||||
assert len(logs_for_user) == 1
|
||||
assert logs_for_user[0].user_id == user.id
|
||||
assert logs_for_user[0].user_email == user.email
|
||||
assert logs_for_user[0].action == UserAuditLogAction.CreateMailbox.value
|
||||
assert (
|
||||
logs_for_user[0].message
|
||||
== f"Create mailbox {output.mailbox.id} ({output.mailbox.email}). Verified=True"
|
||||
)
|
Loading…
Add table
Reference in a new issue