This commit is contained in:
2022-12-30 16:23:27 +00:00
parent 02776e8478
commit 20da343c54
1304 changed files with 870224 additions and 0 deletions

View File

474
app/app/api/views/alias.py Normal file
View File

@ -0,0 +1,474 @@
from deprecated import deprecated
from flask import g
from flask import jsonify
from flask import request
from app import alias_utils
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
AliasInfo,
serialize_alias_info,
serialize_contact,
get_alias_infos_with_pagination,
get_alias_contacts,
serialize_alias_info_v2,
get_alias_info_v2,
get_alias_infos_with_pagination_v3,
)
from app.dashboard.views.alias_contact_manager import create_contact
from app.dashboard.views.alias_log import get_alias_log
from app.db import Session
from app.errors import (
CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded,
ErrContactAlreadyExists,
ErrAddressInvalid,
)
from app.models import Alias, Contact, Mailbox, AliasMailbox
@deprecated
@api_bp.route("/aliases", methods=["GET", "POST"])
@require_api_auth
def get_aliases():
"""
Get aliases
Input:
page_id: in query
Output:
- aliases: list of alias:
- id
- email
- creation_date
- creation_timestamp
- nb_forward
- nb_block
- nb_reply
- note
"""
user = g.user
try:
page_id = int(request.args.get("page_id"))
except (ValueError, TypeError):
return jsonify(error="page_id must be provided in request query"), 400
query = None
data = request.get_json(silent=True)
if data:
query = data.get("query")
alias_infos: [AliasInfo] = get_alias_infos_with_pagination(
user, page_id=page_id, query=query
)
return (
jsonify(
aliases=[serialize_alias_info(alias_info) for alias_info in alias_infos]
),
200,
)
@api_bp.route("/v2/aliases", methods=["GET", "POST"])
@require_api_auth
def get_aliases_v2():
"""
Get aliases
Input:
page_id: in query
pinned: in query
disabled: in query
enabled: in query
Output:
- aliases: list of alias:
- id
- email
- creation_date
- creation_timestamp
- nb_forward
- nb_block
- nb_reply
- note
- mailbox
- mailboxes
- support_pgp
- disable_pgp
- latest_activity: null if no activity.
- timestamp
- action: forward|reply|block|bounced
- contact:
- email
- name
- reverse_alias
"""
user = g.user
try:
page_id = int(request.args.get("page_id"))
except (ValueError, TypeError):
return jsonify(error="page_id must be provided in request query"), 400
pinned = "pinned" in request.args
disabled = "disabled" in request.args
enabled = "enabled" in request.args
if pinned:
alias_filter = "pinned"
elif disabled:
alias_filter = "disabled"
elif enabled:
alias_filter = "enabled"
else:
alias_filter = None
query = None
data = request.get_json(silent=True)
if data:
query = data.get("query")
alias_infos: [AliasInfo] = get_alias_infos_with_pagination_v3(
user, page_id=page_id, query=query, alias_filter=alias_filter
)
return (
jsonify(
aliases=[serialize_alias_info_v2(alias_info) for alias_info in alias_infos]
),
200,
)
@api_bp.route("/aliases/<int:alias_id>", methods=["DELETE"])
@require_api_auth
def delete_alias(alias_id):
"""
Delete alias
Input:
alias_id: in url
Output:
200 if deleted successfully
"""
user = g.user
alias = Alias.get(alias_id)
if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
alias_utils.delete_alias(alias, user)
return jsonify(deleted=True), 200
@api_bp.route("/aliases/<int:alias_id>/toggle", methods=["POST"])
@require_api_auth
def toggle_alias(alias_id):
"""
Enable/disable alias
Input:
alias_id: in url
Output:
200 along with new status:
- enabled
"""
user = g.user
alias: Alias = Alias.get(alias_id)
if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
alias.enabled = not alias.enabled
Session.commit()
return jsonify(enabled=alias.enabled), 200
@api_bp.route("/aliases/<int:alias_id>/activities")
@require_api_auth
def get_alias_activities(alias_id):
"""
Get aliases
Input:
page_id: in query
Output:
- activities: list of activity:
- from
- to
- timestamp
- action: forward|reply|block|bounced
- reverse_alias
"""
user = g.user
try:
page_id = int(request.args.get("page_id"))
except (ValueError, TypeError):
return jsonify(error="page_id must be provided in request query"), 400
alias: Alias = Alias.get(alias_id)
if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
alias_logs = get_alias_log(alias, page_id)
activities = []
for alias_log in alias_logs:
activity = {
"timestamp": alias_log.when.timestamp,
"reverse_alias": alias_log.reverse_alias,
"reverse_alias_address": alias_log.contact.reply_email,
}
if alias_log.is_reply:
activity["from"] = alias_log.alias
activity["to"] = alias_log.website_email
activity["action"] = "reply"
else:
activity["to"] = alias_log.alias
activity["from"] = alias_log.website_email
if alias_log.bounced:
activity["action"] = "bounced"
elif alias_log.blocked:
activity["action"] = "block"
else:
activity["action"] = "forward"
activities.append(activity)
return jsonify(activities=activities), 200
@api_bp.route("/aliases/<int:alias_id>", methods=["PUT", "PATCH"])
@require_api_auth
def update_alias(alias_id):
"""
Update alias note
Input:
alias_id: in url
note (optional): in body
name (optional): in body
mailbox_id (optional): in body
disable_pgp (optional): in body
Output:
200
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
user = g.user
alias: Alias = Alias.get(alias_id)
if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
changed = False
if "note" in data:
new_note = data.get("note")
alias.note = new_note
changed = True
if "mailbox_id" in data:
mailbox_id = int(data.get("mailbox_id"))
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
alias.mailbox_id = mailbox_id
changed = True
if "mailbox_ids" in data:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
mailboxes: [Mailbox] = []
# check if all mailboxes belong to user
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
mailboxes.append(mailbox)
if not mailboxes:
return jsonify(error="Must choose at least one mailbox"), 400
# <<< update alias mailboxes >>>
# first remove all existing alias-mailboxes links
AliasMailbox.filter_by(alias_id=alias.id).delete()
Session.flush()
# then add all new mailboxes
for i, mailbox in enumerate(mailboxes):
if i == 0:
alias.mailbox_id = mailboxes[0].id
else:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mailbox.id)
# <<< END update alias mailboxes >>>
changed = True
if "name" in data:
# to make sure alias name doesn't contain linebreak
new_name = data.get("name")
if new_name and len(new_name) > 128:
return jsonify(error="Name can't be longer than 128 characters"), 400
if new_name:
new_name = new_name.replace("\n", "")
alias.name = new_name
changed = True
if "disable_pgp" in data:
alias.disable_pgp = data.get("disable_pgp")
changed = True
if "pinned" in data:
alias.pinned = data.get("pinned")
changed = True
if changed:
Session.commit()
return jsonify(ok=True), 200
@api_bp.route("/aliases/<int:alias_id>", methods=["GET"])
@require_api_auth
def get_alias(alias_id):
"""
Get alias
Input:
alias_id: in url
Output:
Alias info, same as in get_aliases
"""
user = g.user
alias: Alias = Alias.get(alias_id)
if not alias:
return jsonify(error="Unknown error"), 400
if alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
return jsonify(**serialize_alias_info_v2(get_alias_info_v2(alias))), 200
@api_bp.route("/aliases/<int:alias_id>/contacts")
@require_api_auth
def get_alias_contacts_route(alias_id):
"""
Get alias contacts
Input:
page_id: in query
Output:
- contacts: list of contacts:
- creation_date
- creation_timestamp
- last_email_sent_date
- last_email_sent_timestamp
- contact
- reverse_alias
"""
user = g.user
try:
page_id = int(request.args.get("page_id"))
except (ValueError, TypeError):
return jsonify(error="page_id must be provided in request query"), 400
alias: Alias = Alias.get(alias_id)
if not alias:
return jsonify(error="No such alias"), 404
if alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
contacts = get_alias_contacts(alias, page_id)
return jsonify(contacts=contacts), 200
@api_bp.route("/aliases/<int:alias_id>/contacts", methods=["POST"])
@require_api_auth
def create_contact_route(alias_id):
"""
Create contact for an alias
Input:
alias_id: in url
contact: in body
Output:
201 if success
409 if contact already added
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
alias: Alias = Alias.get(alias_id)
if alias.user_id != g.user.id:
return jsonify(error="Forbidden"), 403
contact_address = data.get("contact")
try:
contact = create_contact(g.user, alias, contact_address)
except ErrContactErrorUpgradeNeeded as err:
return jsonify(error=err.error_for_user()), 403
except (ErrAddressInvalid, CannotCreateContactForReverseAlias) as err:
return jsonify(error=err.error_for_user()), 400
except ErrContactAlreadyExists as err:
return jsonify(**serialize_contact(err.contact, existed=True)), 200
return jsonify(**serialize_contact(contact)), 201
@api_bp.route("/contacts/<int:contact_id>", methods=["DELETE"])
@require_api_auth
def delete_contact(contact_id):
"""
Delete contact
Input:
contact_id: in url
Output:
200
"""
user = g.user
contact = Contact.get(contact_id)
if not contact or contact.alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
Contact.delete(contact_id)
Session.commit()
return jsonify(deleted=True), 200
@api_bp.route("/contacts/<int:contact_id>/toggle", methods=["POST"])
@require_api_auth
def toggle_contact(contact_id):
"""
Block/Unblock contact
Input:
contact_id: in url
Output:
200
"""
user = g.user
contact = Contact.get(contact_id)
if not contact or contact.alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
contact.block_forward = not contact.block_forward
Session.commit()
return jsonify(block_forward=contact.block_forward), 200

