4.21.3
This commit is contained in:
0
app/app/api/views/__init__.py
Normal file
0
app/app/api/views/__init__.py
Normal file
474
app/app/api/views/alias.py
Normal file
474
app/app/api/views/alias.py
Normal 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
|
153
app/app/api/views/alias_options.py
Normal file
153
app/app/api/views/alias_options.py
Normal 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
559
app/app/api/views/apple.py
Normal 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
383
app/app/api/views/auth.py
Normal 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)
|
75
app/app/api/views/auth_mfa.py
Normal file
75
app/app/api/views/auth_mfa.py
Normal 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
|
126
app/app/api/views/custom_domain.py
Normal file
126
app/app/api/views/custom_domain.py
Normal 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
|
49
app/app/api/views/export.py
Normal file
49
app/app/api/views/export.py
Normal 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)
|
208
app/app/api/views/mailbox.py
Normal file
208
app/app/api/views/mailbox.py
Normal 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,
|
||||
)
|
235
app/app/api/views/new_custom_alias.py
Normal file
235
app/app/api/views/new_custom_alias.py
Normal 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,
|
||||
)
|
117
app/app/api/views/new_random_alias.py
Normal file
117
app/app/api/views/new_random_alias.py
Normal 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,
|
||||
)
|
83
app/app/api/views/notification.py
Normal file
83
app/app/api/views/notification.py
Normal 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
|
51
app/app/api/views/phone.py
Normal file
51
app/app/api/views/phone.py
Normal 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,
|
||||
)
|
148
app/app/api/views/setting.py
Normal file
148
app/app/api/views/setting.py
Normal 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
27
app/app/api/views/sudo.py
Normal 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
46
app/app/api/views/user.py
Normal 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})
|
138
app/app/api/views/user_info.py
Normal file
138
app/app/api/views/user_info.py
Normal 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
|
Reference in New Issue
Block a user