diff --git a/api/blueprints/mail/callback.py b/api/blueprints/mail/callback.py new file mode 100644 index 0000000..7828581 --- /dev/null +++ b/api/blueprints/mail/callback.py @@ -0,0 +1,184 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Thursday, 21st Nov., 2024 + + OBJECTIVE: + + To receive callbacks (webhooks). + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + + NOTES: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For using Quart: +from quart import Blueprint, current_app, request + +# My utils: +from utils_v2.string import json +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.response import ResponseModel +from utils_v2.api.async_quart import ( + set_api_version, + read_input, + get_session_info, + log_request_to_mongo, + log_chain_to_mongo, + should_not_be_under_maintenance, + only_whitelisted_ips, + limit_rate, + validate_input, + handle_cancelled_request +) + +# Common: +from shared import constants + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +test_callback_bp = Blueprint("user_cb", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@test_callback_bp.record_once +def init(blueprint_setup_state): + + # This gets called when the blueprint is registered. + # Consider this to be a one-time setup for the whole blueprint: + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +@test_callback_bp.route("/callback", methods = ["POST", "GET"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = False, sanitize_data = False) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "testCllBckApi", + log_input = True, + log_output = True, + sensitive_keys = None +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@handle_cancelled_request() +async def callback_test( + inbound_headers: dict = None, + inbound_data: dict = None, + inbound_files: dict = None, + **kwargs +): + + """ + This URL does nothing, just captures data on webhooks and logs it for documentation. + :param inbound_headers: auto-extracted by the decorators. + :param inbound_data: auto-extracted by the decorators. + :param inbound_files: auto-extracted by the decorators. + :param kwargs: Any number of extra inputs supplied by the decorators. + :return: A standard response structure. + """ + + # Construct a message: + message = "🪝 *WEBHOOK/CALLBACK ALERT!* 🪝\n\n" + message += f"Method: *{request.method}*\nLog Id.: `{kwargs.get('log_id')}`\n\n" + message += "*Headers:*\n```json\n" + message += json.to_string({k: v for k, v in request.headers.items()}) + message += "\n```\n" + message += "*Query Args:*\n```json\n" + message += json.to_string(request.args.to_dict()) + message += "\n```\n" + message += "*JSON:*\n```json\n" + message += json.to_string(await request.get_json()) + message += "\n```\n" + message += "*Form-Data:*\n```json\n" + message += json.to_string((await request.form).to_dict()) + message += "\n```\n" + message += "*Form-Files:*\n```json\n" + message += json.to_string(inbound_files, default = str) + message += "\n```\n" + + # Send a message on Telegram: + api_response = await current_app.http_client.post( + url = current_app.script_data["alerts"]["url"], + json = { + "message": message, + "type": "info", + "chatId": "1275560043" # ... KPS + } + ) + + # Return a success response: + return ResponseModel( + status_code = StatusCodes.OK, + data = {"accepted": True} + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/utils_v2/api/async_quart.py b/utils_v2/api/async_quart.py index 2158d7e..7bd0abe 100644 --- a/utils_v2/api/async_quart.py +++ b/utils_v2/api/async_quart.py @@ -43,7 +43,7 @@ from functools import wraps # My utils: from utils_v2.string import json -from utils_v2.datetime import datetime +from utils_v2.date_time import date_time from utils_v2.security import sanitizers from utils_v2.api.codes import StatusCodes, HttpCodes from utils_v2.api.log import APILogModel @@ -79,6 +79,7 @@ import asyncio # For timekeeping: import time +import datetime # ***************************************************************************************************************** @@ -811,7 +812,7 @@ def log_request_to_mongo( # Make variables and extract available info.: exception = None response = None - request_ts = datetime.get_current_utc_date_time() + request_ts = date_time.get_current_utc_date_time() start_ts = time.perf_counter() cpu_start_ts = time.process_time() diff --git a/utils_v2/datetime/__init__.py b/utils_v2/date_time/__init__.py similarity index 100% rename from utils_v2/datetime/__init__.py rename to utils_v2/date_time/__init__.py diff --git a/utils_v2/datetime/datetime.py b/utils_v2/date_time/date_time.py similarity index 100% rename from utils_v2/datetime/datetime.py rename to utils_v2/date_time/date_time.py diff --git a/utils_v2/logging/context.py b/utils_v2/logging/context.py index a25e96c..d7ead43 100644 --- a/utils_v2/logging/context.py +++ b/utils_v2/logging/context.py @@ -46,7 +46,7 @@ import os # My utils: from utils_v2.string import json -from utils_v2.datetime import datetime +from utils_v2.date_time import date_time from utils_v2.security import sanitizers from utils_v2.api.codes import StatusCodes, HttpCodes from utils_v2.api.log import APILogModel @@ -330,7 +330,7 @@ class AsyncLoggerContext: # Make variables and extract available info.: exception = None response = None - request_ts = datetime.get_current_utc_date_time() + request_ts = date_time.get_current_utc_date_time() start_ts = time.perf_counter() cpu_start_ts = time.process_time() diff --git a/utils_v2/oauth/base.py b/utils_v2/oauth/base.py index 588d4f2..d98f2b8 100644 --- a/utils_v2/oauth/base.py +++ b/utils_v2/oauth/base.py @@ -41,6 +41,9 @@ import io # For defining the class's structure: from abc import ABC, abstractmethod +# For working with datatypes: +from typing import List + # For debugging: from icecream import IceCreamDebugger @@ -115,6 +118,41 @@ class OAuthBase(ABC): def debug_everything(self): self._debug_only_errors = False + # ┏┓┓ ┳┳┓ ┓ ┓ + # ┣┫┣┓┏╋┏┓┏┓┏╋ ┃┃┃┏┓╋┣┓┏┓┏┫┏ + # ┛┗┗┛┛┗┛ ┗┻┗┗ ┛ ┗┗ ┗┛┗┗┛┗┻┛ + + @abstractmethod + async def initialize( + self, + scopes: List + ) -> bool: + + """ + To initialize the service-specific OAuth2.0 class. For example, in Google's case, we need to initialize an app + flow that was created for an app through its Cloud Console panel. + :param scopes: The list of permissions being requested. The word 'scopes' has been borrowed from Google's OAuth + documentation (which was implemented first). + :return: True if the initialization succeeded, False if it failed. + """ + + pass + + async def get_authorization_url( + self, + **kwargs + ) -> str | None: + + """ + To create an authorization URL which will be then sent to the front-end for the user to click and grant/decline + various permissions. + :param kwargs: The identifiers of the user who wants to use your service (where your service needs access to + their second-party account). + :return: The authorization URL if successful, or None if failed. + """ + + pass + # ***************************************************************************************************************** # ***** **** diff --git a/utils_v2/oauth/services/goog.py b/utils_v2/oauth/services/goog.py index 9db12aa..d9024f1 100644 --- a/utils_v2/oauth/services/goog.py +++ b/utils_v2/oauth/services/goog.py @@ -15,6 +15,8 @@ REFERENCES: 1. https://developers.google.com/calendar/api/quickstart/python + 2. https://developers.google.com/identity/protocols/oauth2/web-server#python + 3. https://www.youtube.com/watch?v=vQQEaSnQ_bs&t=940s&pp=ygUVb2F1dGgyIHB5dGhvbiB5b3V0dWJl DOWNLOADS: @@ -40,6 +42,7 @@ import io # My utils: from utils_v2.string import json +from utils_v2.date_time import date_time # The base model: from utils_v2.oauth.base import OAuthBase @@ -52,6 +55,12 @@ from google_auth_oauthlib.flow import InstalledAppFlow # For asynchronous activities: import asyncio +# To make deep-copies: +import copy + +# To work with date and time: +import datetime + # ***************************************************************************************************************** # ***** **** @@ -100,28 +109,191 @@ class GoogleOAuth(OAuthBase): # Class variables: __flow = None - async def init( + # ┏┓┓ ┳┳┓ ┓ ┓ + # ┣┫┣┓┏╋┏┓┏┓┏╋ ┃┃┃┏┓╋┣┓┏┓┏┫┏ + # ┛┗┗┛┛┗┛ ┗┻┗┗ ┛ ┗┗ ┗┛┗┗┛┗┻┛ + + async def initialize( self, - scopes = None - ): + scopes, + raise_exception = False + ) -> bool: - # Initialize your Google App: - self._printer("Initializing flow.") - self.__flow = InstalledAppFlow.from_client_config( - self._config, - scopes = scopes or SCOPES_GMAIL_MAIL_MANAGEMENT, - redirect_uri = self._redirect_url - ) + """ + Initialize the Google OAuth mechanism by creating an app-flow. This defines the app that you are trying to + deploy. You must create this app in Google's Cloud Platform's console. + :param scopes: The scopes (permissions) needed by this app. + :param raise_exception: If set to True, any exception that occurs will be propagated. If set to false, any + exception that occurs will be suppressed. + :return: True if the initialization succeeded, False if it failed. + """ - async def get_authorization_url(self): + # Start by assuming success: + success = True - authorization_url, state = self.__flow.authorization_url( - access_type = "offline", - include_granted_scopes = "true" - ) + try: + # Initialize your Google App: + self._printer("Initializing flow.") + self.__flow = InstalledAppFlow.from_client_config( + self._config, + scopes = scopes, + redirect_uri = self._redirect_url + ) + + # If something goes wrong: + except Exception as exception: + self._printer(exception) + if raise_exception: raise + success = False + + # Done here: + return success + + async def get_authorization_url( + self, + raise_exception = False, + **kwargs, + ) -> str | None: + + """ + To generate an authorization URL that can be sent to the front end. When the + :param raise_exception: If set to True, any exception that occurs will be propagated. If set to false, any + exception that occurs will be suppressed. + :param kwargs: Any no. of keyword args that you might want to give to this specific service. + :return: + """ + + # Start by assuming failure: + authorization_url = None + + try: + + # Request a URL that will be sent to the user to request + # permissions to access their account: + authorization_url, _ = self.__flow.authorization_url( + access_type = kwargs.get("access_type", "offline"), + approval_prompt = kwargs.get("approval_prompt", "force"), + include_granted_scopes = kwargs.get("include_granted_scopes", "true"), + login_hint = kwargs.get("email"), + state = kwargs.get("user_id") + ) + + # Done here: + return authorization_url + + # If something goes wrong: + except Exception as exception: + self._printer(exception) + if raise_exception: raise + authorization_url = None + + # Done here: return authorization_url + async def get_tokens( + self, + raise_exception = False, + **kwargs + ) -> Credentials | dict | None: + + # Start by assuming failure: + tokens = None + + try: + + # Fetch the tokens: + credentials = self.__flow.fetch_token(authorization_response = kwargs["redirect_url"]) + tokens = { + "access_token": credentials.get("access_token"), + "refresh_token": credentials.get("refresh_token"), + "expires_in": (ttl := credentials["expires_in"] - 60), + "expires_at": date_time.get_current_utc_date_time() + datetime.timedelta(seconds = ttl), + "scopes": credentials.get("scope"), + } + + # If something goes wrong: + except Exception as exception: + self._printer(exception) + if raise_exception: raise + tokens = None + + # Done here: + return tokens + + async def refresh_tokens( + self, + old_tokens: dict, + raise_exception = False + ) -> dict | None: + + # Start by assuming failure: + tokens = None + + try: + + # If the tokens haven't expired, just return the existing tokens back: + tokens_expired = True if date_time.get_current_utc_date_time() >= old_tokens["expires_at"] else False + if not tokens_expired: return old_tokens + + # Construct the credentials and request a refresh: + credentials = await self.credentials_from_tokens(old_tokens) + if tokens_expired and credentials.refresh_token: + credentials.refresh(Request()) + tokens = { + "access_token": credentials.token, + "refresh_token": credentials.refresh_token, + "expires_at": (exp_at := date_time.as_if_timezone( + credentials.expiry, + timezone = date_time.TIMEZONE_UTC + )), + "expires_in": (exp_at - date_time.get_current_utc_date_time()).total_seconds(), + "scopes": credentials.scopes, + } + + # If something goes wrong: + except Exception as exception: + self._printer(exception) + if raise_exception: raise + tokens = None + + # Done here: + return tokens + + # ┏┓ • ┏┓ •┏• + # ┗┓┏┓┏┓┓┏┓┏┏┓ ┗┓┏┓┏┓┏┓╋┓┏ + # ┗┛┗ ┛ ┗┛┗┗┗ ┗┛┣┛┗ ┗┗┛┗┗ + # ┛ + + async def credentials_from_tokens( + self, + tokens: dict, + raise_exception = False + ) -> Credentials | None: + + # Start by assuming failure: + credentials = None + + try: + + # Make a deep-copy and add some fields from the config: + tokens_copy = copy.deepcopy(tokens) + first_key = list(self._config.keys())[0] + tokens_copy["client_id"] = self._config.get(first_key, {}).get("client_id") + tokens_copy["client_secret"] = self._config.get(first_key, {}).get("client_secret") + + # Create the credentials: + credentials = Credentials.from_authorized_user_info(info = tokens_copy) + + # If something goes wrong: + except Exception as exception: + self._printer(exception) + if raise_exception: raise + credentials = None + + # Done here: + return credentials + # ***************************************************************************************************************** # ***** **** @@ -132,21 +304,44 @@ class GoogleOAuth(OAuthBase): if __name__ == "__main__": + import dateparser + secrets_file = r"../../../creds/google_converse_test_oauth.json" secrets_dict = json.from_file(secrets_file) my_goog = GoogleOAuth( config = secrets_dict, - # redirect_url = r"https://v2.api.bicree.com/user/callback/test", - redirect_url = r"127.0.0.1", + redirect_url = r"https://nexcom.ditscentre.in/converse/test/callback", debug = True, debug_prefix = "OAuth (Goog) | " ) async def main(): - await my_goog.init() - print("AUTH URL:", await my_goog.get_authorization_url()) + await my_goog.initialize(scopes = SCOPES_GMAIL_MAIL_MANAGEMENT) + # await asyncio.sleep(1.0) + # print("AUTH URL 0:", await my_goog.get_authorization_url( + # user_id = "BHOPLI", + # # email = "pskhushal@gmail.com" + # )) + # + # redirect_url = input("Paste the redirect URL here: ") + # print("TOKENS:", json.to_string(await my_goog.get_tokens(redirect_url = redirect_url), default = str)) + + old_tok = { + "access_token": "ya29.a0AeDClZAjmLUZ1hh0aTbddz4ThjRzwQNMwdi_H4AYO-C4ETvWpK8aHYz5eV9PTUlFDJKQoHYtFgu4u2XfoiOdIEMVNwAKFbb8sakIWef7Yk5HdiDYC0A-MUp5XZnoNGLuP_GW_O3IxfCLH7cC0fb3AfHx4OsBpa_Qu1X-IpvfaCgYKAaoSARMSFQHGX2MiX81qoRex393eWE229OMCUg0175", + "refresh_token": "1//0g4mVQrydnc1ECgYIARAAGBASNgF-L9Irk_UxcRjgkz_YyK5Ujs1qCaj8nKL7bqQ0jHnHYlpVRh_Pwm77X4Angp8R-o-fgmKfzg", + "expires_in": 3539, + "expires_at": dateparser.parse("2024-11-25 09:24:56.690876+00:00"), + "token_type": "Bearer", + "scopes": [ + "https://www.googleapis.com/auth/gmail.labels", + "https://www.googleapis.com/auth/gmail.modify" + ] + } + + new_tok = await my_goog.refresh_tokens(old_tokens = old_tok) + print("NEW TOKENS:", json.to_string(new_tok, default = str)) asyncio.run(main())