View File

@ -0,0 +1,153 @@
import tldextract
from flask import jsonify, request, g
from sqlalchemy import desc
from app.alias_suffix import get_alias_suffixes
from app.api.base import api_bp, require_api_auth
from app.db import Session
from app.log import LOG
from app.models import AliasUsedOn, Alias, User
from app.utils import convert_to_id
@api_bp.route("/v4/alias/options")
@require_api_auth
def options_v4():
"""
Return what options user has when creating new alias.
Same as v3 but return time-based signed-suffix in addition to suffix. To be used with /v2/alias/custom/new
Input:
a valid api-key in "Authentication" header and
optional "hostname" in args
Output: cf README
can_create: bool
suffixes: [[suffix, signed_suffix]]
prefix_suggestion: str
recommendation: Optional dict
alias: str
hostname: str
"""
user = g.user
hostname = request.args.get("hostname")
ret = {
"can_create": user.can_create_new_alias(),
"suffixes": [],
"prefix_suggestion": "",
}
# recommendation alias if exist
if hostname:
# put the latest used alias first
q = (
Session.query(AliasUsedOn, Alias, User)
.filter(
AliasUsedOn.alias_id == Alias.id,
Alias.user_id == user.id,
AliasUsedOn.hostname == hostname,
)
.order_by(desc(AliasUsedOn.created_at))
)
r = q.first()
if r:
_, alias, _ = r
LOG.d("found alias %s %s %s", alias, hostname, user)
ret["recommendation"] = {"alias": alias.email, "hostname": hostname}
# custom alias suggestion and suffix
if hostname:
# keep only the domain name of hostname, ignore TLD and subdomain
# for ex www.groupon.com -> groupon
ext = tldextract.extract(hostname)
prefix_suggestion = ext.domain
prefix_suggestion = convert_to_id(prefix_suggestion)
ret["prefix_suggestion"] = prefix_suggestion
suffixes = get_alias_suffixes(user)
# custom domain should be put first
ret["suffixes"] = list([suffix.suffix, suffix.signed_suffix] for suffix in suffixes)
return jsonify(ret)
@api_bp.route("/v5/alias/options")
@require_api_auth
def options_v5():
"""
Return what options user has when creating new alias.
Same as v4 but uses a better format. To be used with /v2/alias/custom/new
Input:
a valid api-key in "Authentication" header and
optional "hostname" in args
Output: cf README
can_create: bool
suffixes: [
{
suffix: "suffix",
signed_suffix: "signed_suffix",
is_custom: true,
is_premium: false
}
]
prefix_suggestion: str
recommendation: Optional dict
alias: str
hostname: str
"""
user = g.user
hostname = request.args.get("hostname")
ret = {
"can_create": user.can_create_new_alias(),
"suffixes": [],
"prefix_suggestion": "",
}
# recommendation alias if exist
if hostname:
# put the latest used alias first
q = (
Session.query(AliasUsedOn, Alias, User)
.filter(
AliasUsedOn.alias_id == Alias.id,
Alias.user_id == user.id,
AliasUsedOn.hostname == hostname,
)
.order_by(desc(AliasUsedOn.created_at))
)
r = q.first()
if r:
_, alias, _ = r
LOG.d("found alias %s %s %s", alias, hostname, user)
ret["recommendation"] = {"alias": alias.email, "hostname": hostname}
# custom alias suggestion and suffix
if hostname:
# keep only the domain name of hostname, ignore TLD and subdomain
# for ex www.groupon.com -> groupon
ext = tldextract.extract(hostname)
prefix_suggestion = ext.domain
prefix_suggestion = convert_to_id(prefix_suggestion)
ret["prefix_suggestion"] = prefix_suggestion
suffixes = get_alias_suffixes(user)
# custom domain should be put first
ret["suffixes"] = [
{
"suffix": suffix.suffix,
"signed_suffix": suffix.signed_suffix,
"is_custom": suffix.is_custom,
"is_premium": suffix.is_premium,
}
for suffix in suffixes
]
return jsonify(ret)

559
app/app/api/views/apple.py Normal file
View File

