├── tests ├── __init__.py ├── test_oauth.py └── test_mongo_db.py ├── pm2.json ├── logger.py ├── Pipfile ├── lemon.mthli.com.conf ├── rds.py ├── LICENSE ├── README.md ├── mongo ├── db.py ├── users.py ├── orders.py ├── licenses.py └── subscriptions.py ├── .gitignore ├── oauth.py ├── lemon.py ├── app.py └── Pipfile.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Fix `ModuleNotFoundError` with pytest. 2 | # https://stackoverflow.com/a/65383002 3 | -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "lemonsqueepy", 5 | "script": "python3 -m pipenv run hypercorn app:app", 6 | "exec_mode": "fork", 7 | "kill_timeout": 5000, 8 | "listen_timeout": 10000, 9 | "max_memory_restart": "256M", 10 | "watch": false 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | _fmt = '[%(asctime)s] [%(process)d] [%(levelname)s] [%(module)s] %(message)s' 4 | _datefmt = '%Y-%m-%d %H:%M:%S %z' 5 | _handler = logging.StreamHandler() 6 | _handler.setFormatter(logging.Formatter(fmt=_fmt, datefmt=_datefmt)) 7 | 8 | logger = logging.getLogger() 9 | logger.addHandler(_handler) 10 | logger.setLevel(logging.INFO) 11 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | redis = "*" 8 | motor = "*" 9 | quart = "*" 10 | quart-cors = "*" 11 | hypercorn = "*" 12 | strenum = "*" 13 | validators = "*" 14 | pyjwt = "*" 15 | pycryptodome = "*" 16 | python-dateutil = "*" 17 | async-lru = "*" 18 | httpx = "*" 19 | tenacity = "*" 20 | cryptography = "*" 21 | 22 | [dev-packages] 23 | autopep8 = "*" 24 | ipython = "*" 25 | pytest = "*" 26 | pytest-asyncio = "*" 27 | pytest-sugar = "*" 28 | 29 | [requires] 30 | python_version = "3.9" 31 | -------------------------------------------------------------------------------- /lemon.mthli.com.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name lemon.mthli.com; 5 | 6 | location / { 7 | proxy_pass http://127.0.0.1:8000; 8 | 9 | proxy_http_version 1.1; 10 | proxy_redirect off; 11 | 12 | proxy_connect_timeout 300; 13 | proxy_read_timeout 300; 14 | proxy_send_timeout 300; 15 | 16 | proxy_set_header Host $host; 17 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 18 | proxy_set_header X-Forwarded-Host $host; 19 | proxy_set_header X-Forwarded-Prefix /; 20 | proxy_set_header X-Forwarded-Proto $scheme; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/test_oauth.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | 4 | from oauth import generate_user_token, decrypt_user_token 5 | 6 | # Make sure that secret is a **16 characters length** string. 7 | _LEMONSQUEEZY_SIGNING_SECRET = '0123456789abcdef' 8 | 9 | 10 | def test_generate_user_token(): 11 | assert generate_user_token( 12 | user_id=str(uuid.uuid4()), 13 | timestamp=int(time.time()), 14 | secret=_LEMONSQUEEZY_SIGNING_SECRET, 15 | ) 16 | 17 | 18 | def test_decrypt_user_token(): 19 | user_id = str(uuid.uuid4()) 20 | timestamp = int(time.time()) 21 | 22 | token = generate_user_token( 23 | user_id=user_id, 24 | timestamp=timestamp, 25 | secret=_LEMONSQUEEZY_SIGNING_SECRET, 26 | ) 27 | 28 | info = decrypt_user_token( 29 | token=token, 30 | secret=_LEMONSQUEEZY_SIGNING_SECRET, 31 | ) 32 | 33 | assert info.user_id == user_id 34 | assert info.generate_timestamp == timestamp 35 | -------------------------------------------------------------------------------- /tests/test_mongo_db.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from mongo.db import convert_id_to_str_in_json, \ 4 | convert_at_to_datetime_in_json 5 | 6 | 7 | def test_convert_id_to_str_in_json(): 8 | data = { 9 | 'id': 1, 10 | 'l': [{ 11 | 'd': { 12 | '_id': 1, 13 | 'uid': 2, 14 | 'user_id': '...', 15 | }, 16 | }], 17 | } 18 | 19 | convert_id_to_str_in_json(data) 20 | 21 | assert data['id'] == '1' 22 | assert data['l'][0]['d']['_id'] == '1' 23 | assert isinstance(data['l'][0]['d']['uid'], int) 24 | assert isinstance(data['l'][0]['d']['user_id'], str) 25 | 26 | 27 | def test_convert_at_to_datetime_in_json(): 28 | data = { 29 | 'l': [{ 30 | 'd': { 31 | 't1': '2023-01-17T12:26:23.000000Z', 32 | 't1_at': '2023-01-17T12:26:23.000000Z', 33 | 't2_at': 1691487612, 34 | 't3_at': '...', 35 | }, 36 | }], 37 | } 38 | 39 | convert_at_to_datetime_in_json(data) 40 | 41 | assert isinstance(data['l'][0]['d']['t1'], str) 42 | assert isinstance(data['l'][0]['d']['t1_at'], datetime) 43 | assert isinstance(data['l'][0]['d']['t2_at'], int) 44 | assert isinstance(data['l'][0]['d']['t3_at'], str) 45 | -------------------------------------------------------------------------------- /rds.py: -------------------------------------------------------------------------------- 1 | import redis 2 | 3 | from quart import abort 4 | 5 | # For checking whether the credential issuer is our own. 6 | # https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id 7 | # 8 | # You may have multiple client ids, it's ok, 9 | # just execute `SADD google_oauth_client_ids "..."` in redis-cli. 10 | GOOGLE_OAUTH_CLIENT_IDS = 'google_oauth_client_ids' # set. 11 | 12 | # For checking whether the requests are sent from Lemon Squeezy. 13 | # https://docs.lemonsqueezy.com/help/webhooks#signing-requests 14 | # 15 | # We also use this secret to generate user token with AES-128 algorithm, 16 | # so please make sure that this secret is a **16 characters length** string, 17 | # and do not contain any leading and trailing whitespace characters, 18 | # for example "0123456789abcdef" (don't use it, just a example, haha). 19 | LEMONSQUEEZY_SIGNING_SECRET = 'lemonsqueezy_signing_secret' # string. 20 | 21 | # Interact with the Lemon Squeezy backend. 22 | # https://docs.lemonsqueezy.com/guides/developer-guide/getting-started#api-overview 23 | LEMONSQUEEZY_API_KEY = 'lemonsqueezy_api_key' # string. 24 | 25 | # Default host and port. 26 | rds = redis.from_url('redis://localhost:6379') 27 | 28 | 29 | def get_str_from_rds(key: str) -> str: 30 | value = rds.get(key) 31 | if not value: 32 | abort(500, f'"{key}" not exists') 33 | 34 | value = value.decode().strip() 35 | if not value: 36 | abort(500, f'"{key}" is empty') 37 | 38 | return value 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Matthew Lee 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lemonsqueepy 2 | 3 | [Lemon Squeezy](https://www.lemonsqueezy.com/) with Python 🐍 4 | 5 | This project is an **account and payment** manage system for Web App, 6 | fills in the missing pieces for accessing Lemon Squeezy, 7 | significantly reduce your development costs. 8 | 9 | Currently we already support these scenarios: 10 | 11 | - [x] Sign in with Google 12 | - [x] Check order is available or not 13 | - [x] Check subscription is available or not 14 | - [x] Check license is available or not 15 | - [x] Activate license 16 | 17 | You can use it as: 18 | 19 | - A standalone service (recommend), and make RESTful requests from your front-end application 20 | - A standalone service (recommend), and make RPC requests from other services (Node.js, Go, etc.) 21 | - A Python web framework, and develop some busniess logical based on it 22 | 23 | [For more details and tutorials, please checkout the Wiki](https://github.com/mthli/lemonsqueepy/wiki). 24 | 25 | If you want to experience or support this project, please make an order or subscription in the [demo page](https://lemontree.vercel.app/). 26 | 27 | --- 28 | 29 | 本项目是一个为 Web App 设计的 **帐号和支付** 管理系统,可以显著降低你接入 Lemon Squeezy 的开发成本。 30 | 31 | 目前我们已经支持如下场景: 32 | 33 | - [x] 谷歌登录 34 | - [x] 校验订单是否可用 35 | - [x] 校验订阅是否可用 36 | - [x] 校验证书是否可用 37 | - [x] 激活证书 38 | 39 | 你可以将这个项目用于: 40 | 41 | - 一个独立的服务(推荐),并从你的前端应用中直接发起 RESTful 请求 42 | - 一个独立的服务(推荐),并从 Node.js 或者 Go 等语言实现的后端服务中发起 RPC 请求 43 | - 一个 Python 网络框架,并在其中添加自己的业务逻辑 44 | 45 | [更多详细信息和教程,请参阅 Wiki 链接](https://github.com/mthli/lemonsqueepy/wiki)。 46 | 47 | 如果你想体验或者支持本项目,请在 [示例页面](https://lemontree.vercel.app/) 下单或者发起订阅。 48 | 49 | ## License 50 | 51 | ``` 52 | BSD 3-Clause License 53 | 54 | Copyright (c) 2023, Matthew Lee 55 | ``` 56 | -------------------------------------------------------------------------------- /mongo/db.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Union 3 | 4 | from dateutil import parser 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | 7 | # Default host and port. 8 | _client = AsyncIOMotorClient('localhost', 27017) 9 | _db = _client['lemonsqueezy'] # database. 10 | 11 | users = _db['users'] # collection. 12 | orders = _db['orders'] # collection. 13 | licenses = _db['licenses'] # collection. 14 | subscriptions = _db['subscriptions'] # collection. 15 | subscription_payments = _db['subscription_payments'] # collection. 16 | 17 | 18 | # https://lemonsqueezy.nolt.io/234 19 | # 20 | # The `id` and `_id` fields are mixing str and int in origin webhooks request, 21 | # don't know why, but we should choose str as the type based on the following reasons: 22 | # 23 | # 1. We should avoid id number of int insufficient (get rich). 24 | # 2. Go unmarshall JSON number as float64 (loss of precision). 25 | def convert_id_to_str_in_json(data: Any): 26 | if isinstance(data, list): 27 | for item in data: 28 | convert_id_to_str_in_json(item) 29 | elif isinstance(data, dict): 30 | for key, value in data.items(): 31 | if not isinstance(key, str): 32 | raise TypeError(f'key must be string instead of {type(key)}') 33 | if (key == 'id' or key.endswith('_id')) and isinstance(value, int): 34 | data[key] = str(value) 35 | else: 36 | convert_id_to_str_in_json(value) 37 | 38 | 39 | # Assume that `data` is created from `json.loads()`, 40 | # convert all ISO-8601 formatted date-time `_at` fields to datetime type, 41 | # so we don't need to use MongoDB Aggregation Operations in those fields. 42 | # 43 | # Same as: 44 | # https://pymongo.readthedocs.io/en/stable/examples/datetimes.html 45 | def convert_at_to_datetime_in_json(data: Any): 46 | if isinstance(data, list): 47 | for item in data: 48 | convert_at_to_datetime_in_json(item) 49 | elif isinstance(data, dict): 50 | for key, value in data.items(): 51 | if not isinstance(key, str): 52 | raise TypeError(f'key must be string instead of {type(key)}') 53 | if not isinstance(value, str) or not key.endswith('_at'): 54 | convert_at_to_datetime_in_json(value) 55 | continue 56 | try: 57 | data[key] = parser.isoparse(value) 58 | except Exception: 59 | pass # DO NOTHING. 60 | 61 | 62 | # https://stackoverflow.com/a/42777551 63 | def convert_datetime_to_isoformat_with_z(dt: Union[datetime, str]) -> str: 64 | if isinstance(dt, str): 65 | return dt 66 | return dt.isoformat().replace('+00:00', 'Z') 67 | -------------------------------------------------------------------------------- /mongo/users.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, asdict 2 | from typing import Optional 3 | 4 | from async_lru import alru_cache 5 | from quart import abort 6 | 7 | from mongo.db import users 8 | 9 | 10 | @dataclass 11 | class User: 12 | id: str = '' # required; uuid, as primary key. 13 | token: str = '' # required; should be unique too. 14 | email: str = '' # optional; have to exist after oauth. 15 | name: str = '' # optional; maybe exist even if oauth. 16 | avatar: str = '' # optional; maybe exist even if oauth. 17 | create_timestamp: int = 0 # required; in seconds. 18 | update_timestamp: int = 0 # required; in seconds. 19 | 20 | 21 | @dataclass 22 | class Token: 23 | ciphertext: str = '' # required. 24 | tag: str = '' # required. 25 | nonce: str = '' # required. 26 | 27 | 28 | @dataclass 29 | class TokenInfo: 30 | user_id: str = '' # required. 31 | generate_timestamp: int = 0 # required; in seconds. 32 | 33 | 34 | # MongoDB does not recreate the index if it already exists. 35 | # https://www.mongodb.com/community/forums/t/behavior-of-createindex-for-an-existing-index/2248/2 36 | async def setup_users(): 37 | await users.create_index('id', unique=True, background=True) # str. 38 | await users.create_index('token', unique=True, background=True) # str. 39 | await users.create_index('email', background=True) # str. 40 | await users.create_index('create_timestamp', background=True) # int. 41 | await users.create_index('update_timestamp', background=True) # int. 42 | 43 | 44 | async def find_user_by_email(email: str) -> Optional[User]: 45 | return await _find_user_by_('email', email) 46 | 47 | 48 | async def find_user_by_token(token: str) -> Optional[User]: 49 | return await _find_user_by_('token', token) 50 | 51 | 52 | @alru_cache(ttl=10) 53 | async def _find_user_by_(key: str, value: str) -> Optional[User]: 54 | res: dict = await users.find_one({key: value}) 55 | if not res: 56 | return None 57 | 58 | return User( 59 | id=res['id'], 60 | email=res['email'], 61 | token=res['token'], 62 | name=res['name'], 63 | avatar=res['avatar'], 64 | create_timestamp=res['create_timestamp'], 65 | update_timestamp=res['update_timestamp'], 66 | ) 67 | 68 | 69 | async def upsert_user(user: User): 70 | if not user.id \ 71 | or not user.token \ 72 | or not user.create_timestamp \ 73 | or not user.update_timestamp: 74 | abort(500, 'invalid user object') 75 | 76 | await users.update_one( 77 | {'id': user.id}, 78 | {'$set': asdict(user)}, 79 | upsert=True, 80 | ) 81 | 82 | _find_user_by_.cache_clear() 83 | -------------------------------------------------------------------------------- /mongo/orders.py: -------------------------------------------------------------------------------- 1 | from enum import unique 2 | from typing import Optional 3 | 4 | from async_lru import alru_cache 5 | from strenum import StrEnum 6 | 7 | from mongo.db import orders, convert_datetime_to_isoformat_with_z 8 | 9 | 10 | @unique 11 | class Status(StrEnum): 12 | PENDING = 'pending' 13 | PAID = 'paid' 14 | FAILED = 'failed' 15 | REFUNDED = 'refunded' 16 | 17 | 18 | async def setup_orders(): 19 | await orders.create_index('meta.event_name', background=True) # nopep8; str. 20 | await orders.create_index('meta.custom_data.user_id', background=True) # nopep8; str. 21 | 22 | await orders.create_index('data.id', background=True) # nopep8; str, as the `order_id`. 23 | await orders.create_index('data.attributes.store_id', background=True) # nopep8; str. 24 | await orders.create_index('data.attributes.customer_id', background=True) # nopep8; str. 25 | await orders.create_index('data.attributes.identifier', background=True) # nopep8; str. 26 | 27 | await orders.create_index('data.attributes.user_email', background=True) # nopep8; str. 28 | await orders.create_index('data.attributes.status', background=True) # nopep8; str. 29 | 30 | await orders.create_index('data.attributes.first_order_item.id', background=True) # nopep8; str, as the `order_item_id`. 31 | await orders.create_index('data.attributes.first_order_item.order_id', background=True) # nopep8; str, as the `order_id`. 32 | await orders.create_index('data.attributes.first_order_item.product_id', background=True) # nopep8; str, as the `product_id`. 33 | await orders.create_index('data.attributes.first_order_item.variant_id', background=True) # nopep8; str, as the `variant_id`. 34 | 35 | await orders.create_index('data.attributes.created_at', background=True) # nopep8; datetime. 36 | await orders.create_index('data.attributes.updated_at', background=True) # nopep8; datetime. 37 | 38 | 39 | # https://docs.lemonsqueezy.com/api/orders#the-order-object 40 | # https://docs.lemonsqueezy.com/help/webhooks#example-payloads 41 | # 42 | # You will notice that the `data` in the payload is the order object, 43 | # plus some `meta` and the usual `relationships` and `links`. 44 | async def insert_order(order: dict): 45 | await orders.insert_one(order) 46 | find_latest_order.cache_clear() 47 | 48 | 49 | @alru_cache(ttl=10) 50 | async def find_latest_order( 51 | user_id: str, 52 | store_id: str, 53 | product_id: str, 54 | variant_id: str, 55 | test_mode: bool = False, 56 | ) -> Optional[dict]: 57 | cursor = orders \ 58 | .find({ 59 | 'meta.custom_data.user_id': user_id, 60 | 'data.attributes.store_id': store_id, 61 | 'data.attributes.first_order_item.product_id': product_id, 62 | 'data.attributes.first_order_item.variant_id': variant_id, 63 | 'data.attributes.test_mode': test_mode, 64 | }) \ 65 | .sort('data.attributes.updated_at', -1) \ 66 | .limit(1) 67 | 68 | res: list[dict] = [] 69 | async for order in cursor: 70 | res.append(order) 71 | 72 | return res[0] if res else None 73 | 74 | 75 | def convert_order_to_response(order: dict) -> dict: 76 | status = order['data']['attributes']['status'] 77 | 78 | created_at = order['data']['attributes']['created_at'] 79 | created_at = convert_datetime_to_isoformat_with_z(created_at) 80 | 81 | updated_at = order['data']['attributes']['updated_at'] 82 | updated_at = convert_datetime_to_isoformat_with_z(updated_at) 83 | 84 | return { 85 | 'available': status == str(Status.PAID), 86 | 'status': status, 87 | 'created_at': created_at, 88 | 'updated_at': updated_at, 89 | } 90 | -------------------------------------------------------------------------------- /mongo/licenses.py: -------------------------------------------------------------------------------- 1 | from enum import unique 2 | from typing import Optional 3 | 4 | from async_lru import alru_cache 5 | from strenum import StrEnum 6 | 7 | from mongo.db import licenses, convert_datetime_to_isoformat_with_z 8 | 9 | 10 | @unique 11 | class Status(StrEnum): 12 | INACTIVE = 'inactive' 13 | ACTIVE = 'active' 14 | EXPIRED = 'expired' 15 | DISABLED = 'disabled' 16 | 17 | 18 | async def setup_licenses(): 19 | await licenses.create_index('meta.event_name', background=True) # nopep8; str. 20 | await licenses.create_index('meta.custom_data.user_id', background=True) # nopep8; str. 21 | 22 | await licenses.create_index('data.id', background=True) # nopep8; str, as the `license_id`. 23 | await licenses.create_index('data.attributes.store_id', background=True) # nopep8; str. 24 | await licenses.create_index('data.attributes.customer_id', background=True) # nopep8; str. 25 | await licenses.create_index('data.attributes.order_id', background=True) # nopep8; str. 26 | await licenses.create_index('data.attributes.order_item_id', background=True) # nopep8; str. 27 | await licenses.create_index('data.attributes.product_id', background=True) # nopep8; str. 28 | 29 | await licenses.create_index('data.attributes.user_email', background=True) # nopep8; str. 30 | await licenses.create_index('data.attributes.key', background=True) # nopep8; str. 31 | await licenses.create_index('data.attributes.key_short', background=True) # nopep8; str. 32 | await licenses.create_index('data.attributes.status', background=True) # nopep8; str. 33 | 34 | await licenses.create_index('data.attributes.created_at', background=True) # nopep8; datetime. 35 | await licenses.create_index('data.attributes.updated_at', background=True) # nopep8; datetime. 36 | 37 | 38 | # https://docs.lemonsqueezy.com/api/license-keys#the-license-key-object 39 | # https://docs.lemonsqueezy.com/help/webhooks#example-payloads 40 | # 41 | # You will notice that the `data` in the payload is the order object, 42 | # plus some `meta` and the usual `relationships` and `links`. 43 | async def insert_license(license: dict): 44 | await licenses.insert_one(license) 45 | find_latest_license.cache_clear() 46 | 47 | 48 | # Based on the upper API specs, the `license_key` is unique in all stores. 49 | # https://docs.lemonsqueezy.com/api/license-keys#retrieve-a-license-key 50 | @alru_cache(ttl=10) 51 | async def find_latest_license( 52 | license_key: str, 53 | test_mode: bool = False, 54 | ) -> Optional[dict]: 55 | cursor = licenses \ 56 | .find({ 57 | 'data.attributes.key': license_key, 58 | 'data.attributes.test_mode': test_mode, 59 | }) \ 60 | .sort('data.attributes.updated_at', -1) \ 61 | .limit(1) 62 | 63 | res: list[dict] = [] 64 | async for order in cursor: 65 | res.append(order) 66 | 67 | return res[0] if res else None 68 | 69 | 70 | def convert_license_to_response(license: dict) -> dict: 71 | status = license['data']['attributes']['status'] 72 | activation_limit = license['data']['attributes']['activation_limit'] 73 | instances_count = license['data']['attributes']['instances_count'] 74 | 75 | created_at = license['data']['attributes']['created_at'] 76 | created_at = convert_datetime_to_isoformat_with_z(created_at) 77 | 78 | updated_at = license['data']['attributes']['updated_at'] 79 | updated_at = convert_datetime_to_isoformat_with_z(updated_at) 80 | 81 | return { 82 | 'available': status == str(Status.ACTIVE), 83 | 'status': status, 84 | 'activation_limit': activation_limit, 85 | 'instances_count': instances_count, 86 | 'created_at': created_at, 87 | 'updated_at': updated_at, 88 | } 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Others 163 | .vscode/ 164 | .DS_Store 165 | -------------------------------------------------------------------------------- /oauth.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | import jwt 5 | import validators 6 | 7 | from base64 import b64encode, b64decode 8 | from dataclasses import asdict 9 | from typing import Optional 10 | from uuid import uuid4 11 | 12 | from Crypto.Cipher import AES 13 | from jwt import PyJWKClient 14 | from quart import abort 15 | from validators import ValidationFailure 16 | 17 | from logger import logger 18 | from mongo.users import User, Token, TokenInfo, \ 19 | find_user_by_email, \ 20 | find_user_by_token, \ 21 | upsert_user 22 | from rds import rds, get_str_from_rds, \ 23 | GOOGLE_OAUTH_CLIENT_IDS, \ 24 | LEMONSQUEEZY_SIGNING_SECRET 25 | 26 | # Unsupported asyncio for now. 27 | _google_jwk_client = PyJWKClient( 28 | uri='https://www.googleapis.com/oauth2/v3/certs', 29 | cache_jwk_set=True, 30 | lifespan=86400, # seconds of 1 day. 31 | timeout=10, # seconds. 32 | ) 33 | 34 | 35 | # https://onboardbase.com/blog/aes-encryption-decryption/ 36 | def generate_user_token(user_id: str, timestamp: int, secret: str = '') -> str: 37 | secret = secret.strip() 38 | if not secret: 39 | secret = get_str_from_rds(LEMONSQUEEZY_SIGNING_SECRET) 40 | if len(secret) != 16: 41 | abort(500, f'"{LEMONSQUEEZY_SIGNING_SECRET}" must be 16 characters length string') # nopep8. 42 | 43 | info = TokenInfo(user_id=user_id, generate_timestamp=timestamp) 44 | info = json.dumps(asdict(info)).encode() 45 | 46 | cipher = AES.new(secret.encode(), AES.MODE_EAX) 47 | ciphertext, tag = cipher.encrypt_and_digest(info) # bytes. 48 | nonce: bytes = cipher.nonce 49 | 50 | token = Token( 51 | ciphertext=b64encode(ciphertext).decode(), 52 | tag=b64encode(tag).decode(), 53 | nonce=b64encode(nonce).decode(), 54 | ) 55 | 56 | token = json.dumps(asdict(token)).encode() 57 | return b64encode(token).decode() 58 | 59 | 60 | # https://onboardbase.com/blog/aes-encryption-decryption/ 61 | def decrypt_user_token(token: str, secret: str = '') -> TokenInfo: 62 | secret = secret.strip() 63 | if not secret: 64 | secret = get_str_from_rds(LEMONSQUEEZY_SIGNING_SECRET) 65 | if len(secret) != 16: 66 | abort(500, f'"{LEMONSQUEEZY_SIGNING_SECRET}" must be 16 characters length string') # nopep8. 67 | 68 | token: Token = Token(**json.loads(b64decode(token))) 69 | cipher = AES.new(secret.encode(), AES.MODE_EAX, b64decode(token.nonce)) 70 | info = cipher.decrypt_and_verify(b64decode(token.ciphertext), b64decode(token.tag)) # nopep8. 71 | return TokenInfo(**json.loads(info)) 72 | 73 | 74 | async def upsert_user_from_google_oauth( 75 | credential: str, 76 | user_token: str = '', 77 | verify_exp: bool = False, 78 | ) -> User: 79 | payload: dict = {} 80 | 81 | client_ids = rds.smembers(GOOGLE_OAUTH_CLIENT_IDS) 82 | for cid in client_ids: 83 | try: 84 | payload = _decode_google_oauth_credential( 85 | credential=credential, 86 | client_id=cid.decode(), 87 | verify_exp=verify_exp, 88 | ) 89 | except Exception as e: 90 | logger.exception('_decode_google_oauth_credential') 91 | latest_err = e 92 | pass # DO NOTHING. 93 | if not payload: 94 | abort(401, f'invalid credential, credential={credential}') 95 | 96 | email = payload.get('email', '').strip() 97 | if not email: 98 | abort(401, '"email" not exists') 99 | if isinstance(validators.email(email), ValidationFailure): 100 | abort(401, f'invalid "email", email={email}') 101 | 102 | name = payload.get('name', '').strip() 103 | avatar = payload.get('picture', '').strip() 104 | timestamp = int(time.time()) 105 | 106 | user: Optional[User] = None 107 | if user_token: # first priority. 108 | user = await find_user_by_token(user_token) 109 | if not user: # second priority. 110 | user = await find_user_by_email(email) 111 | 112 | if not user: # is new user. 113 | user_id = str(uuid4()) 114 | user = User( 115 | id=user_id, 116 | token=generate_user_token(user_id, timestamp), 117 | email=email, 118 | name=name, 119 | avatar=avatar, 120 | create_timestamp=timestamp, 121 | update_timestamp=timestamp, 122 | ) 123 | else: 124 | # Don't need to renew user token here. 125 | # user.token = generate_user_token(user.id, timestamp) 126 | user.email = email 127 | user.name = name 128 | user.avatar = avatar 129 | user.update_timestamp = timestamp 130 | 131 | await upsert_user(user) 132 | return user 133 | 134 | 135 | # https://developers.google.com/identity/gsi/web/guides/verify-google-id-token 136 | # https://pyjwt.readthedocs.io/en/stable/usage.html#retrieve-rsa-signing-keys-from-a-jwks-endpoint 137 | def _decode_google_oauth_credential( 138 | credential: str, 139 | client_id: str, 140 | verify_exp: bool = False, 141 | ) -> dict: 142 | signing_key = _google_jwk_client.get_signing_key_from_jwt(credential) 143 | return jwt.decode( 144 | jwt=credential, 145 | key=signing_key.key, 146 | algorithms=['RS256'], 147 | audience=client_id, 148 | issuer='https://accounts.google.com', 149 | options={'verify_exp': verify_exp}, 150 | ) 151 | -------------------------------------------------------------------------------- /mongo/subscriptions.py: -------------------------------------------------------------------------------- 1 | from enum import unique 2 | from typing import Optional 3 | 4 | from async_lru import alru_cache 5 | from strenum import StrEnum 6 | 7 | from mongo.db import subscriptions, \ 8 | subscription_payments, \ 9 | convert_datetime_to_isoformat_with_z 10 | 11 | 12 | @unique 13 | class Status(StrEnum): 14 | ON_TRIAL = 'on_trial' 15 | ACTIVE = 'active' 16 | PAUSED = 'paused' 17 | PAST_DUE = 'past_due' 18 | UNPAID = 'unpaid' 19 | CANCELLED = 'cancelled' 20 | EXPIRED = 'expired' 21 | 22 | 23 | @unique 24 | class BillingReason(StrEnum): 25 | INITIAL = 'initial' 26 | RENEWAL = 'renewal' 27 | UPDATED = 'updated' 28 | 29 | 30 | async def setup_subscriptions(): 31 | await subscriptions.create_index('meta.event_name', background=True) # nopep8; str. 32 | await subscriptions.create_index('meta.custom_data.user_id', background=True) # nopep8; str. 33 | 34 | await subscriptions.create_index('data.id', background=True) # nopep8; str, as the `subscription_id`. 35 | await subscriptions.create_index('data.attributes.store_id', background=True) # nopep8; str. 36 | await subscriptions.create_index('data.attributes.customer_id', background=True) # nopep8; str. 37 | await subscriptions.create_index('data.attributes.order_id', background=True) # nopep8; str. 38 | await subscriptions.create_index('data.attributes.order_item_id', background=True) # nopep8; str. 39 | await subscriptions.create_index('data.attributes.product_id', background=True) # nopep8; str. 40 | await subscriptions.create_index('data.attributes.variant_id', background=True) # nopep8; str. 41 | 42 | await subscriptions.create_index('data.attributes.user_email', background=True) # nopep8; str. 43 | await subscriptions.create_index('data.attributes.status', background=True) # nopep8; str. 44 | 45 | await subscriptions.create_index('data.attributes.created_at', background=True) # nopep8; datetime. 46 | await subscriptions.create_index('data.attributes.updated_at', background=True) # nopep8; datetime. 47 | 48 | 49 | async def setup_subscription_payments(): 50 | await subscription_payments.create_index('meta.event_name', background=True) # nopep8; str. 51 | await subscription_payments.create_index('meta.custom_data.user_id', background=True) # nopep8; str. 52 | 53 | await subscription_payments.create_index('data.id', background=True) # nopep8; str, as the `invoice_id`. 54 | await subscription_payments.create_index('data.attributes.store_id', background=True) # nopep8; str. 55 | await subscription_payments.create_index('data.attributes.subscription_id', background=True) # nopep8; str. 56 | 57 | await subscription_payments.create_index('data.attributes.billing_reason', background=True) # nopep8; str. 58 | await subscription_payments.create_index('data.attributes.status', background=True) # nopep8; str. 59 | 60 | await subscription_payments.create_index('data.attributes.created_at', background=True) # nopep8; datetime. 61 | await subscription_payments.create_index('data.attributes.updated_at', background=True) # nopep8; datetime. 62 | 63 | 64 | # https://docs.lemonsqueezy.com/api/subscriptions#the-subscription-object 65 | # https://docs.lemonsqueezy.com/help/webhooks#example-payloads 66 | # 67 | # You will notice that the `data` in the payload is the subscription object, 68 | # plus some `meta` and the usual `relationships` and `links`. 69 | async def insert_subscription(subscription: dict): 70 | await subscriptions.insert_one(subscription) 71 | find_latest_subscription.cache_clear() 72 | 73 | 74 | # https://docs.lemonsqueezy.com/api/subscription-invoices#the-subscription-invoice-object 75 | # https://docs.lemonsqueezy.com/help/webhooks#example-payloads 76 | # 77 | # You will notice that the `data` in the payload is the subscription invoice object, 78 | # plus some `meta` and the usual `relationships` and `links`. 79 | async def insert_subscription_payment(payment: dict): 80 | await subscription_payments.insert_one(payment) 81 | 82 | 83 | @alru_cache(ttl=10) 84 | async def find_latest_subscription( 85 | user_id: str, 86 | store_id: str, 87 | product_id: str, 88 | variant_id: str, 89 | test_mode: bool = False, 90 | ) -> Optional[dict]: 91 | cursor = subscriptions \ 92 | .find({ 93 | 'meta.custom_data.user_id': user_id, 94 | 'data.attributes.store_id': store_id, 95 | 'data.attributes.product_id': product_id, 96 | 'data.attributes.variant_id': variant_id, 97 | 'data.attributes.test_mode': test_mode, 98 | }) \ 99 | .sort('data.attributes.updated_at', -1) \ 100 | .limit(1) 101 | 102 | res: list[dict] = [] 103 | async for order in cursor: 104 | res.append(order) 105 | 106 | return res[0] if res else None 107 | 108 | 109 | def convert_subscription_to_response(subscription: dict) -> dict: 110 | status = subscription['data']['attributes']['status'] 111 | 112 | created_at = subscription['data']['attributes']['created_at'] 113 | created_at = convert_datetime_to_isoformat_with_z(created_at) 114 | 115 | updated_at = subscription['data']['attributes']['updated_at'] 116 | updated_at = convert_datetime_to_isoformat_with_z(updated_at) 117 | 118 | return { 119 | 'available': status == str(Status.ON_TRIAL) or status == str(Status.ACTIVE), 120 | 'status': status, 121 | 'created_at': created_at, 122 | 'updated_at': updated_at, 123 | } 124 | -------------------------------------------------------------------------------- /lemon.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import json 4 | 5 | import httpx 6 | 7 | from enum import unique 8 | 9 | from quart import abort 10 | from strenum import StrEnum 11 | from werkzeug.datastructures import Headers 12 | 13 | from logger import logger 14 | from mongo.licenses import insert_license 15 | from mongo.orders import insert_order 16 | from mongo.subscriptions import insert_subscription, insert_subscription_payment 17 | from rds import get_str_from_rds, \ 18 | LEMONSQUEEZY_SIGNING_SECRET, \ 19 | LEMONSQUEEZY_API_KEY 20 | 21 | 22 | # https://docs.lemonsqueezy.com/help/webhooks#event-types 23 | @unique 24 | class Event(StrEnum): 25 | ORDER_CREATED = 'order_created' 26 | ORDER_REFUNDED = 'order_refunded' 27 | SUBSCRIPTION_CREATED = 'subscription_created' 28 | SUBSCRIPTION_UPDATED = 'subscription_updated' 29 | SUBSCRIPTION_CANCELLED = 'subscription_cancelled' 30 | SUBSCRIPTION_RESUMED = 'subscription_resumed' 31 | SUBSCRIPTION_EXPIRED = 'subscription_expired' 32 | SUBSCRIPTION_PAUSED = 'subscription_paused' 33 | SUBSCRIPTION_UNPAUSED = 'subscription_unpaused' 34 | SUBSCRIPTION_PAYMENT_SUCCESS = 'subscription_payment_success' 35 | SUBSCRIPTION_PAYMENT_FAILED = 'subscription_payment_failed' 36 | SUBSCRIPTION_PAYMENT_RECOVERED = 'subscription_payment_recovered' 37 | LICENSE_KEY_CREATED = 'license_key_created' 38 | LICENSE_KEY_UPDATED = 'license_key_updated' 39 | 40 | 41 | # https://docs.lemonsqueezy.com/help/webhooks#signing-requests 42 | def check_signing_secret(headers: Headers, body: bytes, secret: str = ''): 43 | signature = headers.get(key='X-Signature', default='', type=str) 44 | if not signature: 45 | abort(400, f'"X-Signature" not exists') 46 | 47 | secret = secret.strip() 48 | if not secret: 49 | secret = get_str_from_rds(LEMONSQUEEZY_SIGNING_SECRET) 50 | 51 | digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() 52 | if not hmac.compare_digest(digest, signature): 53 | abort(400, f'invalid "X-Signature", signature={signature}') 54 | 55 | 56 | # https://docs.lemonsqueezy.com/help/webhooks#webhook-requests 57 | def parse_event(headers: Headers) -> Event: 58 | event = headers.get(key='X-Event-Name', default='', type=str) 59 | if not event: 60 | abort(400, '"X-Event-Name" not exists') 61 | 62 | try: 63 | return Event[event.upper()] 64 | except Exception: 65 | abort(400, f'invalid "X-Event-Name", event={event}') 66 | 67 | 68 | # https://docs.lemonsqueezy.com/help/webhooks#webhook-requests 69 | async def dispatch_event(event: Event, body: dict): 70 | if str(event).startswith('order_'): 71 | await insert_order(body) 72 | elif str(event).startswith('subscription_payment_'): 73 | await insert_subscription_payment(body) 74 | elif str(event).startswith('subscription_'): 75 | await insert_subscription(body) 76 | elif str(event).startswith('license_'): 77 | await insert_license(body) 78 | else: 79 | abort(400, f'unsupported event, event={str(event)}') 80 | 81 | 82 | # https://docs.lemonsqueezy.com/help/licensing/license-api#post-v1-licenses-activate 83 | # 84 | # FIXME (Matthew Lee) 85 | # API calls are rate limited to 60 / minute, 86 | # but the rate limited http code is undocumented, 87 | # don't know whether it is 429 or not for now until we reach and log it. 88 | async def activate_license( 89 | license_key: str, 90 | instance_name: str, 91 | api_key: str = '', 92 | ) -> dict: 93 | if not api_key: 94 | api_key = get_str_from_rds(LEMONSQUEEZY_API_KEY) 95 | 96 | headers = { 97 | 'Accept': 'application/json', 98 | 'Authorization': f'Bearer {api_key}', 99 | } 100 | 101 | data = { 102 | 'license_key': license_key, 103 | 'instance_name': instance_name, 104 | } 105 | 106 | transport = httpx.AsyncHTTPTransport(retries=2) 107 | client = httpx.AsyncClient(transport=transport) 108 | 109 | try: 110 | # Content-Type must be 'application/x-www-form-urlencoded'. 111 | response = await client.post( 112 | url='https://api.lemonsqueezy.com/v1/licenses/activate', 113 | headers=headers, 114 | data=data, 115 | timeout=10, 116 | follow_redirects=True, 117 | ) 118 | finally: 119 | await client.aclose() 120 | 121 | # https://docs.lemonsqueezy.com/help/licensing/license-api#errors 122 | if not response.is_success: 123 | if response.status_code == 400: 124 | data: dict = response.json() 125 | abort(response.status_code, data['error']) 126 | else: # 404 or 422. 127 | abort(response.status_code, response.text) 128 | 129 | # Automatically .aclose() if the response body is read to completion. 130 | data: dict = response.json() 131 | logger.info( 132 | f'activate license, ' 133 | f'license_key={license_key}, ' 134 | f'instance_name={instance_name}, ' 135 | f'body={json.dumps(data)}' 136 | ) 137 | 138 | # The `data` structure is not similar to webhooks request, 139 | # so we retrieve license again for later code reusing. 140 | return await retrieve_license(str(data['license_key']['id']), api_key) 141 | 142 | 143 | # https://docs.lemonsqueezy.com/api/license-keys#retrieve-a-license-key 144 | async def retrieve_license(license_id: str, api_key: str = '') -> dict: 145 | if not api_key: 146 | api_key = get_str_from_rds(LEMONSQUEEZY_API_KEY) 147 | 148 | headers = { 149 | 'Accept': 'application/json', 150 | 'Content-Type': 'application/vnd.api+json', 151 | 'Authorization': f'Bearer {api_key}', 152 | } 153 | 154 | transport = httpx.AsyncHTTPTransport(retries=2) 155 | client = httpx.AsyncClient(transport=transport) 156 | 157 | try: 158 | response = await client.get( 159 | url=f'https://api.lemonsqueezy.com/v1/license-keys/{license_id}', 160 | headers=headers, 161 | timeout=10, 162 | follow_redirects=True, 163 | ) 164 | finally: 165 | await client.aclose() 166 | 167 | if not response.is_success: 168 | abort(response.status_code, response.text) 169 | 170 | # Automatically .aclose() if the response body is read to completion. 171 | data: dict = response.json() 172 | logger.info( 173 | f'retrieve license, ' 174 | f'license_id={license_id}, ' 175 | f'body={json.dumps(data)}' 176 | ) 177 | 178 | return data 179 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | from distutils.util import strtobool 5 | from dataclasses import asdict 6 | from uuid import uuid4 7 | 8 | from quart import Quart, abort, request 9 | from quart_cors import cors 10 | from werkzeug.exceptions import HTTPException 11 | 12 | from lemon import check_signing_secret, \ 13 | parse_event, \ 14 | dispatch_event, \ 15 | activate_license as activate_license_internal 16 | from logger import logger 17 | from mongo.db import convert_id_to_str_in_json, \ 18 | convert_at_to_datetime_in_json 19 | from mongo.licenses import setup_licenses, \ 20 | find_latest_license, \ 21 | convert_license_to_response 22 | from mongo.orders import setup_orders, \ 23 | find_latest_order, \ 24 | convert_order_to_response 25 | from mongo.subscriptions import setup_subscriptions, \ 26 | setup_subscription_payments, \ 27 | find_latest_subscription, \ 28 | convert_subscription_to_response 29 | from mongo.users import User, setup_users, upsert_user 30 | from oauth import generate_user_token, \ 31 | decrypt_user_token, \ 32 | upsert_user_from_google_oauth 33 | 34 | app = Quart(__name__) 35 | app = cors(app, allow_origin='*') 36 | 37 | 38 | # https://pgjones.gitlab.io/quart/how_to_guides/startup_shutdown.html 39 | @app.before_serving 40 | async def before_serving(): 41 | logger.info('setup collections before serving') 42 | await setup_users() 43 | await setup_orders() 44 | await setup_licenses() 45 | await setup_subscriptions() 46 | await setup_subscription_payments() 47 | 48 | 49 | # https://flask.palletsprojects.com/en/2.2.x/errorhandling/#generic-exception-handler 50 | # 51 | # If no handler is registered, 52 | # HTTPException subclasses show a generic message about their code, 53 | # while other exceptions are converted to a generic "500 Internal Server Error". 54 | @app.errorhandler(HTTPException) 55 | def handle_exception(e: HTTPException): 56 | response = e.get_response() 57 | response.data = json.dumps({ 58 | 'code': e.code, 59 | 'name': e.name, # as JavaScript Error `name`. 60 | 'message': e.description, # as JavaScript Error `message`. 61 | }) 62 | response.content_type = 'application/json' 63 | logger.error(f'errorhandler, data={response.data}') 64 | return response 65 | 66 | 67 | # Register anonymous user. 68 | # 69 | # After register, 70 | # all requests' headers should contain `"Authorization": "Bearer USER_TOKEN"`, 71 | # and the `USER_TOKEN` value comes from `user.token`. 72 | @app.post('/api/user/register') 73 | async def register(): 74 | user_id = str(uuid4()) 75 | timestamp = int(time.time()) 76 | token = generate_user_token(user_id, timestamp) 77 | 78 | user = User( 79 | id=user_id, 80 | token=token, 81 | create_timestamp=timestamp, 82 | update_timestamp=timestamp, 83 | ) 84 | 85 | await upsert_user(user) 86 | return asdict(user) 87 | 88 | 89 | # { 90 | # 'credential': required; str. 91 | # 'user_token': optional; str. 92 | # 'verify_exp': optional; boolean. 93 | # } 94 | @app.post('/api/user/oauth/google') 95 | async def google_oauth(): 96 | body: dict = await request.get_json() or {} 97 | 98 | user = await upsert_user_from_google_oauth( 99 | credential=_parse_str_from_dict(body, 'credential'), 100 | user_token=_parse_str_from_dict(body, 'user_token', required=False), 101 | verify_exp=bool(body.get('verify_exp', False)), 102 | ) 103 | 104 | return asdict(user) 105 | 106 | 107 | # https://docs.lemonsqueezy.com/help/webhooks#webhook-requests 108 | # 109 | # FIXME (Matthew Lee) 110 | # Currently we strongly depends webhooks usability, 111 | # but when it not available, 112 | # we need to fallback to manual request Lemon Squeezy APIs. 113 | @app.post('/api/webhooks/lemonsqueezy') 114 | async def lemonsqueezy_webhooks(): 115 | # Always record webhooks body for debugging. 116 | body: dict = await request.get_json() or {} 117 | logger.info(f'/api/webhooks/lemonsqueezy, body={json.dumps(body)}') 118 | 119 | data = await request.get_data() # raw body. 120 | check_signing_secret(request.headers, data) 121 | 122 | event = parse_event(request.headers) 123 | convert_id_to_str_in_json(body) 124 | convert_at_to_datetime_in_json(body) 125 | await dispatch_event(event, body) 126 | 127 | return {} # 200. 128 | 129 | 130 | # ?user_token=str required. 131 | # &store_id=str required. 132 | # &product_id=str required. 133 | # &variant_id=str required. 134 | # &test_mode=bool optional; default is `false`. 135 | @app.get('/api/orders/check') 136 | async def check_order(): 137 | user_token = _parse_str_from_dict(request.args, 'user_token') 138 | store_id = _parse_str_from_dict(request.args, 'store_id') 139 | product_id = _parse_str_from_dict(request.args, 'product_id') 140 | variant_id = _parse_str_from_dict(request.args, 'variant_id') 141 | test_mode = bool(strtobool(request.args.get('test_mode', 'false'))) 142 | 143 | res = await find_latest_order( 144 | user_id=decrypt_user_token(user_token).user_id, 145 | store_id=store_id, 146 | product_id=product_id, 147 | variant_id=variant_id, 148 | test_mode=test_mode, 149 | ) 150 | 151 | if not res: 152 | abort(404, 'order not found') 153 | 154 | return convert_order_to_response(res) 155 | 156 | 157 | # ?user_token=str required. 158 | # &store_id=str required. 159 | # &product_id=str required. 160 | # &variant_id=str required. 161 | # &test_mode=bool optional; default is `false`. 162 | @app.get('/api/subscriptions/check') 163 | async def check_subscription(): 164 | user_token = _parse_str_from_dict(request.args, 'user_token') 165 | store_id = _parse_str_from_dict(request.args, 'store_id') 166 | product_id = _parse_str_from_dict(request.args, 'product_id') 167 | variant_id = _parse_str_from_dict(request.args, 'variant_id') 168 | test_mode = bool(strtobool(request.args.get('test_mode', 'false'))) 169 | 170 | res = await find_latest_subscription( 171 | user_id=decrypt_user_token(user_token).user_id, 172 | store_id=store_id, 173 | product_id=product_id, 174 | variant_id=variant_id, 175 | test_mode=test_mode, 176 | ) 177 | 178 | if not res: 179 | abort(404, 'subscription not found') 180 | 181 | return convert_subscription_to_response(res) 182 | 183 | 184 | # ?license_key=str required. 185 | # &test_mode=bool optional; default is `false`. 186 | @app.get('/api/licenses/check') 187 | async def check_license(): 188 | license_key = _parse_str_from_dict(request.args, 'license_key') 189 | test_mode = bool(strtobool(request.args.get('test_mode', 'false'))) 190 | 191 | res = await find_latest_license( 192 | license_key=license_key, 193 | test_mode=test_mode, 194 | ) 195 | 196 | if not res: 197 | abort(404, 'license not found') 198 | 199 | return convert_license_to_response(res) 200 | 201 | 202 | # { 203 | # 'license_key': required; str. 204 | # 'instance_name': required; str. 205 | # } 206 | @app.post('/api/licenses/activate') 207 | async def activate_license(): 208 | # Always record api body for debugging. 209 | body: dict = await request.get_json() or {} 210 | logger.info(f'/api/licenses/activate, body={json.dumps(body)}') 211 | 212 | license_key = _parse_str_from_dict(body, 'license_key') 213 | instance_name = _parse_str_from_dict(body, 'instance_name') 214 | 215 | res = await activate_license_internal(license_key, instance_name) 216 | return convert_license_to_response(res) 217 | 218 | 219 | def _parse_str_from_dict( 220 | data: dict, 221 | key: str, 222 | default: str = '', 223 | required: bool = True, 224 | ) -> str: 225 | value = data.get(key, default) 226 | if not isinstance(value, str): 227 | if required: 228 | abort(400, f'"{key}" must be string') 229 | 230 | value = value.strip() 231 | if not value: 232 | if required: 233 | abort(400, f'"{key}" must not empty') 234 | 235 | return value 236 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "6c435fafa42baf0479241bb1dc7f21e771b5a9d55ab081592ca87c1b2d97035b" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiofiles": { 20 | "hashes": [ 21 | "sha256:9312414ae06472eb6f1d163f555e466a23aed1c8f60c30cccf7121dba2e53eb2", 22 | "sha256:edd247df9a19e0db16534d4baaf536d6609a43e1de5401d7a4c1c148753a1635" 23 | ], 24 | "markers": "python_version >= '3.7' and python_version < '4.0'", 25 | "version": "==23.1.0" 26 | }, 27 | "anyio": { 28 | "hashes": [ 29 | "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", 30 | "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5" 31 | ], 32 | "markers": "python_version >= '3.7'", 33 | "version": "==3.7.1" 34 | }, 35 | "async-lru": { 36 | "hashes": [ 37 | "sha256:b8a59a5df60805ff63220b2a0c5b5393da5521b113cd5465a44eb037d81a5627", 38 | "sha256:ff02944ce3c288c5be660c42dbcca0742b32c3b279d6dceda655190240b99224" 39 | ], 40 | "index": "pypi", 41 | "version": "==2.0.4" 42 | }, 43 | "async-timeout": { 44 | "hashes": [ 45 | "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", 46 | "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" 47 | ], 48 | "markers": "python_full_version <= '3.11.2'", 49 | "version": "==4.0.2" 50 | }, 51 | "blinker": { 52 | "hashes": [ 53 | "sha256:1eb563df6fdbc39eeddc177d953203f99f097e9bf0e2b8f9f3cf18b6ca425e36", 54 | "sha256:923e5e2f69c155f2cc42dafbbd70e16e3fde24d2d4aa2ab72fbe386238892462" 55 | ], 56 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 57 | "version": "==1.5" 58 | }, 59 | "certifi": { 60 | "hashes": [ 61 | "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", 62 | "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" 63 | ], 64 | "markers": "python_version >= '3.6'", 65 | "version": "==2023.7.22" 66 | }, 67 | "cffi": { 68 | "hashes": [ 69 | "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", 70 | "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", 71 | "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", 72 | "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", 73 | "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", 74 | "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", 75 | "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", 76 | "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", 77 | "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", 78 | "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", 79 | "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", 80 | "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", 81 | "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", 82 | "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", 83 | "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", 84 | "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", 85 | "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", 86 | "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", 87 | "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", 88 | "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", 89 | "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", 90 | "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", 91 | "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", 92 | "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", 93 | "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", 94 | "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", 95 | "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", 96 | "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", 97 | "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", 98 | "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", 99 | "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", 100 | "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", 101 | "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", 102 | "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", 103 | "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", 104 | "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", 105 | "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", 106 | "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", 107 | "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", 108 | "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", 109 | "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", 110 | "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", 111 | "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", 112 | "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", 113 | "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", 114 | "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", 115 | "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", 116 | "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", 117 | "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", 118 | "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", 119 | "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", 120 | "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", 121 | "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", 122 | "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", 123 | "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", 124 | "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", 125 | "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", 126 | "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", 127 | "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", 128 | "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", 129 | "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", 130 | "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", 131 | "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", 132 | "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" 133 | ], 134 | "version": "==1.15.1" 135 | }, 136 | "click": { 137 | "hashes": [ 138 | "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd", 139 | "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5" 140 | ], 141 | "markers": "python_version >= '3.7'", 142 | "version": "==8.1.6" 143 | }, 144 | "cryptography": { 145 | "hashes": [ 146 | "sha256:0d09fb5356f975974dbcb595ad2d178305e5050656affb7890a1583f5e02a306", 147 | "sha256:23c2d778cf829f7d0ae180600b17e9fceea3c2ef8b31a99e3c694cbbf3a24b84", 148 | "sha256:3fb248989b6363906827284cd20cca63bb1a757e0a2864d4c1682a985e3dca47", 149 | "sha256:41d7aa7cdfded09b3d73a47f429c298e80796c8e825ddfadc84c8a7f12df212d", 150 | "sha256:42cb413e01a5d36da9929baa9d70ca90d90b969269e5a12d39c1e0d475010116", 151 | "sha256:4c2f0d35703d61002a2bbdcf15548ebb701cfdd83cdc12471d2bae80878a4207", 152 | "sha256:4fd871184321100fb400d759ad0cddddf284c4b696568204d281c902fc7b0d81", 153 | "sha256:5259cb659aa43005eb55a0e4ff2c825ca111a0da1814202c64d28a985d33b087", 154 | "sha256:57a51b89f954f216a81c9d057bf1a24e2f36e764a1ca9a501a6964eb4a6800dd", 155 | "sha256:652627a055cb52a84f8c448185922241dd5217443ca194d5739b44612c5e6507", 156 | "sha256:67e120e9a577c64fe1f611e53b30b3e69744e5910ff3b6e97e935aeb96005858", 157 | "sha256:6af1c6387c531cd364b72c28daa29232162010d952ceb7e5ca8e2827526aceae", 158 | "sha256:6d192741113ef5e30d89dcb5b956ef4e1578f304708701b8b73d38e3e1461f34", 159 | "sha256:7efe8041897fe7a50863e51b77789b657a133c75c3b094e51b5e4b5cec7bf906", 160 | "sha256:84537453d57f55a50a5b6835622ee405816999a7113267739a1b4581f83535bd", 161 | "sha256:8f09daa483aedea50d249ef98ed500569841d6498aa9c9f4b0531b9964658922", 162 | "sha256:95dd7f261bb76948b52a5330ba5202b91a26fbac13ad0e9fc8a3ac04752058c7", 163 | "sha256:a74fbcdb2a0d46fe00504f571a2a540532f4c188e6ccf26f1f178480117b33c4", 164 | "sha256:a983e441a00a9d57a4d7c91b3116a37ae602907a7618b882c8013b5762e80574", 165 | "sha256:ab8de0d091acbf778f74286f4989cf3d1528336af1b59f3e5d2ebca8b5fe49e1", 166 | "sha256:aeb57c421b34af8f9fe830e1955bf493a86a7996cc1338fe41b30047d16e962c", 167 | "sha256:ce785cf81a7bdade534297ef9e490ddff800d956625020ab2ec2780a556c313e", 168 | "sha256:d0d651aa754ef58d75cec6edfbd21259d93810b73f6ec246436a21b7841908de" 169 | ], 170 | "index": "pypi", 171 | "version": "==41.0.3" 172 | }, 173 | "decorator": { 174 | "hashes": [ 175 | "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", 176 | "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186" 177 | ], 178 | "markers": "python_version >= '3.5'", 179 | "version": "==5.1.1" 180 | }, 181 | "dnspython": { 182 | "hashes": [ 183 | "sha256:5b7488477388b8c0b70a8ce93b227c5603bc7b77f1565afe8e729c36c51447d7", 184 | "sha256:c33971c79af5be968bb897e95c2448e11a645ee84d93b265ce0b7aabe5dfdca8" 185 | ], 186 | "markers": "python_version >= '3.8' and python_version < '4.0'", 187 | "version": "==2.4.1" 188 | }, 189 | "exceptiongroup": { 190 | "hashes": [ 191 | "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", 192 | "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" 193 | ], 194 | "markers": "python_version < '3.11'", 195 | "version": "==1.1.2" 196 | }, 197 | "h11": { 198 | "hashes": [ 199 | "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", 200 | "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" 201 | ], 202 | "markers": "python_version >= '3.7'", 203 | "version": "==0.14.0" 204 | }, 205 | "h2": { 206 | "hashes": [ 207 | "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d", 208 | "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb" 209 | ], 210 | "markers": "python_full_version >= '3.6.1'", 211 | "version": "==4.1.0" 212 | }, 213 | "hpack": { 214 | "hashes": [ 215 | "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", 216 | "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095" 217 | ], 218 | "markers": "python_full_version >= '3.6.1'", 219 | "version": "==4.0.0" 220 | }, 221 | "httpcore": { 222 | "hashes": [ 223 | "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888", 224 | "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87" 225 | ], 226 | "markers": "python_version >= '3.7'", 227 | "version": "==0.17.3" 228 | }, 229 | "httpx": { 230 | "hashes": [ 231 | "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd", 232 | "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd" 233 | ], 234 | "index": "pypi", 235 | "version": "==0.24.1" 236 | }, 237 | "hypercorn": { 238 | "hashes": [ 239 | "sha256:3fa504efc46a271640023c9b88c3184fd64993f47a282e8ae1a13ccb285c2f67", 240 | "sha256:f956200dbf8677684e6e976219ffa6691d6cf795281184b41dbb0b135ab37b8d" 241 | ], 242 | "index": "pypi", 243 | "version": "==0.14.4" 244 | }, 245 | "hyperframe": { 246 | "hashes": [ 247 | "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15", 248 | "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914" 249 | ], 250 | "markers": "python_full_version >= '3.6.1'", 251 | "version": "==6.0.1" 252 | }, 253 | "idna": { 254 | "hashes": [ 255 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 256 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 257 | ], 258 | "markers": "python_version >= '3.5'", 259 | "version": "==3.4" 260 | }, 261 | "importlib-metadata": { 262 | "hashes": [ 263 | "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb", 264 | "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743" 265 | ], 266 | "markers": "python_version < '3.10'", 267 | "version": "==6.8.0" 268 | }, 269 | "itsdangerous": { 270 | "hashes": [ 271 | "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", 272 | "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" 273 | ], 274 | "markers": "python_version >= '3.7'", 275 | "version": "==2.1.2" 276 | }, 277 | "jinja2": { 278 | "hashes": [ 279 | "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", 280 | "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" 281 | ], 282 | "markers": "python_version >= '3.7'", 283 | "version": "==3.1.2" 284 | }, 285 | "markupsafe": { 286 | "hashes": [ 287 | "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", 288 | "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", 289 | "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", 290 | "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", 291 | "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", 292 | "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", 293 | "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", 294 | "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", 295 | "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", 296 | "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", 297 | "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", 298 | "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", 299 | "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", 300 | "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", 301 | "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", 302 | "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", 303 | "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", 304 | "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", 305 | "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", 306 | "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", 307 | "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", 308 | "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", 309 | "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", 310 | "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", 311 | "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0", 312 | "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", 313 | "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", 314 | "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", 315 | "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", 316 | "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", 317 | "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", 318 | "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", 319 | "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", 320 | "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", 321 | "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", 322 | "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", 323 | "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc", 324 | "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", 325 | "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48", 326 | "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", 327 | "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e", 328 | "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b", 329 | "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", 330 | "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5", 331 | "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e", 332 | "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", 333 | "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", 334 | "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", 335 | "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", 336 | "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" 337 | ], 338 | "markers": "python_version >= '3.7'", 339 | "version": "==2.1.3" 340 | }, 341 | "motor": { 342 | "hashes": [ 343 | "sha256:4fb1e8502260f853554f24115421584e83904a6debb577354d33e9711ee99008", 344 | "sha256:82cd3d8a3b57e322c3fa382a393b52828c9a2e98b315c78af36f01bae78af6a6" 345 | ], 346 | "index": "pypi", 347 | "version": "==3.2.0" 348 | }, 349 | "priority": { 350 | "hashes": [ 351 | "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", 352 | "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0" 353 | ], 354 | "markers": "python_full_version >= '3.6.1'", 355 | "version": "==2.0.0" 356 | }, 357 | "pycparser": { 358 | "hashes": [ 359 | "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", 360 | "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" 361 | ], 362 | "version": "==2.21" 363 | }, 364 | "pycryptodome": { 365 | "hashes": [ 366 | "sha256:01489bbdf709d993f3058e2996f8f40fee3f0ea4d995002e5968965fa2fe89fb", 367 | "sha256:10da29526a2a927c7d64b8f34592f461d92ae55fc97981aab5bbcde8cb465bb6", 368 | "sha256:12600268763e6fec3cefe4c2dcdf79bde08d0b6dc1813887e789e495cb9f3403", 369 | "sha256:157c9b5ba5e21b375f052ca78152dd309a09ed04703fd3721dce3ff8ecced148", 370 | "sha256:16bfd98dbe472c263ed2821284118d899c76968db1a6665ade0c46805e6b29a4", 371 | "sha256:363dd6f21f848301c2dcdeb3c8ae5f0dee2286a5e952a0f04954b82076f23825", 372 | "sha256:3811e31e1ac3069988f7a1c9ee7331b942e605dfc0f27330a9ea5997e965efb2", 373 | "sha256:422c89fd8df8a3bee09fb8d52aaa1e996120eafa565437392b781abec2a56e14", 374 | "sha256:4604816adebd4faf8810782f137f8426bf45fee97d8427fa8e1e49ea78a52e2c", 375 | "sha256:4944defabe2ace4803f99543445c27dd1edbe86d7d4edb87b256476a91e9ffa4", 376 | "sha256:51eae079ddb9c5f10376b4131be9589a6554f6fd84f7f655180937f611cd99a2", 377 | "sha256:53aee6be8b9b6da25ccd9028caf17dcdce3604f2c7862f5167777b707fbfb6cb", 378 | "sha256:62a1e8847fabb5213ccde38915563140a5b338f0d0a0d363f996b51e4a6165cf", 379 | "sha256:6f4b967bb11baea9128ec88c3d02f55a3e338361f5e4934f5240afcb667fdaec", 380 | "sha256:78d863476e6bad2a592645072cc489bb90320972115d8995bcfbee2f8b209918", 381 | "sha256:795bd1e4258a2c689c0b1f13ce9684fa0dd4c0e08680dcf597cf9516ed6bc0f3", 382 | "sha256:7a3d22c8ee63de22336679e021c7f2386f7fc465477d59675caa0e5706387944", 383 | "sha256:83c75952dcf4a4cebaa850fa257d7a860644c70a7cd54262c237c9f2be26f76e", 384 | "sha256:928078c530da78ff08e10eb6cada6e0dff386bf3d9fa9871b4bbc9fbc1efe024", 385 | "sha256:957b221d062d5752716923d14e0926f47670e95fead9d240fa4d4862214b9b2f", 386 | "sha256:9ad6f09f670c466aac94a40798e0e8d1ef2aa04589c29faa5b9b97566611d1d1", 387 | "sha256:9c8eda4f260072f7dbe42f473906c659dcbadd5ae6159dfb49af4da1293ae380", 388 | "sha256:b1d9701d10303eec8d0bd33fa54d44e67b8be74ab449052a8372f12a66f93fb9", 389 | "sha256:b6a610f8bfe67eab980d6236fdc73bfcdae23c9ed5548192bb2d530e8a92780e", 390 | "sha256:c9adee653fc882d98956e33ca2c1fb582e23a8af7ac82fee75bd6113c55a0413", 391 | "sha256:cb1be4d5af7f355e7d41d36d8eec156ef1382a88638e8032215c215b82a4b8ec", 392 | "sha256:d1497a8cd4728db0e0da3c304856cb37c0c4e3d0b36fcbabcc1600f18504fc54", 393 | "sha256:d20082bdac9218649f6abe0b885927be25a917e29ae0502eaf2b53f1233ce0c2", 394 | "sha256:e8ad74044e5f5d2456c11ed4cfd3e34b8d4898c0cb201c4038fe41458a82ea27", 395 | "sha256:f022a4fd2a5263a5c483a2bb165f9cb27f2be06f2f477113783efe3fe2ad887b", 396 | "sha256:f21efb8438971aa16924790e1c3dba3a33164eb4000106a55baaed522c261acf", 397 | "sha256:fc0a73f4db1e31d4a6d71b672a48f3af458f548059aa05e83022d5f61aac9c08" 398 | ], 399 | "index": "pypi", 400 | "version": "==3.18.0" 401 | }, 402 | "pyjwt": { 403 | "hashes": [ 404 | "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de", 405 | "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320" 406 | ], 407 | "index": "pypi", 408 | "version": "==2.8.0" 409 | }, 410 | "pymongo": { 411 | "hashes": [ 412 | "sha256:022c91e2a41eefbcddc844c534520a13c6f613666c37b9fb9ed039eff47bd2e4", 413 | "sha256:02dba4ea2a6f22de4b50864d3957a0110b75d3eeb40aeab0b0ff64bcb5a063e6", 414 | "sha256:04ec1c5451ad358fdbff28ddc6e8a3d1b5f62178d38cd08007a251bc3f59445a", 415 | "sha256:05935f5a4bbae0a99482147588351b7b17999f4a4e6e55abfb74367ac58c0634", 416 | "sha256:0a7739bcebdbeb5648edb15af00fd38f2ab5de20851a1341d229494a638284cc", 417 | "sha256:0bdbbcc1ef3a56347630c57eda5cd9536bdbdb82754b3108c66cbc51b5233dfb", 418 | "sha256:0dcc64747b628a96bcfc6405c42acae3762c85d8ae8c1ce18834b8151cad7486", 419 | "sha256:1897123c4bede1af0c264a3bc389a2505bae50d85e4f211288d352928c02d017", 420 | "sha256:1944b16ffef3573ae064196460de43eb1c865a64fed23551b5eac1951d80acca", 421 | "sha256:2259302d8ab51cd56c3d9d5cca325977e35a0bb3a15a297ec124d2da56c214f7", 422 | "sha256:262a4073d2ee0654f0314ef4d9aab1d8c13dc8dae5c102312e152c02bfa7bdb7", 423 | "sha256:2aab6d1cff00d68212eca75d2260980202b14038d9298fed7d5c455fe3285c7c", 424 | "sha256:2fe4bbf2b2c91e4690b5658b0fbb98ca6e0a8fba9ececd65b4e7d2d1df3e9b01", 425 | "sha256:32d6d2b7e14bb6bc052f6cba0c1cf4d47a2b49c56ea1ed0f960a02bc9afaefb2", 426 | "sha256:334d41649f157c56a47fb289bae3b647a867c1a74f5f3a8a371fb361580bd9d3", 427 | "sha256:35545583396684ea70a0b005034a469bf3f447732396e5b3d50bec94890b8d5c", 428 | "sha256:3681caf37edbe05f72f0d351e4a6cb5874ec7ab5eeb99df3a277dbf110093739", 429 | "sha256:36b0b06c6e830d190215fced82872e5fd8239771063afa206f9adc09574018a3", 430 | "sha256:3a350d03959f9d5b7f2ea0621f5bb2eb3927b8fc1c4031d12cfd3949839d4f66", 431 | "sha256:3bb935789276422d8875f051837356edfccdb886e673444d91e4941a8142bd48", 432 | "sha256:3f0bd25de90b804cc95e548f55f430df2b47f242a4d7bbce486db62f3b3c981f", 433 | "sha256:3f345380f6d6d6d1dc6db9fa5c8480c439ea79553b71a2cbe3030a1f20676595", 434 | "sha256:44381b817eeb47a41bbfbd279594a7fb21017e0e3e15550eb0fd3758333097f3", 435 | "sha256:48409bac0f6a62825c306c9a124698df920afdc396132908a8e88b466925a248", 436 | "sha256:4e6a70c9d437b043fb07eef1796060f476359e5b7d8e23baa49f1a70379d6543", 437 | "sha256:4ec9c6d4547c93cf39787c249969f7348ef6c4d36439af10d57b5ee65f3dfbf9", 438 | "sha256:5248fdf7244a5e976279fe154d116c73f6206e0be71074ea9d9b1e73b5893dd5", 439 | "sha256:5368801ca6b66aacc5cc013258f11899cd6a4c3bb28cec435dd67f835905e9d2", 440 | "sha256:53831effe4dc0243231a944dfbd87896e42b1cf081776930de5cc74371405e3b", 441 | "sha256:54d0b8b6f2548e15b09232827d9ba8e03a599c9a30534f7f2c7bae79df2d1f91", 442 | "sha256:55b6ebeeabe32a9d2e38eeb90f07c020cb91098b34b5fca42ff3991cb6e6e621", 443 | "sha256:58c492e28057838792bed67875f982ffbd3c9ceb67341cc03811859fddb8efbf", 444 | "sha256:5a1e5b931bf729b2eacd720a0e40201c2d5ed0e2bada60863f19b069bb5016c4", 445 | "sha256:5a2a1da505ea78787b0382c92dc21a45d19918014394b220c4734857e9c73694", 446 | "sha256:6cf08997d3ecf9a1eabe12c35aa82a5c588f53fac054ed46fe5c16a0a20ea43d", 447 | "sha256:7b7127bb35f10d974ec1bd5573389e99054c558b821c9f23bb8ff94e7ae6e612", 448 | "sha256:7e307d67641d0e2f7e7d6ee3dad880d090dace96cc1d95c99d15bd9f545a1168", 449 | "sha256:8082eef0d8c711c9c272906fa469965e52b44dbdb8a589b54857b1351dc2e511", 450 | "sha256:854d92d2437e3496742e17342496e1f3d9efb22455501fd6010aa3658138e457", 451 | "sha256:85b92b3828b2c923ed448f820c147ee51fa4566e35c9bf88415586eb0192ced2", 452 | "sha256:884a35c0740744a48f67210692841581ab83a4608d3a031e7125022989ef65f8", 453 | "sha256:912b0fdc16500125dc1837be8b13c99d6782d93d6cd099d0e090e2aca0b6d100", 454 | "sha256:91848d555155ad4594de5e575b6452adc471bc7bc4b4d2b1f4f15a78a8af7843", 455 | "sha256:977c34b5b0b50bd169fbca1a4dd06fbfdfd8ac47734fdc3473532c10098e16ce", 456 | "sha256:980da627edc1275896d7d4670596433ec66e1f452ec244e07bbb2f91c955b581", 457 | "sha256:98764ae13de0ab80ba824ca0b84177006dec51f48dfb7c944d8fa78ab645c67f", 458 | "sha256:995b868ccc9df8d36cb28142363e3911846fe9f43348d942951f60cdd7f62224", 459 | "sha256:9d43634594f2486cc9bb604a1dc0914234878c4faf6604574a25260cb2faaa06", 460 | "sha256:9d45243ff4800320c842c45e01c91037e281840e8c6ed2949ed82a70f55c0e6a", 461 | "sha256:a0d326c3ba989091026fbc4827638dc169abdbb0c0bbe593716921543f530af6", 462 | "sha256:a438508dd8007a4a724601c3790db46fe0edc3d7d172acafc5f148ceb4a07815", 463 | "sha256:a4df87dbbd03ac6372d24f2a8054b4dc33de497d5227b50ec649f436ad574284", 464 | "sha256:a5198beca36778f19a98b56f541a0529502046bc867b352dda5b6322e1ddc4fd", 465 | "sha256:a6750449759f0a83adc9df3a469483a8c3eef077490b76f30c03dc8f7a4b1d66", 466 | "sha256:a86d20210c9805a032cda14225087ec483613aff0955327c7871a3c980562c5b", 467 | "sha256:ae1f85223193f249320f695eec4242cdcc311357f5f5064c2e72cfd18017e8ee", 468 | "sha256:aed21b3142311ad139629c4e101b54f25447ec40d6f42c72ad5c1a6f4f851f3a", 469 | "sha256:b25d2ccdb2901655cc56c0fc978c5ddb35029c46bfd30d182d0e23fffd55b14b", 470 | "sha256:b4c4bcd285bf0f5272d50628e4ea3989738e3af1251b2dd7bf50da2d593f3a56", 471 | "sha256:bbdd6c719cc2ea440d7245ba71ecdda507275071753c6ffe9c8232647246f575", 472 | "sha256:c409e5888a94a3ff99783fffd9477128ffab8416e3f8b2c633993eecdcd5c267", 473 | "sha256:d1b1c8eb21de4cb5e296614e8b775d5ecf9c56b7d3c6000f4bfdb17f9e244e72", 474 | "sha256:d67f4029c57b36a0278aeae044ce382752c078c7625cef71b5e2cf3e576961f9", 475 | "sha256:d9a5e16a32fb1000c72a8734ddd8ae291974deb5d38d40d1bdd01dbe4024eeb0", 476 | "sha256:ddffc0c6d0e92cf43dc6c47639d1ef9ab3c280db2998a33dbb9953bd864841e1", 477 | "sha256:e0f08a2dba1469252462c414b66cb416c7f7295f2c85e50f735122a251fcb131", 478 | "sha256:e3b508e0de613b906267f2c484cb5e9afd3a64680e1af23386ca8f99a29c6145", 479 | "sha256:e426e213ab07a73f8759ab8d69e87d05d7a60b3ecbf7673965948dcf8ebc1c9f", 480 | "sha256:e6d5d2c97c35f83dc65ccd5d64c7ed16eba6d9403e3744e847aee648c432f0bb", 481 | "sha256:ebe1683ec85d8bca389183d01ecf4640c797d6f22e6dac3453a6c492920d5ec3", 482 | "sha256:ef0e3279e72cccc3dc7be75b12b1e54cc938d7ce13f5f22bea844b9d9d5fecd4", 483 | "sha256:efa67f46c1678df541e8f41247d22430905f80a3296d9c914aaa793f2c9fa1db", 484 | "sha256:f41feb8cf429799ac43ed34504839954aa7d907f8bd9ecb52ed5ff0d2ea84245", 485 | "sha256:fab52db4d3aa3b73bcf920fb375dbea63bf0df0cb4bdb38c5a0a69e16568cc21" 486 | ], 487 | "markers": "python_version >= '3.7'", 488 | "version": "==4.4.1" 489 | }, 490 | "python-dateutil": { 491 | "hashes": [ 492 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 493 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 494 | ], 495 | "index": "pypi", 496 | "version": "==2.8.2" 497 | }, 498 | "quart": { 499 | "hashes": [ 500 | "sha256:578a466bcd8c58b947b384ca3517c2a2f3bfeec8f58f4ff5038d4506ffee6be7", 501 | "sha256:c1766f269cdb85daf9da67ba54170abf7839aca97304dcb4cd0778eabfb442c6" 502 | ], 503 | "index": "pypi", 504 | "version": "==0.18.4" 505 | }, 506 | "quart-cors": { 507 | "hashes": [ 508 | "sha256:a12cb8f82506be9794c7d0fba62be04f07ca719e47e0691bf7a63d5ce661b70e", 509 | "sha256:e7c3f176624cfaa934ea96eddbcaea0b6c225d8eee543f97bce3bdc22e1e00ef" 510 | ], 511 | "index": "pypi", 512 | "version": "==0.6.0" 513 | }, 514 | "redis": { 515 | "hashes": [ 516 | "sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d", 517 | "sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c" 518 | ], 519 | "index": "pypi", 520 | "version": "==4.6.0" 521 | }, 522 | "six": { 523 | "hashes": [ 524 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 525 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 526 | ], 527 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 528 | "version": "==1.16.0" 529 | }, 530 | "sniffio": { 531 | "hashes": [ 532 | "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", 533 | "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" 534 | ], 535 | "markers": "python_version >= '3.7'", 536 | "version": "==1.3.0" 537 | }, 538 | "strenum": { 539 | "hashes": [ 540 | "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", 541 | "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659" 542 | ], 543 | "index": "pypi", 544 | "version": "==0.4.15" 545 | }, 546 | "tenacity": { 547 | "hashes": [ 548 | "sha256:2f277afb21b851637e8f52e6a613ff08734c347dc19ade928e519d7d2d8569b0", 549 | "sha256:43af037822bd0029025877f3b2d97cc4d7bb0c2991000a3d59d71517c5c969e0" 550 | ], 551 | "index": "pypi", 552 | "version": "==8.2.2" 553 | }, 554 | "tomli": { 555 | "hashes": [ 556 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 557 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 558 | ], 559 | "markers": "python_version < '3.11'", 560 | "version": "==2.0.1" 561 | }, 562 | "typing-extensions": { 563 | "hashes": [ 564 | "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", 565 | "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" 566 | ], 567 | "markers": "python_version < '3.11'", 568 | "version": "==4.7.1" 569 | }, 570 | "validators": { 571 | "hashes": [ 572 | "sha256:24148ce4e64100a2d5e267233e23e7afeb55316b47d30faae7eb6e7292bc226a" 573 | ], 574 | "index": "pypi", 575 | "version": "==0.20.0" 576 | }, 577 | "werkzeug": { 578 | "hashes": [ 579 | "sha256:935539fa1413afbb9195b24880778422ed620c0fc09670945185cce4d91a8890", 580 | "sha256:98c774df2f91b05550078891dee5f0eb0cb797a522c757a2452b9cee5b202330" 581 | ], 582 | "markers": "python_version >= '3.8'", 583 | "version": "==2.3.6" 584 | }, 585 | "wsproto": { 586 | "hashes": [ 587 | "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", 588 | "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736" 589 | ], 590 | "markers": "python_full_version >= '3.7.0'", 591 | "version": "==1.2.0" 592 | }, 593 | "zipp": { 594 | "hashes": [ 595 | "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0", 596 | "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147" 597 | ], 598 | "markers": "python_version >= '3.8'", 599 | "version": "==3.16.2" 600 | } 601 | }, 602 | "develop": { 603 | "asttokens": { 604 | "hashes": [ 605 | "sha256:4622110b2a6f30b77e1473affaa97e711bc2f07d3f10848420ff1898edbe94f3", 606 | "sha256:6b0ac9e93fb0335014d382b8fa9b3afa7df546984258005da0b9e7095b3deb1c" 607 | ], 608 | "version": "==2.2.1" 609 | }, 610 | "autopep8": { 611 | "hashes": [ 612 | "sha256:86e9303b5e5c8160872b2f5ef611161b2893e9bfe8ccc7e2f76385947d57a2f1", 613 | "sha256:f9849cdd62108cb739dbcdbfb7fdcc9a30d1b63c4cc3e1c1f893b5360941b61c" 614 | ], 615 | "index": "pypi", 616 | "version": "==2.0.2" 617 | }, 618 | "backcall": { 619 | "hashes": [ 620 | "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", 621 | "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" 622 | ], 623 | "version": "==0.2.0" 624 | }, 625 | "decorator": { 626 | "hashes": [ 627 | "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", 628 | "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186" 629 | ], 630 | "markers": "python_version >= '3.5'", 631 | "version": "==5.1.1" 632 | }, 633 | "exceptiongroup": { 634 | "hashes": [ 635 | "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", 636 | "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" 637 | ], 638 | "markers": "python_version < '3.11'", 639 | "version": "==1.1.2" 640 | }, 641 | "executing": { 642 | "hashes": [ 643 | "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc", 644 | "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107" 645 | ], 646 | "version": "==1.2.0" 647 | }, 648 | "iniconfig": { 649 | "hashes": [ 650 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 651 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 652 | ], 653 | "markers": "python_version >= '3.7'", 654 | "version": "==2.0.0" 655 | }, 656 | "ipython": { 657 | "hashes": [ 658 | "sha256:1d197b907b6ba441b692c48cf2a3a2de280dc0ac91a3405b39349a50272ca0a1", 659 | "sha256:248aca623f5c99a6635bc3857677b7320b9b8039f99f070ee0d20a5ca5a8e6bf" 660 | ], 661 | "index": "pypi", 662 | "version": "==8.14.0" 663 | }, 664 | "jedi": { 665 | "hashes": [ 666 | "sha256:bcf9894f1753969cbac8022a8c2eaee06bfa3724e4192470aaffe7eb6272b0c4", 667 | "sha256:cb8ce23fbccff0025e9386b5cf85e892f94c9b822378f8da49970471335ac64e" 668 | ], 669 | "markers": "python_version >= '3.6'", 670 | "version": "==0.19.0" 671 | }, 672 | "matplotlib-inline": { 673 | "hashes": [ 674 | "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311", 675 | "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304" 676 | ], 677 | "markers": "python_version >= '3.5'", 678 | "version": "==0.1.6" 679 | }, 680 | "packaging": { 681 | "hashes": [ 682 | "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", 683 | "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" 684 | ], 685 | "markers": "python_version >= '3.7'", 686 | "version": "==23.1" 687 | }, 688 | "parso": { 689 | "hashes": [ 690 | "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0", 691 | "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75" 692 | ], 693 | "markers": "python_version >= '3.6'", 694 | "version": "==0.8.3" 695 | }, 696 | "pexpect": { 697 | "hashes": [ 698 | "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", 699 | "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" 700 | ], 701 | "markers": "sys_platform != 'win32'", 702 | "version": "==4.8.0" 703 | }, 704 | "pickleshare": { 705 | "hashes": [ 706 | "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", 707 | "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" 708 | ], 709 | "version": "==0.7.5" 710 | }, 711 | "pluggy": { 712 | "hashes": [ 713 | "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", 714 | "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" 715 | ], 716 | "markers": "python_version >= '3.7'", 717 | "version": "==1.2.0" 718 | }, 719 | "prompt-toolkit": { 720 | "hashes": [ 721 | "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac", 722 | "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88" 723 | ], 724 | "markers": "python_full_version >= '3.7.0'", 725 | "version": "==3.0.39" 726 | }, 727 | "ptyprocess": { 728 | "hashes": [ 729 | "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", 730 | "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" 731 | ], 732 | "version": "==0.7.0" 733 | }, 734 | "pure-eval": { 735 | "hashes": [ 736 | "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350", 737 | "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3" 738 | ], 739 | "version": "==0.2.2" 740 | }, 741 | "pycodestyle": { 742 | "hashes": [ 743 | "sha256:259bcc17857d8a8b3b4a2327324b79e5f020a13c16074670f9c8c8f872ea76d0", 744 | "sha256:5d1013ba8dc7895b548be5afb05740ca82454fd899971563d2ef625d090326f8" 745 | ], 746 | "markers": "python_version >= '3.8'", 747 | "version": "==2.11.0" 748 | }, 749 | "pygments": { 750 | "hashes": [ 751 | "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692", 752 | "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29" 753 | ], 754 | "markers": "python_version >= '3.7'", 755 | "version": "==2.16.1" 756 | }, 757 | "pytest": { 758 | "hashes": [ 759 | "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32", 760 | "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a" 761 | ], 762 | "index": "pypi", 763 | "version": "==7.4.0" 764 | }, 765 | "pytest-asyncio": { 766 | "hashes": [ 767 | "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d", 768 | "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b" 769 | ], 770 | "index": "pypi", 771 | "version": "==0.21.1" 772 | }, 773 | "pytest-sugar": { 774 | "hashes": [ 775 | "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062", 776 | "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46" 777 | ], 778 | "index": "pypi", 779 | "version": "==0.9.7" 780 | }, 781 | "six": { 782 | "hashes": [ 783 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 784 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 785 | ], 786 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 787 | "version": "==1.16.0" 788 | }, 789 | "stack-data": { 790 | "hashes": [ 791 | "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815", 792 | "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8" 793 | ], 794 | "version": "==0.6.2" 795 | }, 796 | "termcolor": { 797 | "hashes": [ 798 | "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475", 799 | "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a" 800 | ], 801 | "markers": "python_version >= '3.7'", 802 | "version": "==2.3.0" 803 | }, 804 | "tomli": { 805 | "hashes": [ 806 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 807 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 808 | ], 809 | "markers": "python_version < '3.11'", 810 | "version": "==2.0.1" 811 | }, 812 | "traitlets": { 813 | "hashes": [ 814 | "sha256:9e6ec080259b9a5940c797d58b613b5e31441c2257b87c2e795c5228ae80d2d8", 815 | "sha256:f6cde21a9c68cf756af02035f72d5a723bf607e862e7be33ece505abf4a3bad9" 816 | ], 817 | "markers": "python_version >= '3.7'", 818 | "version": "==5.9.0" 819 | }, 820 | "typing-extensions": { 821 | "hashes": [ 822 | "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", 823 | "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" 824 | ], 825 | "markers": "python_version < '3.11'", 826 | "version": "==4.7.1" 827 | }, 828 | "wcwidth": { 829 | "hashes": [ 830 | "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e", 831 | "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0" 832 | ], 833 | "version": "==0.2.6" 834 | } 835 | } 836 | } 837 | --------------------------------------------------------------------------------