@ -0,0 +1,559 @@
from typing import Optional
import arrow
import requests
from flask import g
from flask import jsonify
from flask import request
from requests import RequestException
from app.api.base import api_bp, require_api_auth
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
from app.db import Session
from app.log import LOG
from app.models import PlanEnum, AppleSubscription
_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly"
_YEARLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.yearly"
_MACAPP_MONTHLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.monthly"
_MACAPP_YEARLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.yearly"
# Apple API URL
_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"
_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
@api_bp.route("/apple/process_payment", methods=["POST"])
@require_api_auth
def apple_process_payment():
"""
Process payment
Input:
receipt_data: in body
(optional) is_macapp: in body
Output:
200 of the payment is successful, i.e. user is upgraded to premium
"""
user = g.user
LOG.d("request for /apple/process_payment from %s", user)
data = request.get_json()
receipt_data = data.get("receipt_data")
is_macapp = "is_macapp" in data and data["is_macapp"] is True
if is_macapp:
LOG.d("Use Macapp secret")
password = MACAPP_APPLE_API_SECRET
else:
password = APPLE_API_SECRET
apple_sub = verify_receipt(receipt_data, user, password)
if apple_sub:
return jsonify(ok=True), 200
return jsonify(error="Processing failed"), 400
@api_bp.route("/apple/update_notification", methods=["GET", "POST"])
def apple_update_notification():
"""
The "Subscription Status URL" to receive update notifications from Apple
"""
# request.json looks like this
# will use unified_receipt.latest_receipt_info and NOT latest_expired_receipt_info
# more info on https://developer.apple.com/documentation/appstoreservernotifications/responsebody
# {
# "unified_receipt": {
# "latest_receipt": "long string",
# "pending_renewal_info": [
# {
# "is_in_billing_retry_period": "0",
# "auto_renew_status": "0",
# "original_transaction_id": "1000000654277043",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "expiration_intent": "1",
# "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# }
# ],
# "environment": "Sandbox",
# "status": 0,
# "latest_receipt_info": [
# {
# "expires_date_pst": "2020-04-20 21:11:57 America/Los_Angeles",
# "purchase_date": "2020-04-21 03:11:57 Etc/GMT",
# "purchase_date_ms": "1587438717000",
# "original_purchase_date_ms": "1587420715000",
# "transaction_id": "1000000654329911",
# "original_transaction_id": "1000000654277043",
# "quantity": "1",
# "expires_date_ms": "1587442317000",
# "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "subscription_group_identifier": "20624274",
# "web_order_line_item_id": "1000000051891577",
# "expires_date": "2020-04-21 04:11:57 Etc/GMT",
# "is_in_intro_offer_period": "false",
# "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
# "purchase_date_pst": "2020-04-20 20:11:57 America/Los_Angeles",
# "is_trial_period": "false",
# },
# {
# "expires_date_pst": "2020-04-20 20:11:57 America/Los_Angeles",
# "purchase_date": "2020-04-21 02:11:57 Etc/GMT",
# "purchase_date_ms": "1587435117000",
# "original_purchase_date_ms": "1587420715000",
# "transaction_id": "1000000654313889",
# "original_transaction_id": "1000000654277043",
# "quantity": "1",
# "expires_date_ms": "1587438717000",
# "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "subscription_group_identifier": "20624274",
# "web_order_line_item_id": "1000000051890729",
# "expires_date": "2020-04-21 03:11:57 Etc/GMT",
# "is_in_intro_offer_period": "false",
# "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
# "purchase_date_pst": "2020-04-20 19:11:57 America/Los_Angeles",
# "is_trial_period": "false",
# },
# {
# "expires_date_pst": "2020-04-20 19:11:54 America/Los_Angeles",
# "purchase_date": "2020-04-21 01:11:54 Etc/GMT",
# "purchase_date_ms": "1587431514000",
# "original_purchase_date_ms": "1587420715000",
# "transaction_id": "1000000654300800",
# "original_transaction_id": "1000000654277043",
# "quantity": "1",
# "expires_date_ms": "1587435114000",
# "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "subscription_group_identifier": "20624274",
# "web_order_line_item_id": "1000000051890161",
# "expires_date": "2020-04-21 02:11:54 Etc/GMT",
# "is_in_intro_offer_period": "false",
# "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
# "purchase_date_pst": "2020-04-20 18:11:54 America/Los_Angeles",
# "is_trial_period": "false",
# },
# {
# "expires_date_pst": "2020-04-20 18:11:54 America/Los_Angeles",
# "purchase_date": "2020-04-21 00:11:54 Etc/GMT",
# "purchase_date_ms": "1587427914000",
# "original_purchase_date_ms": "1587420715000",
# "transaction_id": "1000000654293615",
# "original_transaction_id": "1000000654277043",
# "quantity": "1",
# "expires_date_ms": "1587431514000",
# "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "subscription_group_identifier": "20624274",
# "web_order_line_item_id": "1000000051889539",
# "expires_date": "2020-04-21 01:11:54 Etc/GMT",
# "is_in_intro_offer_period": "false",
# "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
# "purchase_date_pst": "2020-04-20 17:11:54 America/Los_Angeles",
# "is_trial_period": "false",
# },
# {
# "expires_date_pst": "2020-04-20 17:11:54 America/Los_Angeles",
# "purchase_date": "2020-04-20 23:11:54 Etc/GMT",
# "purchase_date_ms": "1587424314000",
# "original_purchase_date_ms": "1587420715000",
# "transaction_id": "1000000654285464",
# "original_transaction_id": "1000000654277043",
# "quantity": "1",
# "expires_date_ms": "1587427914000",
# "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "subscription_group_identifier": "20624274",
# "web_order_line_item_id": "1000000051888827",
# "expires_date": "2020-04-21 00:11:54 Etc/GMT",
# "is_in_intro_offer_period": "false",
# "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
# "purchase_date_pst": "2020-04-20 16:11:54 America/Los_Angeles",
# "is_trial_period": "false",
# },
# {
# "expires_date_pst": "2020-04-20 16:11:54 America/Los_Angeles",
# "purchase_date": "2020-04-20 22:11:54 Etc/GMT",
# "purchase_date_ms": "1587420714000",
# "original_purchase_date_ms": "1587420715000",
# "transaction_id": "1000000654277043",
# "original_transaction_id": "1000000654277043",
# "quantity": "1",
# "expires_date_ms": "1587424314000",
# "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "subscription_group_identifier": "20624274",
# "web_order_line_item_id": "1000000051888825",
# "expires_date": "2020-04-20 23:11:54 Etc/GMT",
# "is_in_intro_offer_period": "false",
# "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
# "purchase_date_pst": "2020-04-20 15:11:54 America/Los_Angeles",
# "is_trial_period": "false",
# },
# ],
# },
# "auto_renew_status_change_date": "2020-04-21 04:11:33 Etc/GMT",
# "environment": "Sandbox",
# "auto_renew_status": "false",
# "auto_renew_status_change_date_pst": "2020-04-20 21:11:33 America/Los_Angeles",
# "latest_expired_receipt": "long string",
# "latest_expired_receipt_info": {
# "original_purchase_date_pst": "2020-04-20 15:11:55 America/Los_Angeles",
# "quantity": "1",
# "subscription_group_identifier": "20624274",
# "unique_vendor_identifier": "4C4DF6BA-DE2A-4737-9A68-5992338886DC",
# "original_purchase_date_ms": "1587420715000",
# "expires_date_formatted": "2020-04-21 04:11:57 Etc/GMT",
# "is_in_intro_offer_period": "false",
# "purchase_date_ms": "1587438717000",
# "expires_date_formatted_pst": "2020-04-20 21:11:57 America/Los_Angeles",
# "is_trial_period": "false",
# "item_id": "1508744966",
# "unique_identifier": "b55fc3dcc688e979115af0697a0195be78be7cbd",
# "original_transaction_id": "1000000654277043",
# "expires_date": "1587442317000",
# "transaction_id": "1000000654329911",
# "bvrs": "3",
# "web_order_line_item_id": "1000000051891577",
# "version_external_identifier": "834289833",
# "bid": "io.simplelogin.ios-app",
# "product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "purchase_date": "2020-04-21 03:11:57 Etc/GMT",
# "purchase_date_pst": "2020-04-20 20:11:57 America/Los_Angeles",
# "original_purchase_date": "2020-04-20 22:11:55 Etc/GMT",
# },
# "password": "22b9d5a110dd4344a1681631f1f95f55",
# "auto_renew_status_change_date_ms": "1587442293000",
# "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
# "notification_type": "DID_CHANGE_RENEWAL_STATUS",
# }
LOG.d("request for /api/apple/update_notification")
data = request.get_json()
if not (
data
and data.get("unified_receipt")
and data["unified_receipt"].get("latest_receipt_info")
):
LOG.d("Invalid data %s", data)
return jsonify(error="Empty Response"), 400
transactions = data["unified_receipt"]["latest_receipt_info"]
# dict of original_transaction_id and transaction
latest_transactions = {}
for transaction in transactions:
original_transaction_id = transaction["original_transaction_id"]
if not latest_transactions.get(original_transaction_id):
latest_transactions[original_transaction_id] = transaction
if (
transaction["expires_date_ms"]
> latest_transactions[original_transaction_id]["expires_date_ms"]
):
latest_transactions[original_transaction_id] = transaction
for original_transaction_id, transaction in latest_transactions.items():
expires_date = arrow.get(int(transaction["expires_date_ms"]) / 1000)
plan = (
PlanEnum.monthly
if transaction["product_id"]
in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
else PlanEnum.yearly
)
apple_sub: AppleSubscription = AppleSubscription.get_by(
original_transaction_id=original_transaction_id
)
if apple_sub:
user = apple_sub.user
LOG.d(
"Update AppleSubscription for user %s, expired at %s, plan %s",
user,
expires_date,
plan,
)
apple_sub.receipt_data = data["unified_receipt"]["latest_receipt"]
apple_sub.expires_date = expires_date
apple_sub.plan = plan
apple_sub.product_id = transaction["product_id"]
Session.commit()
return jsonify(ok=True), 200
else:
LOG.w(
"No existing AppleSub for original_transaction_id %s",
original_transaction_id,
)
LOG.d("request data %s", data)
return jsonify(error="Processing failed"), 400
def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
"""
Call https://buy.itunes.apple.com/verifyReceipt and create/update AppleSubscription table
Call the production URL for verifyReceipt first,
use sandbox URL if receive a 21007 status code.
Return AppleSubscription object if success
https://developer.apple.com/documentation/appstorereceipts/verifyreceipt
"""
LOG.d("start verify_receipt")
try:
r = requests.post(
_PROD_URL, json={"receipt-data": receipt_data, "password": password}
)
except RequestException:
LOG.w("cannot call Apple server %s", _PROD_URL)
return None
if r.status_code >= 500:
LOG.w("Apple server error, response:%s %s", r, r.content)
return None
if r.json() == {"status": 21007}:
# try sandbox_url
LOG.w("Use the sandbox url instead")
r = requests.post(
_SANDBOX_URL,
json={"receipt-data": receipt_data, "password": password},
)
data = r.json()
# data has the following format
# {
# "status": 0,
# "environment": "Sandbox",
# "receipt": {
# "receipt_type": "ProductionSandbox",
# "adam_id": 0,
# "app_item_id": 0,
# "bundle_id": "io.simplelogin.ios-app",
# "application_version": "2",
# "download_id": 0,
# "version_external_identifier": 0,
# "receipt_creation_date": "2020-04-18 16:36:34 Etc/GMT",
# "receipt_creation_date_ms": "1587227794000",
# "receipt_creation_date_pst": "2020-04-18 09:36:34 America/Los_Angeles",
# "request_date": "2020-04-18 16:46:36 Etc/GMT",
# "request_date_ms": "1587228396496",
# "request_date_pst": "2020-04-18 09:46:36 America/Los_Angeles",
# "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
# "original_purchase_date_ms": "1375340400000",
# "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
# "original_application_version": "1.0",
# "in_app": [
# {
# "quantity": "1",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "transaction_id": "1000000653584474",
# "original_transaction_id": "1000000653584474",
# "purchase_date": "2020-04-18 16:27:42 Etc/GMT",
# "purchase_date_ms": "1587227262000",
# "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles",
# "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
# "original_purchase_date_ms": "1587227264000",
# "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
# "expires_date": "2020-04-18 16:32:42 Etc/GMT",
# "expires_date_ms": "1587227562000",
# "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
# "web_order_line_item_id": "1000000051847459",
# "is_trial_period": "false",
# "is_in_intro_offer_period": "false",
# },
# {
# "quantity": "1",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "transaction_id": "1000000653584861",
# "original_transaction_id": "1000000653584474",
# "purchase_date": "2020-04-18 16:32:42 Etc/GMT",
# "purchase_date_ms": "1587227562000",
# "purchase_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
# "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
# "original_purchase_date_ms": "1587227264000",
# "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
# "expires_date": "2020-04-18 16:37:42 Etc/GMT",
# "expires_date_ms": "1587227862000",
# "expires_date_pst": "2020-04-18 09:37:42 America/Los_Angeles",
# "web_order_line_item_id": "1000000051847461",
# "is_trial_period": "false",
# "is_in_intro_offer_period": "false",
# },
# ],
# },
# "latest_receipt_info": [
# {
# "quantity": "1",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "transaction_id": "1000000653584474",
# "original_transaction_id": "1000000653584474",
# "purchase_date": "2020-04-18 16:27:42 Etc/GMT",
# "purchase_date_ms": "1587227262000",
# "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles",
# "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
# "original_purchase_date_ms": "1587227264000",
# "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
# "expires_date": "2020-04-18 16:32:42 Etc/GMT",
# "expires_date_ms": "1587227562000",
# "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
# "web_order_line_item_id": "1000000051847459",
# "is_trial_period": "false",
# "is_in_intro_offer_period": "false",
# "subscription_group_identifier": "20624274",
# },
# {
# "quantity": "1",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "transaction_id": "1000000653584861",
# "original_transaction_id": "1000000653584474",
# "purchase_date": "2020-04-18 16:32:42 Etc/GMT",
# "purchase_date_ms": "1587227562000",
# "purchase_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
# "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
# "original_purchase_date_ms": "1587227264000",
# "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
# "expires_date": "2020-04-18 16:37:42 Etc/GMT",
# "expires_date_ms": "1587227862000",
# "expires_date_pst": "2020-04-18 09:37:42 America/Los_Angeles",
# "web_order_line_item_id": "1000000051847461",
# "is_trial_period": "false",
# "is_in_intro_offer_period": "false",
# "subscription_group_identifier": "20624274",
# },
# {
# "quantity": "1",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "transaction_id": "1000000653585235",
# "original_transaction_id": "1000000653584474",
# "purchase_date": "2020-04-18 16:38:16 Etc/GMT",
# "purchase_date_ms": "1587227896000",
# "purchase_date_pst": "2020-04-18 09:38:16 America/Los_Angeles",
# "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
# "original_purchase_date_ms": "1587227264000",
# "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
# "expires_date": "2020-04-18 16:43:16 Etc/GMT",
# "expires_date_ms": "1587228196000",
# "expires_date_pst": "2020-04-18 09:43:16 America/Los_Angeles",
# "web_order_line_item_id": "1000000051847500",
# "is_trial_period": "false",
# "is_in_intro_offer_period": "false",
# "subscription_group_identifier": "20624274",
# },
# {
# "quantity": "1",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "transaction_id": "1000000653585760",
# "original_transaction_id": "1000000653584474",
# "purchase_date": "2020-04-18 16:44:25 Etc/GMT",
# "purchase_date_ms": "1587228265000",
# "purchase_date_pst": "2020-04-18 09:44:25 America/Los_Angeles",
# "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
# "original_purchase_date_ms": "1587227264000",
# "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
# "expires_date": "2020-04-18 16:49:25 Etc/GMT",
# "expires_date_ms": "1587228565000",
# "expires_date_pst": "2020-04-18 09:49:25 America/Los_Angeles",
# "web_order_line_item_id": "1000000051847566",
# "is_trial_period": "false",
# "is_in_intro_offer_period": "false",
# "subscription_group_identifier": "20624274",
# },
# ],
# "latest_receipt": "very long string",
# "pending_renewal_info": [
# {
# "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "original_transaction_id": "1000000653584474",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "auto_renew_status": "1",
# }
# ],
# }
if data["status"] != 0:
LOG.e(
"verifyReceipt status !=0, probably invalid receipt. User %s, data %s",
user,
data,
)
return None
# use responseBody.Latest_receipt_info and not responseBody.Receipt.In_app
# as recommended on https://developer.apple.com/documentation/appstorereceipts/responsebody/receipt/in_app
# each item in data["latest_receipt_info"] has the following format
# {
# "quantity": "1",
# "product_id": "io.simplelogin.ios_app.subscription.premium.monthly",
# "transaction_id": "1000000653584474",
# "original_transaction_id": "1000000653584474",
# "purchase_date": "2020-04-18 16:27:42 Etc/GMT",
# "purchase_date_ms": "1587227262000",
# "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles",
# "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT",
# "original_purchase_date_ms": "1587227264000",
# "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles",
# "expires_date": "2020-04-18 16:32:42 Etc/GMT",
# "expires_date_ms": "1587227562000",
# "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles",
# "web_order_line_item_id": "1000000051847459",
# "is_trial_period": "false",
# "is_in_intro_offer_period": "false",
# }
transactions = data.get("latest_receipt_info")
if not transactions:
LOG.i("Empty transactions in data %s", data)
return None
latest_transaction = max(transactions, key=lambda t: int(t["expires_date_ms"]))
original_transaction_id = latest_transaction["original_transaction_id"]
expires_date = arrow.get(int(latest_transaction["expires_date_ms"]) / 1000)
plan = (
PlanEnum.monthly
if latest_transaction["product_id"]
in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
else PlanEnum.yearly
)
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=user.id)
if apple_sub:
LOG.d(
"Update AppleSubscription for user %s, expired at %s (%s), plan %s",
user,
expires_date,
expires_date.humanize(),
plan,
)
apple_sub.receipt_data = receipt_data
apple_sub.expires_date = expires_date
apple_sub.original_transaction_id = original_transaction_id
apple_sub.product_id = latest_transaction["product_id"]
apple_sub.plan = plan
else:
# the same original_transaction_id has been used on another account
if AppleSubscription.get_by(original_transaction_id=original_transaction_id):
LOG.e("Same Apple Sub has been used before, current user %s", user)
return None
LOG.d(
"Create new AppleSubscription for user %s, expired at %s, plan %s",
user,
expires_date,
plan,
)
apple_sub = AppleSubscription.create(
user_id=user.id,
receipt_data=receipt_data,
expires_date=expires_date,
original_transaction_id=original_transaction_id,
plan=plan,
product_id=latest_transaction["product_id"],
)
Session.commit()
return apple_sub

383
app/app/api/views/auth.py Normal file
View File

@ -0,0 +1,383 @@
import secrets
import string
import facebook
import google.oauth2.credentials
import googleapiclient.discovery
from flask import jsonify, request
from flask_login import login_user
from itsdangerous import Signer
from app import email_utils
from app.api.base import api_bp
from app.config import FLASK_SECRET, DISABLE_REGISTRATION
from app.dashboard.views.setting import send_reset_password_email
from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,
personal_email_already_used,
send_email,
render,
)
from app.events.auth_event import LoginEvent, RegisterEvent
from app.extensions import limiter
from app.log import LOG
from app.models import User, ApiKey, SocialAuth, AccountActivation
from app.utils import sanitize_email, canonicalize_email
@api_bp.route("/auth/login", methods=["POST"])
@limiter.limit("10/minute")
def auth_login():
"""
Authenticate user
Input:
email
password
device: to create an ApiKey associated with this device
Output:
200 and user info containing:
{
name: "John Wick",
mfa_enabled: true,
mfa_key: "a long string",
api_key: "a long string"
}
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
password = data.get("password")
device = data.get("device")
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user or not user.check_password(password):
LoginEvent(LoginEvent.ActionType.failed, LoginEvent.Source.api).send()
return jsonify(error="Email or password incorrect"), 400
elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
return jsonify(error="Account disabled"), 400
elif not user.activated:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422
elif user.fido_enabled():
# allow user who has TOTP enabled to continue using the mobile app
if not user.enable_otp:
return jsonify(error="Currently we don't support FIDO on mobile yet"), 403
LoginEvent(LoginEvent.ActionType.success, LoginEvent.Source.api).send()
return jsonify(**auth_payload(user, device)), 200
@api_bp.route("/auth/register", methods=["POST"])
@limiter.limit("10/minute")
def auth_register():
"""
User signs up - will need to activate their account with an activation code.
Input:
email
password
Output:
200: user needs to confirm their account
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
dirty_email = data.get("email")
email = canonicalize_email(dirty_email)
password = data.get("password")
if DISABLE_REGISTRATION:
RegisterEvent(RegisterEvent.ActionType.failed, RegisterEvent.Source.api).send()
return jsonify(error="registration is closed"), 400
if not email_can_be_used_as_mailbox(email) or personal_email_already_used(email):
RegisterEvent(
RegisterEvent.ActionType.invalid_email, RegisterEvent.Source.api
).send()
return jsonify(error=f"cannot use {email} as personal inbox"), 400
if not password or len(password) < 8:
RegisterEvent(RegisterEvent.ActionType.failed, RegisterEvent.Source.api).send()
return jsonify(error="password too short"), 400
if len(password) > 100:
RegisterEvent(RegisterEvent.ActionType.failed, RegisterEvent.Source.api).send()
return jsonify(error="password too long"), 400
LOG.d("create user %s", email)
user = User.create(email=email, name=dirty_email, password=password)
Session.flush()
# create activation code
code = "".join([str(secrets.choice(string.digits)) for _ in range(6)])
AccountActivation.create(user_id=user.id, code=code)
Session.commit()
send_email(
email,
"Just one more step to join SimpleLogin",
render("transactional/code-activation.txt.jinja2", code=code),
render("transactional/code-activation.html", code=code),
)
RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send()
return jsonify(msg="User needs to confirm their account"), 200
@api_bp.route("/auth/activate", methods=["POST"])
@limiter.limit("10/minute")
def auth_activate():
"""
User enters the activation code to confirm their account.
Input:
email
code
Output:
200: user account is now activated, user can login now
400: wrong email, code
410: wrong code too many times
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
code = data.get("code")
user = User.get_by(email=email) or User.get_by(email=canonical_email)
# do not use a different message to avoid exposing existing email
if not user or user.activated:
return jsonify(error="Wrong email or code"), 400
account_activation = AccountActivation.get_by(user_id=user.id)
if not account_activation:
return jsonify(error="Wrong email or code"), 400
if account_activation.code != code:
# decrement nb tries
account_activation.tries -= 1
Session.commit()
if account_activation.tries == 0:
AccountActivation.delete(account_activation.id)
Session.commit()
return jsonify(error="Too many wrong tries"), 410
return jsonify(error="Wrong email or code"), 400
LOG.d("activate user %s", user)
user.activated = True
AccountActivation.delete(account_activation.id)
Session.commit()
return jsonify(msg="Account is activated, user can login now"), 200
@api_bp.route("/auth/reactivate", methods=["POST"])
@limiter.limit("10/minute")
def auth_reactivate():
"""
User asks for another activation code
Input:
email
Output:
200: user is going to receive an email for activate their account
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
# do not use a different message to avoid exposing existing email
if not user or user.activated:
return jsonify(error="Something went wrong"), 400
account_activation = AccountActivation.get_by(user_id=user.id)
if account_activation:
AccountActivation.delete(account_activation.id)
Session.commit()
# create activation code
code = "".join([str(secrets.choice(string.digits)) for _ in range(6)])
AccountActivation.create(user_id=user.id, code=code)
Session.commit()
send_email(
email,
"Just one more step to join SimpleLogin",
render("transactional/code-activation.txt.jinja2", code=code),
render("transactional/code-activation.html", code=code),
)
return jsonify(msg="User needs to confirm their account"), 200
@api_bp.route("/auth/facebook", methods=["POST"])
@limiter.limit("10/minute")
def auth_facebook():
"""
Authenticate user with Facebook
Input:
facebook_token: facebook access token
device: to create an ApiKey associated with this device
Output:
200 and user info containing:
{
name: "John Wick",
mfa_enabled: true,
mfa_key: "a long string",
api_key: "a long string"
}
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
facebook_token = data.get("facebook_token")
device = data.get("device")
graph = facebook.GraphAPI(access_token=facebook_token)
user_info = graph.get_object("me", fields="email,name")
email = sanitize_email(user_info.get("email"))
user = User.get_by(email=email)
if not user:
if DISABLE_REGISTRATION:
return jsonify(error="registration is closed"), 400
if not email_can_be_used_as_mailbox(email) or personal_email_already_used(
email
):
return jsonify(error=f"cannot use {email} as personal inbox"), 400
LOG.d("create facebook user with %s", user_info)
user = User.create(email=email, name=user_info["name"], activated=True)
Session.commit()
email_utils.send_welcome_email(user)
if not SocialAuth.get_by(user_id=user.id, social="facebook"):
SocialAuth.create(user_id=user.id, social="facebook")
Session.commit()
return jsonify(**auth_payload(user, device)), 200
@api_bp.route("/auth/google", methods=["POST"])
@limiter.limit("10/minute")
def auth_google():
"""
Authenticate user with Google
Input:
google_token: Google access token
device: to create an ApiKey associated with this device
Output:
200 and user info containing:
{
name: "John Wick",
mfa_enabled: true,
mfa_key: "a long string",
api_key: "a long string"
}
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
google_token = data.get("google_token")
device = data.get("device")
cred = google.oauth2.credentials.Credentials(token=google_token)
build = googleapiclient.discovery.build("oauth2", "v2", credentials=cred)
user_info = build.userinfo().get().execute()
email = sanitize_email(user_info.get("email"))
user = User.get_by(email=email)
if not user:
if DISABLE_REGISTRATION:
return jsonify(error="registration is closed"), 400
if not email_can_be_used_as_mailbox(email) or personal_email_already_used(
email
):
return jsonify(error=f"cannot use {email} as personal inbox"), 400
LOG.d("create Google user with %s", user_info)
user = User.create(email=email, name="", activated=True)
Session.commit()
email_utils.send_welcome_email(user)
if not SocialAuth.get_by(user_id=user.id, social="google"):
SocialAuth.create(user_id=user.id, social="google")
Session.commit()
return jsonify(**auth_payload(user, device)), 200
def auth_payload(user, device) -> dict:
ret = {"name": user.name or "", "email": user.email, "mfa_enabled": user.enable_otp}
# do not give api_key, user can only obtain api_key after OTP verification
if user.enable_otp:
s = Signer(FLASK_SECRET)
ret["mfa_key"] = s.sign(str(user.id))
ret["api_key"] = None
else:
api_key = ApiKey.get_by(user_id=user.id, name=device)
if not api_key:
LOG.d("create new api key for %s and %s", user, device)
api_key = ApiKey.create(user.id, device)
Session.commit()
ret["mfa_key"] = None
ret["api_key"] = api_key.code
# so user is automatically logged in on the web
login_user(user)
return ret
@api_bp.route("/auth/forgot_password", methods=["POST"])
@limiter.limit("10/minute")
def forgot_password():
"""
User forgot password
Input:
email
Output:
200 and a reset password email is sent to user
400 if email not exist
"""
data = request.get_json()
if not data or not data.get("email"):
return jsonify(error="request body must contain email"), 400
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if user:
send_reset_password_email(user)
return jsonify(ok=True)

View File

@ -0,0 +1,75 @@
import pyotp
from flask import jsonify, request
from flask_login import login_user
from itsdangerous import Signer
from app.api.base import api_bp
from app.config import FLASK_SECRET
from app.db import Session
from app.email_utils import send_invalid_totp_login_email
from app.extensions import limiter
from app.log import LOG
from app.models import User, ApiKey
@api_bp.route("/auth/mfa", methods=["POST"])
@limiter.limit("10/minute")
def auth_mfa():
"""
Validate the OTP Token
Input:
mfa_token: OTP token that user enters
mfa_key: MFA key obtained in previous auth request, e.g. /api/auth/login
device: the device name, used to create an ApiKey associated with this device
Output:
200 and user info containing:
{
name: "John Wick",
api_key: "a long string",
email: "user email"
}
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
mfa_token = data.get("mfa_token")
mfa_key = data.get("mfa_key")
device = data.get("device")
s = Signer(FLASK_SECRET)
try:
user_id = int(s.unsign(mfa_key))
except Exception:
return jsonify(error="Invalid mfa_key"), 400
user = User.get(user_id)
if not user:
return jsonify(error="Invalid mfa_key"), 400
elif not user.enable_otp:
return (
jsonify(error="This endpoint should only be used by user who enables MFA"),
400,
)
totp = pyotp.TOTP(user.otp_secret)
if not totp.verify(mfa_token, valid_window=2):
send_invalid_totp_login_email(user, "TOTP")
return jsonify(error="Wrong TOTP Token"), 400
ret = {"name": user.name or "", "email": user.email}
api_key = ApiKey.get_by(user_id=user.id, name=device)
if not api_key:
LOG.d("create new api key for %s and %s", user, device)
api_key = ApiKey.create(user.id, device)
Session.commit()
ret["api_key"] = api_key.code
# so user is logged in automatically on the web
login_user(user)
return jsonify(**ret), 200

View File

@ -0,0 +1,126 @@
from flask import g, request
from flask import jsonify
from app.api.base import api_bp, require_api_auth
from app.db import Session
from app.models import CustomDomain, DomainDeletedAlias, Mailbox, DomainMailbox
def custom_domain_to_dict(custom_domain: CustomDomain):
return {
"id": custom_domain.id,
"domain_name": custom_domain.domain,
"is_verified": custom_domain.verified,
"nb_alias": custom_domain.nb_alias(),
"creation_date": custom_domain.created_at.format(),
"creation_timestamp": custom_domain.created_at.timestamp,
"catch_all": custom_domain.catch_all,
"name": custom_domain.name,
"random_prefix_generation": custom_domain.random_prefix_generation,
"mailboxes": [
{"id": mb.id, "email": mb.email} for mb in custom_domain.mailboxes
],
}
@api_bp.route("/custom_domains", methods=["GET"])
@require_api_auth
def get_custom_domains():
user = g.user
custom_domains = CustomDomain.filter_by(
user_id=user.id, is_sl_subdomain=False
).all()
return jsonify(custom_domains=[custom_domain_to_dict(cd) for cd in custom_domains])
@api_bp.route("/custom_domains/<int:custom_domain_id>/trash", methods=["GET"])
@require_api_auth
def get_custom_domain_trash(custom_domain_id: int):
user = g.user
custom_domain = CustomDomain.get(custom_domain_id)
if not custom_domain or custom_domain.user_id != user.id:
return jsonify(error="Forbidden"), 403
domain_deleted_aliases = DomainDeletedAlias.filter_by(
domain_id=custom_domain.id
).all()
return jsonify(
aliases=[
{
"alias": dda.email,
"deletion_timestamp": dda.created_at.timestamp,
}
for dda in domain_deleted_aliases
]
)
@api_bp.route("/custom_domains/<int:custom_domain_id>", methods=["PATCH"])
@require_api_auth
def update_custom_domain(custom_domain_id):
"""
Update alias note
Input:
custom_domain_id: in url
In body:
catch_all (optional): boolean
random_prefix_generation (optional): boolean
name (optional): in body
mailbox_ids (optional): array of mailbox_id
Output:
200
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
user = g.user
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
if not custom_domain or custom_domain.user_id != user.id:
return jsonify(error="Forbidden"), 403
changed = False
if "catch_all" in data:
catch_all = data.get("catch_all")
custom_domain.catch_all = catch_all
changed = True
if "random_prefix_generation" in data:
random_prefix_generation = data.get("random_prefix_generation")
custom_domain.random_prefix_generation = random_prefix_generation
changed = True
if "name" in data:
name = data.get("name")
custom_domain.name = name
changed = True
if "mailbox_ids" in data:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
if mailbox_ids:
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
mailboxes.append(mailbox)
# first remove all existing domain-mailboxes links
DomainMailbox.filter_by(domain_id=custom_domain.id).delete()
Session.flush()
for mailbox in mailboxes:
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
changed = True
if changed:
Session.commit()
# refresh
custom_domain = CustomDomain.get(custom_domain_id)
return jsonify(custom_domain=custom_domain_to_dict(custom_domain)), 200

View File

@ -0,0 +1,49 @@
from flask import g
from flask import jsonify
from app.api.base import api_bp, require_api_auth
from app.models import Alias, Client, CustomDomain
from app.alias_utils import alias_export_csv
@api_bp.route("/export/data", methods=["GET"])
@require_api_auth
def export_data():
"""
Get user data
Output:
Alias, custom domain and app info
"""
user = g.user
data = {
"email": user.email,
"name": user.name,
"aliases": [],
"apps": [],
"custom_domains": [],
}
for alias in Alias.filter_by(user_id=user.id).all(): # type: Alias
data["aliases"].append(dict(email=alias.email, enabled=alias.enabled))
for custom_domain in CustomDomain.filter_by(user_id=user.id).all():
data["custom_domains"].append(custom_domain.domain)
for app in Client.filter_by(user_id=user.id): # type: Client
data["apps"].append(dict(name=app.name, home_url=app.home_url))
return jsonify(data)
@api_bp.route("/export/aliases", methods=["GET"])
@require_api_auth
def export_aliases():
"""
Get user aliases as importable CSV file
Output:
Importable CSV file
"""
return alias_export_csv(g.user)

View File

@ -0,0 +1,208 @@
from smtplib import SMTPRecipientsRefused
import arrow
from flask import g
from flask import jsonify
from flask import request
from app.api.base import api_bp, require_api_auth
from app.config import JOB_DELETE_MAILBOX
from app.dashboard.views.mailbox import send_verification_email
from app.dashboard.views.mailbox_detail import verify_mailbox_change
from app.db import Session
from app.email_utils import (
mailbox_already_used,
email_can_be_used_as_mailbox,
is_valid_email,
)
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import sanitize_email
def mailbox_to_dict(mailbox: Mailbox):
return {
"id": mailbox.id,
"email": mailbox.email,
"verified": mailbox.verified,
"default": mailbox.user.default_mailbox_id == mailbox.id,
"creation_timestamp": mailbox.created_at.timestamp,
"nb_alias": mailbox.nb_alias(),
}
@api_bp.route("/mailboxes", methods=["POST"])
@require_api_auth
def create_mailbox():
"""
Create a new mailbox. User needs to verify the mailbox via an activation email.
Input:
email: in body
Output:
the new mailbox dict
"""
user = g.user
mailbox_email = sanitize_email(request.get_json().get("email"))
if not user.is_premium():
return jsonify(error=f"Only premium plan can add additional mailbox"), 400
if not is_valid_email(mailbox_email):
return jsonify(error=f"{mailbox_email} invalid"), 400
elif mailbox_already_used(mailbox_email, user):
return jsonify(error=f"{mailbox_email} already used"), 400
elif not email_can_be_used_as_mailbox(mailbox_email):
return (
jsonify(
error=f"{mailbox_email} cannot be used. Please note a mailbox cannot "
f"be a disposable email address"
),
400,
)
else:
new_mailbox = Mailbox.create(email=mailbox_email, user_id=user.id)
Session.commit()
send_verification_email(user, new_mailbox)
return (
jsonify(mailbox_to_dict(new_mailbox)),
201,
)
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["DELETE"])
@require_api_auth
def delete_mailbox(mailbox_id):
"""
Delete mailbox
Input:
mailbox_id: in url
Output:
200 if deleted successfully
"""
user = g.user
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id:
return jsonify(error="Forbidden"), 403
if mailbox.id == user.default_mailbox_id:
return jsonify(error="You cannot delete the default mailbox"), 400
# Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create(
name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id},
run_at=arrow.now(),
commit=True,
)
return jsonify(deleted=True), 200
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["PUT"])
@require_api_auth
def update_mailbox(mailbox_id):
"""
Update mailbox
Input:
mailbox_id: in url
(optional) default: in body. Set a mailbox as the default mailbox.
(optional) email: in body. Change a mailbox email.
(optional) cancel_email_change: in body. Cancel mailbox email change.
Output:
200 if updated successfully
"""
user = g.user
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id:
return jsonify(error="Forbidden"), 403
data = request.get_json() or {}
changed = False
if "default" in data:
is_default = data.get("default")
if is_default:
if not mailbox.verified:
return (
jsonify(
error="Unverified mailbox cannot be used as default mailbox"
),
400,
)
user.default_mailbox_id = mailbox.id
changed = True
if "email" in data:
new_email = sanitize_email(data.get("email"))
if mailbox_already_used(new_email, user):
return jsonify(error=f"{new_email} already used"), 400
elif not email_can_be_used_as_mailbox(new_email):
return (
jsonify(
error=f"{new_email} cannot be used. Please note a mailbox cannot "
f"be a disposable email address"
),
400,
)
try:
verify_mailbox_change(user, mailbox, new_email)
except SMTPRecipientsRefused:
return jsonify(error=f"Incorrect mailbox, please recheck {new_email}"), 400
else:
mailbox.new_email = new_email
changed = True
if "cancel_email_change" in data:
cancel_email_change = data.get("cancel_email_change")
if cancel_email_change:
mailbox.new_email = None
changed = True
if changed:
Session.commit()
return jsonify(updated=True), 200
@api_bp.route("/mailboxes", methods=["GET"])
@require_api_auth
def get_mailboxes():
"""
Get verified mailboxes
Output:
- mailboxes: list of mailbox dict
"""
user = g.user
return (
jsonify(mailboxes=[mailbox_to_dict(mb) for mb in user.mailboxes()]),
200,
)
@api_bp.route("/v2/mailboxes", methods=["GET"])
@require_api_auth
def get_mailboxes_v2():
"""
Get all mailboxes - including unverified mailboxes
Output:
- mailboxes: list of mailbox dict
"""
user = g.user
mailboxes = []
for mailbox in Mailbox.filter_by(user_id=user.id):
mailboxes.append(mailbox)
return (
jsonify(mailboxes=[mailbox_to_dict(mb) for mb in mailboxes]),
200,
)

View File

@ -0,0 +1,235 @@
from flask import g
from flask import jsonify, request
from app import parallel_limiter
from app.alias_suffix import check_suffix_signature, verify_prefix_suffix
from app.alias_utils import check_alias_prefix
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
serialize_alias_info_v2,
get_alias_info_v2,
)
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
from app.db import Session
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
AliasUsedOn,
User,
DeletedAlias,
DomainDeletedAlias,
Mailbox,
AliasMailbox,
)
from app.utils import convert_to_id
@api_bp.route("/v2/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v2():
"""
Create a new custom alias
Same as v1 but signed_suffix is actually the suffix with signature, e.g.
.random_word@SL.co.Xq19rQ.s99uWQ7jD1s5JZDZqczYI5TbNNU
Input:
alias_prefix, for ex "www_groupon_com"
signed_suffix, either .random_letters@simplelogin.co or @my-domain.com
optional "hostname" in args
optional "note"
Output:
201 if success
409 if the alias already exists
"""
user: User = g.user
if not user.can_create_new_alias():
LOG.d("user %s cannot create any custom alias", user)
return (
jsonify(
error="You have reached the limitation of a free account with the maximum of "
f"{MAX_NB_EMAIL_FREE_PLAN} aliases, please upgrade your plan to create more aliases"
),
400,
)
hostname = request.args.get("hostname")
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
signed_suffix = data.get("signed_suffix", "").strip()
note = data.get("note")
alias_prefix = convert_to_id(alias_prefix)
try:
alias_suffix = check_suffix_signature(signed_suffix)
if not alias_suffix:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
except Exception:
LOG.w("Alias suffix is tampered, user %s", user)
return jsonify(error="Tampered suffix"), 400
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
return jsonify(error="wrong alias prefix or suffix"), 400
full_alias = alias_prefix + alias_suffix
if (
Alias.get_by(email=full_alias)
or DeletedAlias.get_by(email=full_alias)
or DomainDeletedAlias.get_by(email=full_alias)
):
LOG.d("full alias already used %s", full_alias)
return jsonify(error=f"alias {full_alias} already exists"), 409
if ".." in full_alias:
return (
jsonify(error="2 consecutive dot signs aren't allowed in an email address"),
400,
)
alias = Alias.create(
user_id=user.id,
email=full_alias,
mailbox_id=user.default_mailbox_id,
note=note,
)
Session.commit()
if hostname:
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
Session.commit()
return (
jsonify(alias=full_alias, **serialize_alias_info_v2(get_alias_info_v2(alias))),
201,
)
@api_bp.route("/v3/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v3():
"""
Create a new custom alias
Same as v2 but accept a list of mailboxes as input
Input:
alias_prefix, for ex "www_groupon_com"
signed_suffix, either .random_letters@simplelogin.co or @my-domain.com
mailbox_ids: list of int
optional "hostname" in args
optional "note"
optional "name"
Output:
201 if success
409 if the alias already exists
"""
user: User = g.user
if not user.can_create_new_alias():
LOG.d("user %s cannot create any custom alias", user)
return (
jsonify(
error="You have reached the limitation of a free account with the maximum of "
f"{MAX_NB_EMAIL_FREE_PLAN} aliases, please upgrade your plan to create more aliases"
),
400,
)
hostname = request.args.get("hostname")
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
if type(data) is not dict:
return jsonify(error="request body does not follow the required format"), 400
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
signed_suffix = data.get("signed_suffix", "") or ""
signed_suffix = signed_suffix.strip()
mailbox_ids = data.get("mailbox_ids")
note = data.get("note")
name = data.get("name")
if name:
name = name.replace("\n", "")
alias_prefix = convert_to_id(alias_prefix)
if not check_alias_prefix(alias_prefix):
return jsonify(error="alias prefix invalid format or too long"), 400
# check if mailbox is not tempered with
if type(mailbox_ids) is not list:
return jsonify(error="mailbox_ids must be an array of id"), 400
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Errors with Mailbox"), 400
mailboxes.append(mailbox)
if not mailboxes:
return jsonify(error="At least one mailbox must be selected"), 400
# hypothesis: user will click on the button in the 600 secs
try:
alias_suffix = check_suffix_signature(signed_suffix)
if not alias_suffix:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
except Exception:
LOG.w("Alias suffix is tampered, user %s", user)
return jsonify(error="Tampered suffix"), 400
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
return jsonify(error="wrong alias prefix or suffix"), 400
full_alias = alias_prefix + alias_suffix
if (
Alias.get_by(email=full_alias)
or DeletedAlias.get_by(email=full_alias)
or DomainDeletedAlias.get_by(email=full_alias)
):
LOG.d("full alias already used %s", full_alias)
return jsonify(error=f"alias {full_alias} already exists"), 409
if ".." in full_alias:
return (
jsonify(error="2 consecutive dot signs aren't allowed in an email address"),
400,
)
alias = Alias.create(
user_id=user.id,
email=full_alias,
note=note,
name=name or None,
mailbox_id=mailboxes[0].id,
)
Session.flush()
for i in range(1, len(mailboxes)):
AliasMailbox.create(
alias_id=alias.id,
mailbox_id=mailboxes[i].id,
)
Session.commit()
if hostname:
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
Session.commit()
return (
jsonify(alias=full_alias, **serialize_alias_info_v2(get_alias_info_v2(alias))),
201,
)

View File

@ -0,0 +1,117 @@
import tldextract
from flask import g
from flask import jsonify, request
from app import parallel_limiter
from app.alias_suffix import get_alias_suffixes
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
get_alias_info_v2,
serialize_alias_info_v2,
)
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
from app.db import Session
from app.errors import AliasInTrashError
from app.extensions import limiter
from app.log import LOG
from app.models import Alias, AliasUsedOn, AliasGeneratorEnum
from app.utils import convert_to_id
@api_bp.route("/alias/random/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_random_alias():
"""
Create a new random alias
Input:
(Optional) note
Output:
201 if success
"""
user = g.user
if not user.can_create_new_alias():
LOG.d("user %s cannot create new random alias", user)
return (
jsonify(
error=f"You have reached the limitation of a free account with the maximum of "
f"{MAX_NB_EMAIL_FREE_PLAN} aliases, please upgrade your plan to create more aliases"
),
400,
)
note = None
data = request.get_json(silent=True)
if data:
note = data.get("note")
alias = None
# custom alias suggestion and suffix
hostname = request.args.get("hostname")
if hostname and user.include_website_in_one_click_alias:
LOG.d("Use %s to create new alias", hostname)
# keep only the domain name of hostname, ignore TLD and subdomain
# for ex www.groupon.com -> groupon
ext = tldextract.extract(hostname)
prefix_suggestion = ext.domain
prefix_suggestion = convert_to_id(prefix_suggestion)
suffixes = get_alias_suffixes(user)
# use the first suffix
suggested_alias = prefix_suggestion + suffixes[0].suffix
alias = Alias.get_by(email=suggested_alias)
# cannot use this alias as it belongs to another user
if alias and not alias.user_id == user.id:
LOG.d("%s belongs to another user", alias)
alias = None
elif alias and alias.user_id == user.id:
# make sure alias was created for this website
if AliasUsedOn.get_by(
alias_id=alias.id, hostname=hostname, user_id=alias.user_id
):
LOG.d("Use existing alias %s", alias)
else:
LOG.d("%s wasn't created for this website %s", alias, hostname)
alias = None
elif not alias:
LOG.d("create new alias %s", suggested_alias)
try:
alias = Alias.create(
user_id=user.id,
email=suggested_alias,
note=note,
mailbox_id=user.default_mailbox_id,
commit=True,
)
except AliasInTrashError:
LOG.i("Alias %s is in trash", suggested_alias)
alias = None
if not alias:
scheme = user.alias_generator
mode = request.args.get("mode")
if mode:
if mode == "word":
scheme = AliasGeneratorEnum.word.value
elif mode == "uuid":
scheme = AliasGeneratorEnum.uuid.value
else:
return jsonify(error=f"{mode} must be either word or uuid"), 400
alias = Alias.create_new_random(user=user, scheme=scheme, note=note)
Session.commit()
if hostname and not AliasUsedOn.get_by(alias_id=alias.id, hostname=hostname):
AliasUsedOn.create(
alias_id=alias.id, hostname=hostname, user_id=alias.user_id, commit=True
)
return (
jsonify(alias=alias.email, **serialize_alias_info_v2(get_alias_info_v2(alias))),
201,
)

View File

@ -0,0 +1,83 @@
from flask import g
from flask import jsonify
from flask import request
from app.api.base import api_bp, require_api_auth
from app.config import PAGE_LIMIT
from app.db import Session
from app.models import Notification
@api_bp.route("/notifications", methods=["GET"])
@require_api_auth
def get_notifications():
"""
Get notifications
Input:
- page: in url. Starts at 0
Output:
- more: boolean. Whether there's more notification to load
- notifications: list of notifications.
- id
- message
- title
- read
- created_at
"""
user = g.user
try:
page = int(request.args.get("page"))
except (ValueError, TypeError):
return jsonify(error="page must be provided in request query"), 400
notifications = (
Notification.filter_by(user_id=user.id)
.order_by(Notification.read, Notification.created_at.desc())
.limit(PAGE_LIMIT + 1) # load a record more to know whether there's more
.offset(page * PAGE_LIMIT)
.all()
)
have_more = len(notifications) > PAGE_LIMIT
return (
jsonify(
more=have_more,
notifications=[
{
"id": notification.id,
"message": notification.message,
"title": notification.title,
"read": notification.read,
"created_at": notification.created_at.humanize(),
}
for notification in notifications[:PAGE_LIMIT]
],
),
200,
)
@api_bp.route("/notifications/<int:notification_id>/read", methods=["POST"])
@require_api_auth
def mark_as_read(notification_id):
"""
Mark a notification as read
Input:
notification_id: in url
Output:
200 if updated successfully
"""
user = g.user
notification = Notification.get(notification_id)
if not notification or notification.user_id != user.id:
return jsonify(error="Forbidden"), 403
notification.read = True
Session.commit()
return jsonify(done=True), 200

View File

@ -0,0 +1,51 @@
import arrow
from flask import g
from flask import jsonify
from app.api.base import api_bp, require_api_auth
from app.models import (
PhoneReservation,
PhoneMessage,
)
@api_bp.route("/phone/reservations/<int:reservation_id>", methods=["GET", "POST"])
@require_api_auth
def phone_messages(reservation_id):
"""
Return messages during this reservation
Output:
- messages: list of alias:
- id
- from_number
- body
- created_at: e.g. 5 minutes ago
"""
user = g.user
reservation: PhoneReservation = PhoneReservation.get(reservation_id)
if not reservation or reservation.user_id != user.id:
return jsonify(error="Invalid reservation"), 400
phone_number = reservation.number
messages = PhoneMessage.filter(
PhoneMessage.number_id == phone_number.id,
PhoneMessage.created_at > reservation.start,
PhoneMessage.created_at < reservation.end,
).all()
return (
jsonify(
messages=[
{
"id": message.id,
"from_number": message.from_number,
"body": message.body,
"created_at": message.created_at.humanize(),
}
for message in messages
],
ended=reservation.end < arrow.now(),
),
200,
)

View File

@ -0,0 +1,148 @@
import arrow
from flask import jsonify, g, request
from app.api.base import api_bp, require_api_auth
from app.db import Session
from app.log import LOG
from app.models import (
User,
AliasGeneratorEnum,
SLDomain,
CustomDomain,
SenderFormatEnum,
AliasSuffixEnum,
)
from app.proton.utils import perform_proton_account_unlink
def setting_to_dict(user: User):
ret = {
"notification": user.notification,
"alias_generator": "word"
if user.alias_generator == AliasGeneratorEnum.word.value
else "uuid",
"random_alias_default_domain": user.default_random_alias_domain(),
# return the default sender format (AT) in case user uses a non-supported sender format
"sender_format": SenderFormatEnum.get_name(user.sender_format)
or SenderFormatEnum.AT.name,
"random_alias_suffix": AliasSuffixEnum.get_name(user.random_alias_suffix),
}
return ret
@api_bp.route("/setting")
@require_api_auth
def get_setting():
"""
Return user setting
"""
user = g.user
return jsonify(setting_to_dict(user))
@api_bp.route("/setting", methods=["PATCH"])
@require_api_auth
def update_setting():
"""
Update user setting
Input:
- notification: bool
- alias_generator: word|uuid
- random_alias_default_domain: str
"""
user = g.user
data = request.get_json() or {}
if "notification" in data:
user.notification = data["notification"]
if "alias_generator" in data:
alias_generator = data["alias_generator"]
if alias_generator not in ["word", "uuid"]:
return jsonify(error="Invalid alias_generator"), 400
if alias_generator == "word":
user.alias_generator = AliasGeneratorEnum.word.value
else:
user.alias_generator = AliasGeneratorEnum.uuid.value
if "sender_format" in data:
sender_format = data["sender_format"]
if not SenderFormatEnum.has_name(sender_format):
return jsonify(error="Invalid sender_format"), 400
user.sender_format = SenderFormatEnum.get_value(sender_format)
user.sender_format_updated_at = arrow.now()
if "random_alias_suffix" in data:
random_alias_suffix = data["random_alias_suffix"]
if not AliasSuffixEnum.has_name(random_alias_suffix):
return jsonify(error="Invalid random_alias_suffix"), 400
user.random_alias_suffix = AliasSuffixEnum.get_value(random_alias_suffix)
if "random_alias_default_domain" in data:
default_domain = data["random_alias_default_domain"]
sl_domain: SLDomain = SLDomain.get_by(domain=default_domain)
if sl_domain:
if sl_domain.premium_only and not user.is_premium():
return jsonify(error="You cannot use this domain"), 400
user.default_alias_public_domain_id = sl_domain.id
user.default_alias_custom_domain_id = None
else:
custom_domain = CustomDomain.get_by(domain=default_domain)
if not custom_domain:
return jsonify(error="invalid domain"), 400
# sanity check
if custom_domain.user_id != user.id or not custom_domain.verified:
LOG.w("%s cannot use domain %s", user, default_domain)
return jsonify(error="invalid domain"), 400
else:
user.default_alias_custom_domain_id = custom_domain.id
user.default_alias_public_domain_id = None
Session.commit()
return jsonify(setting_to_dict(user))
@api_bp.route("/setting/domains")
@require_api_auth
def get_available_domains_for_random_alias():
"""
Available domains for random alias
"""
user = g.user
ret = [
(is_sl, domain) for is_sl, domain in user.available_domains_for_random_alias()
]
return jsonify(ret)
@api_bp.route("/v2/setting/domains")
@require_api_auth
def get_available_domains_for_random_alias_v2():
"""
Available domains for random alias
"""
user = g.user
ret = [
{"domain": domain, "is_custom": not is_sl}
for is_sl, domain in user.available_domains_for_random_alias()
]
return jsonify(ret)
@api_bp.route("/setting/unlink_proton_account", methods=["DELETE"])
@require_api_auth
def unlink_proton_account():
user = g.user
perform_proton_account_unlink(user)
return jsonify({"ok": True})

27
app/app/api/views/sudo.py Normal file
View File

@ -0,0 +1,27 @@
from flask import jsonify, g, request
from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_auth
from app.db import Session
@api_bp.route("/sudo", methods=["PATCH"])
@require_api_auth
def enter_sudo():
"""
Enter sudo mode
Input
- password: user password to validate request to enter sudo mode
"""
user = g.user
data = request.get_json() or {}
if "password" not in data:
return jsonify(error="Invalid password"), 403
if not user.check_password(data["password"]):
return jsonify(error="Invalid password"), 403
g.api_key.sudo_mode_at = arrow.now()
Session.commit()
return jsonify(ok=True)

46
app/app/api/views/user.py Normal file
View File

@ -0,0 +1,46 @@
from flask import jsonify, g
from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_sudo, require_api_auth
from app import config
from app.extensions import limiter
from app.log import LOG
from app.models import Job, ApiToCookieToken
@api_bp.route("/user", methods=["DELETE"])
@require_api_sudo
def delete_user():
"""
Delete the user. Requires sudo mode.
"""
# Schedule delete account job
LOG.w("schedule delete account job for %s", g.user)
Job.create(
name=config.JOB_DELETE_ACCOUNT,
payload={"user_id": g.user.id},
run_at=arrow.now(),
commit=True,
)
return jsonify(ok=True)
@api_bp.route("/user/cookie_token", methods=["GET"])
@require_api_auth
@limiter.limit("5/minute")
def get_api_session_token():
"""
Get a temporary token to exchange it for a cookie based session
Output:
200 and a temporary random token
{
token: "asdli3ldq39h9hd3",
}
"""
token = ApiToCookieToken.create(
user=g.user,
api_key_id=g.api_key.id,
commit=True,
)
return jsonify({"token": token.code})

View File

@ -0,0 +1,138 @@
import base64
from io import BytesIO
from typing import Optional
from flask import jsonify, g, request, make_response
from app import s3, config
from app.api.base import api_bp, require_api_auth
from app.config import SESSION_COOKIE_NAME
from app.db import Session
from app.models import ApiKey, File, PartnerUser, User
from app.proton.utils import get_proton_partner
from app.session import logout_session
from app.utils import random_string
def get_connected_proton_address(user: User) -> Optional[str]:
proton_partner = get_proton_partner()
partner_user = PartnerUser.get_by(user_id=user.id, partner_id=proton_partner.id)
if partner_user is None:
return None
return partner_user.partner_email
def user_to_dict(user: User) -> dict:
ret = {
"name": user.name or "",
"is_premium": user.is_premium(),
"email": user.email,
"in_trial": user.in_trial(),
"max_alias_free_plan": user.max_alias_for_free_account(),
"connected_proton_address": None,
}
if config.CONNECT_WITH_PROTON:
ret["connected_proton_address"] = get_connected_proton_address(user)
if user.profile_picture_id:
ret["profile_picture_url"] = user.profile_picture.get_url()
else:
ret["profile_picture_url"] = None
return ret
@api_bp.route("/user_info")
@require_api_auth
def user_info():
"""
Return user info given the api-key
Output as json
- name
- is_premium
- email
- in_trial
- max_alias_free
- is_connected_with_proton
"""
user = g.user
return jsonify(user_to_dict(user))
@api_bp.route("/user_info", methods=["PATCH"])
@require_api_auth
def update_user_info():
"""
Input
- profile_picture (optional): base64 of the profile picture. Set to null to remove the profile picture
- name (optional)
"""
user = g.user
data = request.get_json() or {}
if "profile_picture" in data:
if data["profile_picture"] is None:
if user.profile_picture_id:
file = user.profile_picture
user.profile_picture_id = None
Session.flush()
if file:
File.delete(file.id)
s3.delete(file.path)
Session.flush()
else:
raw_data = base64.decodebytes(data["profile_picture"].encode())
file_path = random_string(30)
file = File.create(user_id=user.id, path=file_path)
Session.flush()
s3.upload_from_bytesio(file_path, BytesIO(raw_data))
user.profile_picture_id = file.id
Session.flush()
if "name" in data:
user.name = data["name"]
Session.commit()
return jsonify(user_to_dict(user))
@api_bp.route("/api_key", methods=["POST"])
@require_api_auth
def create_api_key():
"""Used to create a new api key
Input:
- device
Output:
- api_key
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
device = data.get("device")
api_key = ApiKey.create(user_id=g.user.id, name=device)
Session.commit()
return jsonify(api_key=api_key.code), 201
@api_bp.route("/logout", methods=["GET"])
@require_api_auth
def logout():
"""
Log user out on the web, i.e. remove the cookie
Output:
- 200
"""
logout_session()
response = make_response(jsonify(msg="User is logged out"), 200)
response.delete_cookie(SESSION_COOKIE_NAME)
return response