From e118af5d539562f4437ad11e226a37df4df48a1e Mon Sep 17 00:00:00 2001 From: Jet Li Date: Sat, 19 Oct 2024 23:28:13 +0000 Subject: [PATCH] Temp commit --- .../backend/application/signin_hub.py | 151 +++ .../backend/business/signin_manager.py | 95 ++ .../backend/infra/auth/user_auth_handler.py | 288 +++++ .../infra/code_management/depot_handler.py | 416 +++++++ .../infra/code_management/gitea/__init__.py | 32 + .../code_management/gitea/api_object_base.py | 120 ++ .../code_management/gitea/api_objects.py | 1004 +++++++++++++++++ .../infra/code_management/gitea/exceptions.py | 30 + .../infra/code_management/gitea/gitea.py | 392 +++++++ .../backend/infra/code_management/readme.md | 7 + .../user_profile/user_profile_handler.py | 2 + .../backend/models/constants.py | 43 + .../backend/models/gitea/constants.py | 14 + .../backend/models/gitea/models.py | 21 + .../backend/models/gitea/readme.md | 1 + app/authentication/backend/models/models.py | 37 + .../backend/models/permission/constants.py | 26 + .../backend/models/user/constants.py | 24 + .../backend/models/user/models.py | 20 + .../services/auth/user_auth_service.py | 30 + .../services/user/user_management_service.py | 80 ++ .../common/config/app_settings.py | 20 + .../webapi/bootstrap/application.py | 75 ++ .../webapi/bootstrap/freeleaps_app.py | 6 + .../webapi/config/site_settings.py | 25 + app/authentication/webapi/main.py | 30 + app/authentication/webapi/providers/common.py | 31 + .../webapi/providers/database.py | 25 + .../webapi/providers/exception_handler.py | 39 + app/authentication/webapi/providers/logger.py | 60 + app/authentication/webapi/providers/router.py | 34 + .../webapi/providers/scheduler.py | 8 + app/authentication/webapi/routes/__init__.py | 5 + app/authentication/webapi/routes/api.py | 15 + .../webapi/routes/signin/__init__.py | 20 + .../signin/reset_password_through_email.py | 37 + .../signin/signin_with_email_and_code.py | 87 ++ .../signin/signin_with_email_and_password.py | 87 ++ .../routes/signin/try_signin_with_email.py | 31 + .../routes/signin/update_user_password.py | 42 + .../webapi/routes/tokens/__init__.py | 12 + .../webapi/routes/tokens/generate_tokens.py | 35 + .../webapi/routes/tokens/refresh_token.py | 33 + .../webapi/routes/tokens/verify_token.py | 27 + app/central_storage/start_central_storage.sh | 4 - infra/exception/exceptions.py | 5 + infra/i18n/region_handler.py | 19 + infra/models/constants.py | 6 + infra/token/token_manager.py | 46 +- infra/utils/date.py | 22 + infra/utils/string.py | 88 ++ 51 files changed, 3782 insertions(+), 25 deletions(-) create mode 100644 app/authentication/backend/application/signin_hub.py create mode 100644 app/authentication/backend/business/signin_manager.py create mode 100644 app/authentication/backend/infra/auth/user_auth_handler.py create mode 100644 app/authentication/backend/infra/code_management/depot_handler.py create mode 100644 app/authentication/backend/infra/code_management/gitea/__init__.py create mode 100644 app/authentication/backend/infra/code_management/gitea/api_object_base.py create mode 100644 app/authentication/backend/infra/code_management/gitea/api_objects.py create mode 100644 app/authentication/backend/infra/code_management/gitea/exceptions.py create mode 100644 app/authentication/backend/infra/code_management/gitea/gitea.py create mode 100644 app/authentication/backend/infra/code_management/readme.md create mode 100644 app/authentication/backend/infra/user_profile/user_profile_handler.py create mode 100644 app/authentication/backend/models/constants.py create mode 100644 app/authentication/backend/models/gitea/constants.py create mode 100644 app/authentication/backend/models/gitea/models.py create mode 100644 app/authentication/backend/models/gitea/readme.md create mode 100644 app/authentication/backend/models/models.py create mode 100644 app/authentication/backend/models/permission/constants.py create mode 100644 app/authentication/backend/models/user/constants.py create mode 100644 app/authentication/backend/models/user/models.py create mode 100644 app/authentication/backend/services/auth/user_auth_service.py create mode 100644 app/authentication/backend/services/user/user_management_service.py create mode 100644 app/authentication/common/config/app_settings.py create mode 100644 app/authentication/webapi/bootstrap/application.py create mode 100644 app/authentication/webapi/bootstrap/freeleaps_app.py create mode 100644 app/authentication/webapi/config/site_settings.py create mode 100755 app/authentication/webapi/main.py create mode 100644 app/authentication/webapi/providers/common.py create mode 100644 app/authentication/webapi/providers/database.py create mode 100644 app/authentication/webapi/providers/exception_handler.py create mode 100644 app/authentication/webapi/providers/logger.py create mode 100644 app/authentication/webapi/providers/router.py create mode 100644 app/authentication/webapi/providers/scheduler.py create mode 100644 app/authentication/webapi/routes/__init__.py create mode 100644 app/authentication/webapi/routes/api.py create mode 100644 app/authentication/webapi/routes/signin/__init__.py create mode 100644 app/authentication/webapi/routes/signin/reset_password_through_email.py create mode 100644 app/authentication/webapi/routes/signin/signin_with_email_and_code.py create mode 100644 app/authentication/webapi/routes/signin/signin_with_email_and_password.py create mode 100644 app/authentication/webapi/routes/signin/try_signin_with_email.py create mode 100644 app/authentication/webapi/routes/signin/update_user_password.py create mode 100644 app/authentication/webapi/routes/tokens/__init__.py create mode 100644 app/authentication/webapi/routes/tokens/generate_tokens.py create mode 100644 app/authentication/webapi/routes/tokens/refresh_token.py create mode 100644 app/authentication/webapi/routes/tokens/verify_token.py delete mode 100755 app/central_storage/start_central_storage.sh create mode 100644 infra/i18n/region_handler.py create mode 100644 infra/models/constants.py create mode 100644 infra/utils/date.py create mode 100644 infra/utils/string.py diff --git a/app/authentication/backend/application/signin_hub.py b/app/authentication/backend/application/signin_hub.py new file mode 100644 index 0000000..d1b0d76 --- /dev/null +++ b/app/authentication/backend/application/signin_hub.py @@ -0,0 +1,151 @@ +from app.authentication.backend.models.constants import ( + NewUserMethod, + UserAccountProperty, + UserLoginAction, +) +from typing import Optional, Tuple +from infra.i18n.region_handler import RegionHandler +from infra.models.constants import UserRegion +from infra.log.log_utils import log_entry_exit_async +from app.authentication.backend.infra.user_management.user_auth_handler import ( + UserAuthManager, +) +from app.authentication.backend.business.signin_manager import SignInManager + + +class SignInHub: + def __init__(self) -> None: + self.user_auth_manager = UserAuthManager() + self.signin_manager = SignInManager() + self.basic_profile_store = BasicProfileStore() + self.provider_profile_store = ProviderProfileStore() + self.code_depot_manager = CodeDepotManager() + self.notification_center = NotificationCenter(sender_id=settings.SYSTEM_USER_ID) + self.event_dispatcher = UserEventDispatcher(owner_id=settings.SYSTEM_USER_ID) + self.module_logger = ModuleLogger(sender_id=UserManager) + + async def signin_with_email_and_code( + self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" + ) -> Tuple[int, Optional[int], Optional[str], Optional[str]]: + """ + Interacts with the business layer to handle the sign-in process with email and code. + """ + return await self.signin_manager.signin_with_email_and_code( + email=email, code=code, host=host, time_zone=time_zone + ) + + @log_entry_exit_async + async def signin_with_email_and_code( + self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str]]: + """Try to signin with email and code. + create a new user account, if the email address has never been used before. + + Args: + email (str): email address + code (str): auth code to be verified + host (str): the host address by which the client access the frontend service + + Returns: + [int, Optional[int], Optional[str], Optional[str]]: + - int: UserLoginAction + - Optional[int]: user role + - Optional[str]: user_id + - Optional[str]: flid + """ + + # Step 1: Verify the email and code + try: + user_id, is_new_user, preferred_region = ( + await self.signin_manager.verify_email_with_code(email, code, host) + ) + except InvalidAuthCodeException: + await self.logger.log_info( + info="The auth code is invalid.", + properties={"email": email, "code": code}, + ) + return [UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None] + + # Step 2: Handle new user creation if necessary + # This will be moved to the Freeleaps + if is_new_user: + user_id = await self.user_service.create_new_user( + NewUserMethod.EMAIL, preferred_region, email, time_zone + ) + await self.event_service.log_signup_event( + user_id, email, preferred_region, time_zone + ) + + # Step 3: Fetch user account and handle login actions + user_account = await self.user_service.get_user_account(user_id) + await self.event_service.log_user_login_event(user_id) + + # Step 4: Handle special actions (FLID reset, password reset, etc.) + if await self.user_service.is_flid_reset_required(user_id): + return [ + UserLoginAction.REVIEW_AND_REVISE_FLID, + user_account.user_role, + user_id, + email.split("@")[0], + preferred_region, + ] + + user_flid = await self.user_service.get_flid(user_id) + + if await self.auth_service.is_password_reset_required(user_id): + return [ + UserLoginAction.NEW_USER_SET_PASSWORD, + user_account.user_role, + user_id, + user_flid, + user_account.preferred_region, + ] + + return [ + UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, + user_account.user_role, + user_id, + user_flid, + user_account.preferred_region, + ] + + @log_entry_exit_async + async def __create_new_user_account( + self, method: NewUserMethod, region: UserRegion + ) -> str: + """create a new user account document in DB + + Args: + method (NewUserMethod): the method the new user came from + region : preferred user region detected via the user log-in website + + Returns: + str: id of user account + """ + if NewUserMethod.EMAIL == method: + user_account = UserAccountDoc( + profile_id=None, + account_id=None, + service_plan_id=None, + properties=int(UserAccountProperty.EMAIL_VERIFIED), + capabilities=int(Capability.VISITOR), + user_role=int(AdministrativeRole.PERSONAL), + region=region, + ) + user_account = await user_account.create() + + elif NewUserMethod.MOBILE == method: + user_account = UserAccountDoc( + profile_id=None, + account_id=None, + service_plan_id=None, + properties=int(UserAccountProperty.MOBILE_VERIFIED), + capabilities=int(Capability.VISITOR), + user_role=int(AdministrativeRole.PERSONAL), + region=region, + ) + user_account = await user_account.create() + + # Create other doc in collections for the new user + await UserAchievement(str(user_account.id)).create_activeness_achievement() + return str(user_account.id) diff --git a/app/authentication/backend/business/signin_manager.py b/app/authentication/backend/business/signin_manager.py new file mode 100644 index 0000000..1eede32 --- /dev/null +++ b/app/authentication/backend/business/signin_manager.py @@ -0,0 +1,95 @@ +# business/auth/signin_business.py +from typing import Tuple, Optional +from app.authentication.backend.services.auth.user_auth_service import UserAuthService +from infra.i18n.region_handler import RegionHandler +from services.auth.achievement_service import AchievementService +from services.auth.event_service import EventService +from services.auth.profile_service import ProfileService +from utils.enums.auth import UserLoginAction, NewUserMethod +from infra.config import settings +from infra.exception.exceptions import InvalidAuthCodeException +from app.authentication.backend.models.constants import UserLoginAction +from app.authentication.backend.services.user.user_management_service import ( + UserManagementService, +) + + +class SignInManager: + def __init__(self): + self.user_auth_service = UserAuthService() + self.region_handler = RegionHandler() + self.user_management_service = UserManagementService() + self.achievement_service = AchievementService() + self.event_service = EventService() + self.profile_service = ProfileService() + + async def signin_with_email_and_code( + self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" + ) -> Tuple[int, Optional[int], Optional[str], Optional[str]]: + """ + Handles the business logic for signing in with email and code. + """ + user_id = await self.user_auth_service.get_user_id_by_email(email) + is_new_user = user_id is None + preferred_region = self.region_handler.detect_from_host(host) + + if await self.user_auth_service.verify_email_with_code(email, code): + if is_new_user: + user_id = await self.user_management_service.create_new_user_account( + method=NewUserMethod.EMAIL, region=preferred_region + ) + await self.user_service.initialize_new_user_data( + user_id=user_id, + email=email, + region=preferred_region, + time_zone=time_zone, + ) + await self.event_service.log_signup_event( + user_id=user_id, + email=email, + region=preferred_region, + time_zone=time_zone, + ) + + user_account = await self.user_service.get_user_account(user_id) + # This will be done by sending the notification back to Freeleaps App + # await self.achievement_service.record_login_event(user_id) + + if await self.profile_service.is_flid_reset_required(user_id): + return ( + UserLoginAction.REVIEW_AND_REVISE_FLID, + user_account.role, + user_id, + email.split("@")[0], + preferred_region, + ) + + flid = await self.profile_service.get_user_flid(user_id) + + if await self.user_service.is_password_reset_required(user_id): + return ( + UserLoginAction.NEW_USER_SET_PASSWORD, + user_account.role, + user_id, + flid, + preferred_region, + ) + + return ( + UserLoginAction.USER_SIGNED_IN, + user_account.role, + user_id, + flid, + preferred_region, + ) + else: + return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None + + async def verify_email_with_code(self, email: str, code: str, host: str): + user_id = await self.user_auth_service.get_user_id_by_email(email) + if not await self.user_auth_service.verify_email_with_code(email, code): + raise InvalidAuthCodeException() + + is_new_user = user_id is None + preferred_region = self.region_handler.detect_from_host(host) + return user_id, is_new_user, preferred_region diff --git a/app/authentication/backend/infra/auth/user_auth_handler.py b/app/authentication/backend/infra/auth/user_auth_handler.py new file mode 100644 index 0000000..638a74c --- /dev/null +++ b/app/authentication/backend/infra/auth/user_auth_handler.py @@ -0,0 +1,288 @@ +import bcrypt +from datetime import datetime, timedelta, timezone +from typing import Optional + +from infra.utils.string import generate_auth_code +from app.authentication.backend.infra.code_management.depot_handler import ( + CodeDepotHandler, +) + + +from app.authentication.backend.models.constants import ( + AuthType, +) +from app.authentication.backend.models.models import ( + AuthCodeDoc, + UserEmailDoc, + UserMobileDoc, + UserPasswordDoc, +) + + +class UserAuthHandler: + def __init__(self): + self.code_depot_manager = CodeDepotHandler() + + async def verify_user_with_password(self, user_id: str, password: str) -> bool: + """Verify user's password + Args: + user_id (str): user identity, _id in UserAccountDoc + password (str): password user provided, clear text + + Returns: + bool: True if password is correct, else return False + """ + + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password: + # password is reseted to empty string, cannot be verified + if user_password.password == "": + return False + + if bcrypt.checkpw( + password.encode("utf-8"), user_password.password.encode("utf-8") + ): + return True + else: + return False + else: + return False + + async def get_user_password(self, user_id: str) -> Optional[str]: + """Get user password through the user_id + + Args: + user_id (str): user identity, _id in UserAccountDoc + + Returns: + str: password hash + """ + + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password is None: + return None + else: + return user_password.password + + async def get_user_email(self, user_id: str) -> Optional[str]: + """get user email through the user_id + + Args: + user_id (str): user identity, _id in UserAccountDoc + + Returns: + str: email address + """ + user_email = await UserEmailDoc.find( + UserEmailDoc.user_id == user_id + ).first_or_none() + + if user_email is None: + return None + else: + return user_email.email + + async def get_user_id_by_email(self, email: str) -> Optional[str]: + """get user id through email from user_email doc + + Args: + email (str): email address, compare email address in lowercase + + Returns: + Optional[str]: user_id or None + """ + user_email = await UserEmailDoc.find( + UserEmailDoc.email == email.lower() + ).first_or_none() + + if user_email is None: + return None + else: + return user_email.user_id + + def user_sign_out(self, token): + pass + + async def generate_auth_code_for_email(self, email: str) -> str: + """send auth code to email address + + Args: + email (str): email address + """ + auth_code = generate_auth_code() + expiry = datetime.now(timezone.utc) + timedelta(minutes=5) + auth_code_doc = AuthCodeDoc( + auth_code=auth_code, + method=email.lower(), + method_type=AuthType.EMAIL, + expiry=expiry, + ) + + await auth_code_doc.create() + return auth_code + + async def verify_email_code(self, email: str, code: str) -> bool: + """sign in with email and code + + Args: + email (str): email address + code (str): auth code to be verified + + Returns: + bool: True if code is valid, False otherwise + """ + result = await AuthCodeDoc.find( + AuthCodeDoc.method == email.lower(), + AuthCodeDoc.auth_code == code, + AuthCodeDoc.expiry > datetime.now(timezone.utc), + AuthCodeDoc.method_type == AuthType.EMAIL, + ).first_or_none() + + if result: + return True + else: + return False + + async def get_user_mobile(self, user_id: str) -> Optional[str]: + """get user mobile number through the user_id + + Args: + user_id (str): user identity, _id in UserAccountDoc + + Returns: + str: mobile number + """ + user_mobile = await UserMobileDoc.find( + UserMobileDoc.user_id == user_id + ).first_or_none() + + if user_mobile is None: + return None + else: + return user_mobile.mobile + + async def generate_auth_code_for_mobile(self, mobile: str) -> str: + """send auth code to mobile number + + Args: + mobile (str): mobile number + """ + auth_code = generate_auth_code() + expiry = datetime.now(timezone.utc) + timedelta(minutes=5) + auth_code_doc = AuthCodeDoc( + auth_code=auth_code, + method=mobile.lower(), + method_type=AuthType.MOBILE, + expiry=expiry, + ) + + await auth_code_doc.create() + return auth_code + + async def verify_mobile_with_code(self, mobile, code): + """sign in with mobile and code + + Args: + mobile (str): mobile number + code (str): auth code to be verified + + Returns: + bool: True if code is valid, False otherwise + """ + result = await AuthCodeDoc.find( + AuthCodeDoc.method == mobile.lower(), + AuthCodeDoc.auth_code == code, + AuthCodeDoc.expiry > datetime.now(timezone.utc), + AuthCodeDoc.method_type == AuthType.MOBILE, + ).first_or_none() + + if result: + return True + else: + return False + + async def save_email_auth_method(self, user_id: str, email: str): + """save email auth method to user_email doc + + Args: + user_id (str): user id + email (str): email address + """ + user_email = await UserEmailDoc.find( + UserEmailDoc.user_id == user_id + ).first_or_none() + + if user_email is None: + new_user_email = UserEmailDoc(user_id=user_id, email=email.lower()) + await new_user_email.create() + else: + user_email.email = email.lower() + await user_email.save() + + async def save_password_auth_method(self, user_id: str, user_flid, password: str): + """save password auth method to user_password doc + + Args: + user_id (str): user id + password (str): user password + """ + password_hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password is None: + new_user_password = UserPasswordDoc( + user_id=user_id, password=password_hashed + ) + await new_user_password.create() + else: + user_password.password = password_hashed + await user_password.save() + + result = await self.code_depot_manager.update_depot_user_password( + user_flid, password + ) + if not result: + raise Exception("Failed to update user password in code depot") + + async def reset_password(self, user_id: str): + """clean password auth method from user_password doc + + Args: + user_id (str): user id + """ + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password: + user_password.password = "" + await user_password.save() + else: + raise Exception("User password was not set before.") + + async def is_password_reset_required(self, user_id: str) -> bool: + """check if password is required for the user + + Args: + user_id (str): user id + + Returns: + bool: True if password is required, False otherwise + """ + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password: + return user_password.password == "" + else: + return True diff --git a/app/authentication/backend/infra/code_management/depot_handler.py b/app/authentication/backend/infra/code_management/depot_handler.py new file mode 100644 index 0000000..90d1796 --- /dev/null +++ b/app/authentication/backend/infra/code_management/depot_handler.py @@ -0,0 +1,416 @@ +import pytz +import random +from infra.log.module_logger import ModuleLogger +from typing import Optional + +from app.authentication.backend.common.config.app_settings import app_settings + +from app.authentication.backend.models.constants import DepotStatus +from app.authentication.backend.infra.code_management.gitea.gitea import Gitea +from app.authentication.backend.models.gitea.models import CodeDepotDoc +from infra.exception.exceptions import InvalidOperationError +from infra.utils.date import get_sunday +from datetime import datetime +from datetime import datetime, timedelta, timezone +from dateutil.parser import parse + + +class CodeDepotHandler: + def __init__(self) -> None: + self.gitea_url = app_settings.GITEA_URL + self.gitea_token = app_settings.GITEA_TOKEN + self.gitea_org = app_settings.GITEA_DEPOT_ORGANIZATION + + self.code_depot_domain_name = app_settings.CODE_DEPOT_DOMAIN_NAME + self.code_depot_ssh_port = app_settings.CODE_DEPOT_SSH_PORT + self.code_depot_http_port = app_settings.CODE_DEPOT_HTTP_PORT + + self.gitea_admin = Gitea( + self.gitea_url, token_text=self.gitea_token, auth=None, verify=False + ) + self.product_org = self.gitea_admin.get_org_by_name(self.gitea_org) + self.module_logger = ModuleLogger(sender_id="CodeDepotManager") + + async def check_depot_name_availabe(self, code_depot_name: str) -> bool: + """Return True if the depot name is available, otherwise return False + + Parameters: + code_depot_name (str): the name of the code depot + + Returns: + bool: True if the depot name is availabe, otherwise return False + """ + + result = await CodeDepotDoc.find_one(CodeDepotDoc.depot_name == code_depot_name) + + if result: + return False + else: + return True + + def check_code_depot_exist(self, code_depot_name: str) -> bool: + """Check and return True if the code deport with the name exist in Gitea, otherwise return False + + Parameters: + code_depot_name (str): the name of the code depot + + Returns: + bool: True if the code depot exist, otherwise return False + """ + all_depots = self.product_org.get_repositories() + for depot in all_depots: + if depot.name.lower() == code_depot_name.lower(): + return True + + return False + + async def __generate_new_code_depot_name( + self, code_depot_name: str, gitea_code_depot_names: list[str] + ) -> str: + """Generate a new code depot name if the code depot name already exists + + Parameters: + code_depot_name (str): the name of the code depot + + Returns: + str: the new code depot name + """ + code_depot_name = code_depot_name.lower() + depot_doc = await CodeDepotDoc.find_one( + CodeDepotDoc.depot_name == code_depot_name + ) + + # if the depot name already exists in the database or Gitea, generate a new name + if depot_doc or (code_depot_name in gitea_code_depot_names): + new_code_depot_name = "{}-{}".format( + code_depot_name, random.randint(10000, 99999) + ) + while new_code_depot_name in gitea_code_depot_names: + new_code_depot_name = "{}-{}".format( + code_depot_name, random.randint(10000, 99999) + ) + + return await self.__generate_new_code_depot_name( + new_code_depot_name, gitea_code_depot_names + ) + else: + return code_depot_name + + async def create_code_depot(self, product_id, code_depot_name) -> Optional[str]: + """Create a new git code depot + + Parameters: + product_id (str): the id of the product + code_depot_name (str): the name of the code depot + + Returns: + str: return code depot id if it's created successfully, else return None + """ + gitea_code_depot_names = [ + depot.name.lower() for depot in self.product_org.get_repositories() + ] + code_depot_name_to_be_created = await self.__generate_new_code_depot_name( + code_depot_name, gitea_code_depot_names + ) + + new_depot_doc = CodeDepotDoc( + depot_name=code_depot_name_to_be_created, + product_id=product_id, + depot_status=DepotStatus.TO_BE_CREATED, + ) + await new_depot_doc.create() + + try: + self.product_org.create_repo( + repoName=code_depot_name_to_be_created, + description=product_id, + private=True, + autoInit=True, + default_branch="main", + ) + except Exception as e: + await self.module_logger.log_exception( + exception=e, + text=f"Failed to create depot {code_depot_name_to_be_created} in Gitea. Error: {e}", + properties={ + "code_depot_name_to_be_created": code_depot_name_to_be_created + }, + ) + raise InvalidOperationError( + f"Failed to create depot {code_depot_name_to_be_created} in Gitea" + ) + + await new_depot_doc.set({CodeDepotDoc.depot_status: DepotStatus.CREATED}) + await new_depot_doc.save() + + return str(new_depot_doc.id) + + def get_depot_ssh_url(self, code_depot_name: str) -> str: + """Return the ssh url of the code depot + + Parameters: + depot_name (str): the name of the depot + + Returns: + str: the ssh url of the code depot + """ + if self.code_depot_ssh_port != "22": + return f"git@{self.code_depot_domain_name}:{self.code_depot_ssh_port}/{self.gitea_org}/{code_depot_name}.git" + else: + return f"git@{self.code_depot_domain_name}/{self.gitea_org}/{code_depot_name}.git" + + def get_depot_http_url(self, code_depot_name: str) -> str: + """Return the http url of the code depot + + Parameters: + depot_name (str): the name of the depot + + Returns: + str: the http url of the code depot + """ + if self.code_depot_http_port in ["443", "3443"]: + return f"https://{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git" + else: + return f"http://{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git" + + def get_depot_http_url_with_user_name( + self, code_depot_name: str, user_name: str + ) -> str: + """Return the http url of the code depot + + Parameters: + depot_name (str): the name of the depot + + Returns: + str: the http url of the code depot + """ + if self.code_depot_http_port in ["443", "3443"]: + return f"https://{user_name}@{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git" + else: + return f"http://{user_name}@{self.code_depot_domain_name}:{self.code_depot_http_port}/{self.gitea_org}/{code_depot_name}.git" + + async def get_depot_users(self, code_depot_name: str) -> list[str]: + """Return list of user names have permission to access the depot + + Parameters: + depot_name (str): the name of the depot + + Returns: + list: list of user names + """ + + result = await CodeDepotDoc.find_one(CodeDepotDoc.depot_name == code_depot_name) + + if result: + return result.collaborators + else: + return [] + + async def update_depot_user_password(self, user_name: str, password: str) -> bool: + """Update the password of the user in Gitea + + Parameters: + user_name (str): the name of the user + password (str): the new password of the user + + Returns: + bool: True if operations succeed, otherwise return False + """ + depot_user = self.gitea_admin.get_user_by_name(user_name) + + if depot_user: + try: + self.gitea_admin.update_user_password(user_name, password) + except Exception as e: + await self.module_logger.log_exception( + exception=e, + text=f"Failed to update password for user {user_name} in Gitea.", + properties={"user_name": user_name}, + ) + return False + else: + await self.module_logger.log_error( + error=f"User {user_name} does not exist in Gitea.", + properties={"user_name": user_name}, + ) + return False + + return True + + async def create_depot_user( + self, user_name: str, password: str, email: str + ) -> bool: + """Create a new user in Gitea + Parameters: + user_name (str): the name of the user + password (str): the password of the user + email (str): email address of the user + + Returns: + bool: True if operations succeed, otherwise return False + """ + depot_user = self.gitea_admin.get_user_by_name(user_name) + + if depot_user: + await self.module_logger.log_info( + info=f"User {user_name} exist in Gitea.", + properties={"user_name": user_name}, + ) + return True + else: + try: + await self.module_logger.log_info( + info=f"Create user {user_name} in Gitea with password.", + properties={"user_name": user_name}, + ) + depot_user = self.gitea_admin.create_user( + user_name=user_name, + login_name=user_name, + password=password, + email=email, + change_pw=False, + send_notify=False, + ) + except Exception as e: + await self.module_logger.log_exception( + exception=e, + text=f"Failed to create user {user_name} in Gitea.", + properties={"user_name": user_name}, + ) + return False + + return True + + async def grant_user_depot_access( + self, user_name: str, code_depot_name: str + ) -> bool: + """Grant user access to the code depot in Gitea + + Parameters: + user_name (str): the name of the user + code_depot_name (str): the name of the code depot + + Returns: + bool: True if operations succeed, otherwise return False + """ + try: + code_depot = self.product_org.get_repository(code_depot_name) + except Exception as e: + await self.module_logger.log_exception( + exception=e, + text=f"Failed to get depot {code_depot_name} in Gitea.", + properties={"code_depot_name": code_depot_name}, + ) + return False + + try: + if code_depot.add_collaborator(user_name=user_name, permission="Write"): + code_depot_doc = await CodeDepotDoc.find_one( + CodeDepotDoc.depot_name == code_depot_name + ) + + if code_depot_doc: + if user_name not in code_depot_doc.collaborators: + code_depot_doc.collaborators.append(user_name) + await code_depot_doc.save() + except Exception as e: + await self.module_logger.log_exception( + exception=e, + text=f"Failed to grant permission for user {user_name} to access code depot {code_depot_name}.", + properties={"user_name": user_name, "code_depot_name": code_depot_name}, + ) + return False + + return True + + async def generate_statistic_result( + self, code_depot_name: str + ) -> Optional[dict[str, any]]: + """Call Gitea API and collect statistic result of the repository. + + Args: + code_depot_name (str): the name of the code depot + + Returns: + dict[str, any]: statistic result + """ + + try: + code_depot = self.product_org.get_repository(code_depot_name) + except Exception as e: + await self.module_logger.log_exception( + exception=e, + text=f"Failed to get depot {code_depot_name} in Gitea.", + properties={"code_depot_name": code_depot_name}, + ) + return None + + commits = code_depot.get_commits() + + if len(commits) > 0: + commit_dates = [] + for commit in commits: + commit_dates.append(parse(commit.created).date()) + + today = datetime.now(timezone.utc) + last_sunday = get_sunday(today) + # only get commits statistic of the last 20 weeks + last_20_sundays = [last_sunday - timedelta(weeks=i) for i in range(20)] + + weekly_commits = {} + for date in last_20_sundays: + weekly_commits[date.strftime("%Y-%m-%d")] = 0 + + for commit_date in commit_dates: + commit_date_sunday = get_sunday(commit_date).strftime("%Y-%m-%d") + if commit_date_sunday in weekly_commits: + weekly_commits[commit_date_sunday] += 1 + + last_update = parse(commits[-1].created) + results = { + "total_commits": len(commits), + "last_commiter": commits[-1].commit["committer"]["name"], + "last_update": last_update.astimezone(pytz.UTC), + "weekly_commits": weekly_commits, + } + else: + results = { + "total_commits": 0, + "last_commiter": "", + "last_update": None, + "weekly_commits": {}, + } + + return results + + async def analyze_code_depots(self): + code_depot_docs = await CodeDepotDoc.find( + CodeDepotDoc.depot_status == DepotStatus.CREATED + ).to_list() + + for code_depot_doc in code_depot_docs: + await self.module_logger.log_info( + info="Start to analyze code depot {}".format(code_depot_doc.depot_name), + properties={"code_depot_name": code_depot_doc.depot_name}, + ) + statistic_result = await self.generate_statistic_result( + code_depot_doc.depot_name + ) + + # cannot get the statistic result, probably it cannot find the + # git repository in gitea + if not statistic_result: + continue + + code_depot_doc.total_commits = statistic_result["total_commits"] + code_depot_doc.last_commiter = statistic_result["last_commiter"] + code_depot_doc.last_update = statistic_result["last_update"] + code_depot_doc.weekly_commits = statistic_result["weekly_commits"] + await code_depot_doc.save() + + async def fetch_code_depot(self, code_depot_id: str) -> Optional[dict[str, any]]: + code_depot_doc = await CodeDepotDoc.get(code_depot_id) + if code_depot_doc: + return code_depot_doc.model_dump() + else: + return None diff --git a/app/authentication/backend/infra/code_management/gitea/__init__.py b/app/authentication/backend/infra/code_management/gitea/__init__.py new file mode 100644 index 0000000..24e7e45 --- /dev/null +++ b/app/authentication/backend/infra/code_management/gitea/__init__.py @@ -0,0 +1,32 @@ +from .api_objects import ( + AlreadyExistsException, + Branch, + Comment, + Commit, + Content, + Issue, + MigrationServices, + Milestone, + NotFoundException, + Organization, + Repository, + Team, + User, +) +from .gitea import AlreadyExistsException, Gitea, NotFoundException + +__all__ = [ + "Gitea", + "User", + "Organization", + "Team", + "Repository", + "Branch", + "NotFoundException", + "AlreadyExistsException", + "Issue", + "Milestone", + "Commit", + "Comment", + "Content", +] diff --git a/app/authentication/backend/infra/code_management/gitea/api_object_base.py b/app/authentication/backend/infra/code_management/gitea/api_object_base.py new file mode 100644 index 0000000..5350e28 --- /dev/null +++ b/app/authentication/backend/infra/code_management/gitea/api_object_base.py @@ -0,0 +1,120 @@ +from .exceptions import ( + MissiongEqualyImplementation, + ObjectIsInvalid, + RawRequestEndpointMissing, +) + + +class ReadonlyApiObject: + def __init__(self, gitea): + self.gitea = gitea + self.deleted = False # set if .delete was called, so that an exception is risen + + def __str__(self): + return "GiteaAPIObject (%s):" % (type(self)) + + def __eq__(self, other): + """Compare only fields that are part of the gitea-data identity""" + raise MissiongEqualyImplementation() + + def __hash__(self): + """Hash only fields that are part of the gitea-data identity""" + raise MissiongEqualyImplementation() + + _fields_to_parsers = {} + + @classmethod + def request(cls, gitea): + if hasattr("API_OBJECT", cls): + return cls._request(gitea) + else: + raise RawRequestEndpointMissing() + + @classmethod + def _request(cls, gitea, args): + result = cls._get_gitea_api_object(gitea, args) + api_object = cls.parse_response(gitea, result) + return api_object + + @classmethod + def _get_gitea_api_object(cls, gitea, args): + """Retrieving an object always as GET_API_OBJECT""" + return gitea.requests_get(cls.API_OBJECT.format(**args)) + + @classmethod + def parse_response(cls, gitea, result) -> "ReadonlyApiObject": + # gitea.logger.debug("Found api object of type %s (id: %s)" % (type(cls), id)) + api_object = cls(gitea) + cls._initialize(gitea, api_object, result) + return api_object + + @classmethod + def _initialize(cls, gitea, api_object, result): + for name, value in result.items(): + if name in cls._fields_to_parsers and value is not None: + parse_func = cls._fields_to_parsers[name] + value = parse_func(gitea, value) + cls._add_read_property(name, value, api_object) + # add all patchable fields missing in the request to be writable + for name in cls._fields_to_parsers.keys(): + if not hasattr(api_object, name): + cls._add_read_property(name, None, api_object) + + @classmethod + def _add_read_property(cls, name, value, api_object): + if not hasattr(api_object, name): + setattr(api_object, "_" + name, value) + prop = property((lambda n: lambda self: self._get_var(n))(name)) + setattr(cls, name, prop) + else: + raise AttributeError(f"Attribute {name} already exists on api object.") + + def _get_var(self, name): + if self.deleted: + raise ObjectIsInvalid() + return getattr(self, "_" + name) + + +class ApiObject(ReadonlyApiObject): + _patchable_fields = set() + + def __init__(self, gitea): + super().__init__(gitea) + self._dirty_fields = set() + + def commit(self): + raise NotImplemented() + + _parsers_to_fields = {} + + def get_dirty_fields(self): + dirty_fields_values = {} + for field in self._dirty_fields: + value = getattr(self, field) + if field in self._parsers_to_fields: + dirty_fields_values[field] = self._parsers_to_fields[field](value) + else: + dirty_fields_values[field] = value + return dirty_fields_values + + @classmethod + def _initialize(cls, gitea, api_object, result): + super()._initialize(gitea, api_object, result) + for name in cls._patchable_fields: + cls._add_write_property(name, None, api_object) + + @classmethod + def _add_write_property(cls, name, value, api_object): + if not hasattr(api_object, "_" + name): + setattr(api_object, "_" + name, value) + prop = property( + (lambda n: lambda self: self._get_var(n))(name), + (lambda n: lambda self, v: self.__set_var(n, v))(name), + ) + setattr(cls, name, prop) + + def __set_var(self, name, i): + if self.deleted: + raise ObjectIsInvalid() + self._dirty_fields.add(name) + setattr(self, "_" + name, i) diff --git a/app/authentication/backend/infra/code_management/gitea/api_objects.py b/app/authentication/backend/infra/code_management/gitea/api_objects.py new file mode 100644 index 0000000..f87bbd6 --- /dev/null +++ b/app/authentication/backend/infra/code_management/gitea/api_objects.py @@ -0,0 +1,1004 @@ +import logging +from datetime import datetime +from typing import Dict, List, Optional, Sequence, Set, Tuple, Union + +from .api_object_base import ApiObject, ReadonlyApiObject +from .exceptions import * + + +class Organization(ApiObject): + """see https://try.gitea.io/api/swagger#/organization/orgGetAll""" + + API_OBJECT = """/orgs/{name}""" # + ORG_REPOS_REQUEST = """/orgs/%s/repos""" # + ORG_TEAMS_REQUEST = """/orgs/%s/teams""" # + ORG_TEAMS_CREATE = """/orgs/%s/teams""" # + ORG_GET_MEMBERS = """/orgs/%s/members""" # + ORG_IS_MEMBER = """/orgs/%s/members/%s""" # , + ORG_HEATMAP = """/users/%s/heatmap""" # + + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Organization): + return False + return self.gitea == other.gitea and self.name == other.name + + def __hash__(self): + return hash(self.gitea) ^ hash(self.name) + + @classmethod + def request(cls, gitea: "Gitea", name: str) -> "Organization": + return cls._request(gitea, {"name": name}) + + @classmethod + def parse_response(cls, gitea, result) -> "Organization": + api_object = super().parse_response(gitea, result) + # add "name" field to make this behave similar to users for gitea < 1.18 + # also necessary for repository-owner when org is repo owner + if not hasattr(api_object, "name"): + Organization._add_read_property("name", result["username"], api_object) + return api_object + + _patchable_fields = { + "description", + "full_name", + "location", + "visibility", + "website", + } + + def commit(self): + values = self.get_dirty_fields() + args = {"name": self.name} + self.gitea.requests_patch(Organization.API_OBJECT.format(**args), data=values) + self.dirty_fields = {} + + def create_repo( + self, + repoName: str, + description: str = "", + private: bool = False, + autoInit=True, + gitignores: str = None, + license: str = None, + readme: str = "Default", + issue_labels: str = None, + default_branch="main", + ): + """Create an organization Repository + + Throws: + AlreadyExistsException: If the Repository exists already. + Exception: If something else went wrong. + """ + result = self.gitea.requests_post( + f"/orgs/{self.name}/repos", + data={ + "name": repoName, + "description": description, + "private": private, + "auto_init": autoInit, + "gitignores": gitignores, + "license": license, + "issue_labels": issue_labels, + "readme": readme, + "default_branch": default_branch, + }, + ) + if "id" in result: + self.gitea.logger.info( + "Successfully created Repository %s " % result["name"] + ) + else: + self.gitea.logger.error(result["message"]) + raise Exception("Repository not created... (gitea: %s)" % result["message"]) + return Repository.parse_response(self, result) + + def get_repositories(self) -> List["Repository"]: + results = self.gitea.requests_get_paginated( + Organization.ORG_REPOS_REQUEST % self.username + ) + return [Repository.parse_response(self.gitea, result) for result in results] + + def get_repository(self, name) -> "Repository": + repos = self.get_repositories() + for repo in repos: + if repo.name == name: + return repo + raise NotFoundException("Repository %s not existent in organization." % name) + + def get_teams(self) -> List["Team"]: + results = self.gitea.requests_get( + Organization.ORG_TEAMS_REQUEST % self.username + ) + teams = [Team.parse_response(self.gitea, result) for result in results] + # organisation seems to be missing using this request, so we add org manually + for t in teams: + setattr(t, "_organization", self) + return teams + + def get_team(self, name) -> "Team": + teams = self.get_teams() + for team in teams: + if team.name == name: + return team + raise NotFoundException("Team not existent in organization.") + + def get_members(self) -> List["User"]: + results = self.gitea.requests_get(Organization.ORG_GET_MEMBERS % self.username) + return [User.parse_response(self.gitea, result) for result in results] + + def is_member(self, username) -> bool: + if isinstance(username, User): + username = username.username + try: + # returns 204 if its ok, 404 if its not + self.gitea.requests_get( + Organization.ORG_IS_MEMBER % (self.username, username) + ) + return True + except: + return False + + def remove_member(self, user: "User"): + path = f"/orgs/{self.username}/members/{user.username}" + self.gitea.requests_delete(path) + + def delete(self): + """Delete this Organization. Invalidates this Objects data. Also deletes all Repositories owned by the User""" + for repo in self.get_repositories(): + repo.delete() + self.gitea.requests_delete(Organization.API_OBJECT.format(name=self.username)) + self.deleted = True + + def get_heatmap(self) -> List[Tuple[datetime, int]]: + results = self.gitea.requests_get(User.USER_HEATMAP % self.username) + results = [ + (datetime.fromtimestamp(result["timestamp"]), result["contributions"]) + for result in results + ] + return results + + +class User(ApiObject): + API_OBJECT = """/users/{name}""" # + USER_MAIL = """/user/emails?sudo=%s""" # + USER_PATCH = """/admin/users/%s""" # + ADMIN_DELETE_USER = """/admin/users/%s""" # + ADMIN_EDIT_USER = """/admin/users/{username}""" # + USER_HEATMAP = """/users/%s/heatmap""" # + + def __init__(self, gitea): + super().__init__(gitea) + self._emails = [] + + def __eq__(self, other): + if not isinstance(other, User): + return False + return self.gitea == other.gitea and self.id == other.id + + def __hash__(self): + return hash(self.gitea) ^ hash(self.id) + + @property + def emails(self): + self.__request_emails() + return self._emails + + @classmethod + def request(cls, gitea: "Gitea", name: str) -> "User": + api_object = cls._request(gitea, {"name": name}) + return api_object + + _patchable_fields = { + "active", + "admin", + "allow_create_organization", + "allow_git_hook", + "allow_import_local", + "email", + "full_name", + "location", + "login_name", + "max_repo_creation", + "must_change_password", + "password", + "prohibit_login", + "website", + } + + def commit(self, login_name: str, source_id: int = 0): + """ + Unfortunately it is necessary to require the login name + as well as the login source (that is not supplied when getting a user) for + changing a user. + Usually source_id is 0 and the login_name is equal to the username. + """ + values = self.get_dirty_fields() + values.update( + # api-doc says that the "source_id" is necessary; works without though + {"login_name": login_name, "source_id": source_id} + ) + args = {"username": self.username} + self.gitea.requests_patch(User.ADMIN_EDIT_USER.format(**args), data=values) + self.dirty_fields = {} + + def create_repo( + self, + repoName: str, + description: str = "", + private: bool = False, + autoInit=True, + gitignores: str = None, + license: str = None, + readme: str = "Default", + issue_labels: str = None, + default_branch="main", + ): + """Create a user Repository + + Throws: + AlreadyExistsException: If the Repository exists already. + Exception: If something else went wrong. + """ + result = self.gitea.requests_post( + "/user/repos", + data={ + "name": repoName, + "description": description, + "private": private, + "auto_init": autoInit, + "gitignores": gitignores, + "license": license, + "issue_labels": issue_labels, + "readme": readme, + "default_branch": default_branch, + }, + ) + if "id" in result: + self.gitea.logger.info( + "Successfully created Repository %s " % result["name"] + ) + else: + self.gitea.logger.error(result["message"]) + raise Exception("Repository not created... (gitea: %s)" % result["message"]) + return Repository.parse_response(self, result) + + def get_repositories(self) -> List["Repository"]: + """Get all Repositories owned by this User.""" + url = f"/users/{self.username}/repos" + results = self.gitea.requests_get_paginated(url) + return [Repository.parse_response(self.gitea, result) for result in results] + + def get_orgs(self) -> List[Organization]: + """Get all Organizations this user is a member of.""" + url = f"/users/{self.username}/orgs" + results = self.gitea.requests_get_paginated(url) + return [Organization.parse_response(self.gitea, result) for result in results] + + def get_teams(self) -> List["Team"]: + url = f"/user/teams" + results = self.gitea.requests_get_paginated(url, sudo=self) + return [Team.parse_response(self.gitea, result) for result in results] + + def get_accessible_repos(self) -> List["Repository"]: + """Get all Repositories accessible by the logged in User.""" + results = self.gitea.requests_get("/user/repos", sudo=self) + return [Repository.parse_response(self, result) for result in results] + + def __request_emails(self): + result = self.gitea.requests_get(User.USER_MAIL % self.login) + # report if the adress changed by this + for mail in result: + self._emails.append(mail["email"]) + if mail["primary"]: + self._email = mail["email"] + + def delete(self): + """Deletes this User. Also deletes all Repositories he owns.""" + self.gitea.requests_delete(User.ADMIN_DELETE_USER % self.username) + self.deleted = True + + def get_heatmap(self) -> List[Tuple[datetime, int]]: + results = self.gitea.requests_get(User.USER_HEATMAP % self.username) + results = [ + (datetime.fromtimestamp(result["timestamp"]), result["contributions"]) + for result in results + ] + return results + + +class Branch(ReadonlyApiObject): + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Branch): + return False + return self.commit == other.commit and self.name == other.name + + def __hash__(self): + return hash(self.commit["id"]) ^ hash(self.name) + + _fields_to_parsers = { + # This is not a commit object + # "commit": lambda gitea, c: Commit.parse_response(gitea, c) + } + + @classmethod + def request(cls, gitea: "Gitea", owner: str, repo: str, ref: str): + return cls._request(gitea, {"owner": owner, "repo": repo, "ref": ref}) + + +class Repository(ApiObject): + API_OBJECT = """/repos/{owner}/{name}""" # , + REPO_MIGRATE = """/repos/migrate""" + REPO_COLLABORATOR = """/repos/{owner}/{repo}/collaborators/{username}""" # , , + REPO_SEARCH = """/repos/search/%s""" # + REPO_BRANCHES = """/repos/%s/%s/branches""" # , + REPO_ISSUES = """/repos/{owner}/{repo}/issues""" # + REPO_DELETE = """/repos/%s/%s""" # , + REPO_TIMES = """/repos/%s/%s/times""" # , + REPO_USER_TIME = """/repos/%s/%s/times/%s""" # , , + REPO_COMMITS = "/repos/%s/%s/commits" # , + REPO_TRANSFER = "/repos/{owner}/{repo}/transfer" + REPO_MILESTONES = """/repos/{owner}/{repo}/milestones""" + + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Repository): + return False + return self.owner == other.owner and self.name == other.name + + def __hash__(self): + return hash(self.owner) ^ hash(self.name) + + _fields_to_parsers = { + # dont know how to tell apart user and org as owner except form email being empty. + "owner": lambda gitea, r: Organization.parse_response(gitea, r) + if r["email"] == "" + else User.parse_response(gitea, r), + "updated_at": lambda gitea, t: Util.convert_time(t), + } + + @classmethod + def request(cls, gitea: "Gitea", owner: str, name: str): + return cls._request(gitea, {"owner": owner, "name": name}) + + _patchable_fields = { + "allow_manual_merge", + "allow_merge_commits", + "allow_rebase", + "allow_rebase_explicit", + "allow_rebase_update", + "allow_squash_merge", + "archived", + "autodetect_manual_merge", + "default_branch", + "default_delete_branch_after_merge", + "default_merge_style", + "description", + "enable_prune", + "external_tracker", + "external_wiki", + "has_issues", + "has_projects", + "has_pull_requests", + "has_wiki", + "ignore_whitespace_conflicts", + "internal_tracker", + "mirror_interval", + "name", + "private", + "template", + "website", + } + + def commit(self): + values = self.get_dirty_fields() + args = {"owner": self.owner.username, "name": self.name} + self.gitea.requests_patch(self.API_OBJECT.format(**args), data=values) + self.dirty_fields = {} + + def get_branches(self) -> List["Branch"]: + """Get all the Branches of this Repository.""" + results = self.gitea.requests_get( + Repository.REPO_BRANCHES % (self.owner.username, self.name) + ) + return [Branch.parse_response(self.gitea, result) for result in results] + + def add_branch(self, create_from: Branch, newname: str) -> "Branch": + """Add a branch to the repository""" + # Note: will only work with gitea 1.13 or higher! + data = {"new_branch_name": newname, "old_branch_name": create_from.name} + result = self.gitea.requests_post( + Repository.REPO_BRANCHES % (self.owner.username, self.name), data=data + ) + return Branch.parse_response(self.gitea, result) + + def get_issues(self) -> List["Issue"]: + """Get all Issues of this Repository (open and closed)""" + return self.get_issues_state(Issue.OPENED) + self.get_issues_state(Issue.CLOSED) + + def get_commits(self, page_limit: int = 0) -> List["Commit"]: + """Get all the Commits of this Repository.""" + try: + results = self.gitea.requests_get_paginated( + Repository.REPO_COMMITS % (self.owner.username, self.name), + page_limit=page_limit, + ) + except ConflictException as err: + logging.warning(err) + logging.warning( + "Repository %s/%s is Empty" % (self.owner.username, self.name) + ) + results = [] + return [Commit.parse_response(self.gitea, result) for result in results] + + def get_issues_state(self, state) -> List["Issue"]: + """Get issues of state Issue.open or Issue.closed of a repository.""" + assert state in [Issue.OPENED, Issue.CLOSED] + issues = [] + data = {"state": state} + results = self.gitea.requests_get_paginated( + Repository.REPO_ISSUES.format(owner=self.owner.username, repo=self.name), + params=data, + ) + for result in results: + issue = Issue.parse_response(self.gitea, result) + # adding data not contained in the issue answer + Issue._add_read_property("repo", self, issue) + Issue._add_read_property("owner", self.owner, issue) + issues.append(issue) + return issues + + def get_times(self): + results = self.gitea.requests_get( + Repository.REPO_TIMES % (self.owner.username, self.name) + ) + return results + + def get_user_time(self, username) -> float: + if isinstance(username, User): + username = username.username + results = self.gitea.requests_get( + Repository.REPO_USER_TIME % (self.owner.username, self.name, username) + ) + time = sum(r["time"] for r in results) + return time + + def get_full_name(self) -> str: + return self.owner.username + "/" + self.name + + def create_issue(self, title, assignees=frozenset(), description="") -> ApiObject: + data = { + "assignees": assignees, + "body": description, + "closed": False, + "title": title, + } + result = self.gitea.requests_post( + Repository.REPO_ISSUES.format(owner=self.owner.username, repo=self.name), + data=data, + ) + return Issue.parse_response(self.gitea, result) + + def create_milestone( + self, title: str, description: str, due_date: str = None, state: str = "open" + ) -> "Milestone": + url = Repository.REPO_MILESTONES.format( + owner=self.owner.username, repo=self.name + ) + data = {"title": title, "description": description, "state": state} + if due_date: + data["due_date"] = due_date + result = self.gitea.requests_post(url, data=data) + return Milestone.parse_response(self.gitea, result) + + def create_gitea_hook(self, hook_url: str, events: List[str]): + url = f"/repos/{self.owner.username}/{self.name}/hooks" + data = { + "type": "gitea", + "config": {"content_type": "json", "url": hook_url}, + "events": events, + "active": True, + } + return self.gitea.requests_post(url, data=data) + + def list_hooks(self): + url = f"/repos/{self.owner.username}/{self.name}/hooks" + return self.gitea.requests_get(url) + + def delete_hook(self, id: str): + url = f"/repos/{self.owner.username}/{self.name}/hooks/{id}" + self.gitea.requests_delete(url) + + def is_collaborator(self, user_name: str) -> bool: + try: + # returns 204 if its ok, 404 if its not + self.gitea.requests_get( + Repository.REPO_COLLABORATOR.format( + owner=self.owner.username, repo=self.name, username=user_name + ) + ) + return True + except Exception as err: + logging.warning(err) + return False + + def add_collaborator(self, user_name: str, permission: str = "Write") -> bool: + """Add user into collaborators of the repository + + Args: + username (str): user name to be added + permission (str, optional): Write/Read/Administrator. Defaults to "Write". + + Returns: + bool: True if user is added, otherwise return False. + """ + + data = {"permission": permission} + self.gitea.requests_put( + Repository.REPO_COLLABORATOR.format( + owner=self.owner.username, repo=self.name, username=user_name + ), + data=data, + ) + + # verify after adding the user + return self.is_collaborator(user_name) + + def get_users_with_access(self) -> Sequence[User]: + url = f"/repos/{self.owner.username}/{self.name}/collaborators" + response = self.gitea.requests_get(url) + collabs = [User.parse_response(self.gitea, user) for user in response] + if isinstance(self.owner, User): + return collabs + [self.owner] + else: + # owner must be org + teams = self.owner.get_teams() + for team in teams: + team_repos = team.get_repos() + if self.name in [n.name for n in team_repos]: + collabs += team.get_members() + return collabs + + def remove_collaborator(self, user_name: str): + url = f"/repos/{self.owner.username}/{self.name}/collaborators/{user_name}" + self.gitea.requests_delete(url) + + def transfer_ownership( + self, + new_owner: Union["User", "Organization"], + new_teams: Set["Team"] = frozenset(), + ): + url = Repository.REPO_TRANSFER.format(owner=self.owner.username, repo=self.name) + data = {"new_owner": new_owner.username} + if isinstance(new_owner, Organization): + new_team_ids = [ + team.id for team in new_teams if team in new_owner.get_teams() + ] + data["team_ids"] = new_team_ids + self.gitea.requests_post(url, data=data) + # TODO: make sure this instance is either updated or discarded + + def get_git_content(self, commit: "Commit" = None) -> List["Content"]: + """https://try.gitea.io/api/swagger#/repository/repoGetContentsList""" + url = f"/repos/{self.owner.username}/{self.name}/contents" + data = {"ref": commit.sha} if commit else {} + result = [ + Content.parse_response(self.gitea, f) + for f in self.gitea.requests_get(url, data) + ] + return result + + def get_file_content( + self, content: "Content", commit: "Commit" = None + ) -> Union[str, List["Content"]]: + """https://try.gitea.io/api/swagger#/repository/repoGetContents""" + url = f"/repos/{self.owner.username}/{self.name}/contents/{content.path}" + data = {"ref": commit.sha} if commit else {} + if content.type == Content.FILE: + return self.gitea.requests_get(url, data)["content"] + else: + return [ + Content.parse_response(self.gitea, f) + for f in self.gitea.requests_get(url, data) + ] + + def create_file(self, file_path: str, content: str, data: dict = None): + """https://try.gitea.io/api/swagger#/repository/repoCreateFile""" + if not data: + data = {} + url = f"/repos/{self.owner.username}/{self.name}/contents/{file_path}" + data.update({"content": content}) + return self.gitea.requests_post(url, data) + + def change_file( + self, file_path: str, file_sha: str, content: str, data: dict = None + ): + """https://try.gitea.io/api/swagger#/repository/repoCreateFile""" + if not data: + data = {} + url = f"/repos/{self.owner.username}/{self.name}/contents/{file_path}" + data.update({"sha": file_sha, "content": content}) + return self.gitea.requests_put(url, data) + + def delete(self): + self.gitea.requests_delete( + Repository.REPO_DELETE % (self.owner.username, self.name) + ) + self.deleted = True + + @classmethod + def migrate_repo( + cls, + gitea: "Gitea", + service: str, + clone_addr: str, + repo_name: str, + description: str = "", + private: bool = False, + auth_token: str = None, + auth_username: str = None, + auth_password: str = None, + mirror: bool = False, + mirror_interval: str = None, + lfs: bool = False, + lfs_endpoint: str = "", + wiki: bool = False, + labels: bool = False, + issues: bool = False, + pull_requests: bool = False, + releases: bool = False, + milestones: bool = False, + repo_owner: str = None, + ): + """Migrate a Repository from another service. + + Throws: + AlreadyExistsException: If the Repository exists already. + Exception: If something else went wrong. + """ + result = gitea.requests_post( + cls.REPO_MIGRATE, + data={ + "auth_password": auth_password, + "auth_token": auth_token, + "auth_username": auth_username, + "clone_addr": clone_addr, + "description": description, + "issues": issues, + "labels": labels, + "lfs": lfs, + "lfs_endpoint": lfs_endpoint, + "milestones": milestones, + "mirror": mirror, + "mirror_interval": mirror_interval, + "private": private, + "pull_requests": pull_requests, + "releases": releases, + "repo_name": repo_name, + "repo_owner": repo_owner, + "service": service, + "wiki": wiki, + }, + ) + if "id" in result: + gitea.logger.info( + "Successfully created Job to Migrate Repository %s " % result["name"] + ) + else: + gitea.logger.error(result["message"]) + raise Exception( + "Repository not Migrated... (gitea: %s)" % result["message"] + ) + return Repository.parse_response(gitea, result) + + +class Milestone(ApiObject): + API_OBJECT = """/repos/{owner}/{repo}/milestones/{number}""" # + + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Milestone): + return False + return self.gitea == other.gitea and self.id == other.id + + def __hash__(self): + return hash(self.gitea) ^ hash(self.id) + + _fields_to_parsers = { + "closed_at": lambda gitea, t: Util.convert_time(t), + "due_on": lambda gitea, t: Util.convert_time(t), + } + + _patchable_fields = { + "allow_merge_commits", + "allow_rebase", + "allow_rebase_explicit", + "allow_squash_merge", + "archived", + "default_branch", + "description", + "has_issues", + "has_pull_requests", + "has_wiki", + "ignore_whitespace_conflicts", + "name", + "private", + "website", + } + + @classmethod + def request(cls, gitea: "Gitea", owner: str, repo: str, number: str): + return cls._request(gitea, {"owner": owner, "repo": repo, "number": number}) + + +class Comment(ApiObject): + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Comment): + return False + return self.repo == other.repo and self.id == other.id + + def __hash__(self): + return hash(self.repo) ^ hash(self.id) + + _fields_to_parsers = { + "user": lambda gitea, r: User.parse_response(gitea, r), + "created_at": lambda gitea, t: Util.convert_time(t), + "updated_at": lambda gitea, t: Util.convert_time(t), + } + + +class Commit(ReadonlyApiObject): + def __init__(self, gitea): + super().__init__(gitea) + + _fields_to_parsers = { + # NOTE: api may return None for commiters that are no gitea users + "author": lambda gitea, u: User.parse_response(gitea, u) + if u + else None + } + + def __eq__(self, other): + if not isinstance(other, Commit): + return False + return self.sha == other.sha + + def __hash__(self): + return hash(self.sha) + + @classmethod + def parse_response(cls, gitea, result) -> "Commit": + commit_cache = result["commit"] + api_object = cls(gitea) + cls._initialize(gitea, api_object, result) + # inner_commit for legacy reasons + Commit._add_read_property("inner_commit", commit_cache, api_object) + return api_object + + +class Issue(ApiObject): + API_OBJECT = """/repos/{owner}/{repo}/issues/{index}""" # + GET_TIME = """/repos/%s/%s/issues/%s/times""" # + GET_COMMENTS = """/repos/%s/%s/issues/comments""" + CREATE_ISSUE = """/repos/{owner}/{repo}/issues""" + + OPENED = "open" + CLOSED = "closed" + + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Issue): + return False + return self.repo == other.repo and self.id == other.id + + def __hash__(self): + return hash(self.repo) ^ hash(self.id) + + _fields_to_parsers = { + "milestone": lambda gitea, m: Milestone.parse_response(gitea, m), + "user": lambda gitea, u: User.parse_response(gitea, u), + "assignee": lambda gitea, u: User.parse_response(gitea, u), + "assignees": lambda gitea, us: [User.parse_response(gitea, u) for u in us], + "state": lambda gitea, s: Issue.CLOSED if s == "closed" else Issue.OPENED, + # Repository in this request is just a "RepositoryMeta" record, thus request whole object + "repository": lambda gitea, r: Repository.request(gitea, r["owner"], r["name"]), + } + + _parsers_to_fields = { + "milestone": lambda m: m.id, + } + + _patchable_fields = { + "assignee", + "assignees", + "body", + "due_date", + "milestone", + "state", + "title", + } + + def commit(self): + values = self.get_dirty_fields() + args = { + "owner": self.repository.owner.username, + "repo": self.repository.name, + "index": self.number, + } + self.gitea.requests_patch(Issue.API_OBJECT.format(**args), data=values) + self.dirty_fields = {} + + @classmethod + def request(cls, gitea: "Gitea", owner: str, repo: str, number: str): + api_object = cls._request( + gitea, {"owner": owner, "repo": repo, "index": number} + ) + return api_object + + @classmethod + def create_issue(cls, gitea, repo: Repository, title: str, body: str = ""): + args = {"owner": repo.owner.username, "repo": repo.name} + data = {"title": title, "body": body} + result = gitea.requests_post(Issue.CREATE_ISSUE.format(**args), data=data) + return Issue.parse_response(gitea, result) + + def get_time_sum(self, user: User) -> int: + results = self.gitea.requests_get( + Issue.GET_TIME % (self.owner.username, self.repo.name, self.number) + ) + return sum( + result["time"] + for result in results + if result and result["user_id"] == user.id + ) + + def get_times(self) -> Optional[Dict]: + return self.gitea.requests_get( + Issue.GET_TIME % (self.owner.username, self.repository.name, self.number) + ) + + def delete_time(self, time_id: str): + path = f"/repos/{self.owner.username}/{self.repository.name}/issues/{self.number}/times/{time_id}" + self.gitea.requests_delete(path) + + def add_time(self, time: int, created: str = None, user_name: User = None): + path = f"/repos/{self.owner.username}/{self.repository.name}/issues/{self.number}/times" + self.gitea.requests_post( + path, data={"created": created, "time": int(time), "user_name": user_name} + ) + + def get_comments(self) -> List[ApiObject]: + results = self.gitea.requests_get( + Issue.GET_COMMENTS % (self.owner.username, self.repo.name) + ) + allProjectComments = [ + Comment.parse_response(self.gitea, result) for result in results + ] + # Comparing the issue id with the URL seems to be the only (!) way to get to the comments of one issue + return [ + comment + for comment in allProjectComments + if comment.issue_url.endswith("/" + str(self.number)) + ] + + +class Team(ApiObject): + API_OBJECT = """/teams/{id}""" # + ADD_REPO = """/teams/%s/repos/%s/%s""" # + TEAM_DELETE = """/teams/%s""" # + GET_MEMBERS = """/teams/%s/members""" # + GET_REPOS = """/teams/%s/repos""" # + + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Team): + return False + return self.organization == other.organization and self.id == other.id + + def __hash__(self): + return hash(self.organization) ^ hash(self.id) + + _fields_to_parsers = { + "organization": lambda gitea, o: Organization.parse_response(gitea, o) + } + + _patchable_fields = { + "can_create_org_repo", + "description", + "includes_all_repositories", + "name", + "permission", + "units", + "units_map", + } + + @classmethod + def request(cls, gitea: "Gitea", id: int): + return cls._request(gitea, {"id": id}) + + def commit(self): + values = self.get_dirty_fields() + args = {"id": self.id} + self.gitea.requests_patch(self.API_OBJECT.format(**args), data=values) + self.dirty_fields = {} + + def add_user(self, user: User): + """https://try.gitea.io/api/swagger#/organization/orgAddTeamMember""" + url = f"/teams/{self.id}/members/{user.login}" + self.gitea.requests_put(url) + + def add_repo(self, org: Organization, repo: Repository): + self.gitea.requests_put(Team.ADD_REPO % (self.id, org, repo.name)) + + def get_members(self): + """Get all users assigned to the team.""" + results = self.gitea.requests_get(Team.GET_MEMBERS % self.id) + return [User.parse_response(self.gitea, result) for result in results] + + def get_repos(self): + """Get all repos of this Team.""" + results = self.gitea.requests_get(Team.GET_REPOS % self.id) + return [Repository.parse_response(self.gitea, result) for result in results] + + def delete(self): + self.gitea.requests_delete(Team.TEAM_DELETE % self.id) + self.deleted = True + + def remove_team_member(self, user_name: str): + url = f"/teams/{self.id}/members/{user_name}" + self.gitea.requests_delete(url) + + +class Content(ReadonlyApiObject): + FILE = "file" + + def __init__(self, gitea): + super().__init__(gitea) + + def __eq__(self, other): + if not isinstance(other, Team): + return False + return ( + self.repo == self.repo and self.sha == other.sha and self.name == other.name + ) + + def __hash__(self): + return hash(self.repo) ^ hash(self.sha) ^ hash(self.name) + + +class Util: + @staticmethod + def convert_time(time: str) -> datetime: + """Parsing of strange Gitea time format ("%Y-%m-%dT%H:%M:%S:%z" but with ":" in time zone notation)""" + try: + return datetime.strptime(time[:-3] + "00", "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + return datetime.strptime(time[:-3] + "00", "%Y-%m-%dT%H:%M:%S") + + +class MigrationServices: + GIT = "1" + GITHUB = "2" + GITEA = "3" + GITLAB = "4" + GOGS = "5" + ONEDEV = "6" + GITBUCKET = "7" + CODEBASE = "8" diff --git a/app/authentication/backend/infra/code_management/gitea/exceptions.py b/app/authentication/backend/infra/code_management/gitea/exceptions.py new file mode 100644 index 0000000..dfb90d1 --- /dev/null +++ b/app/authentication/backend/infra/code_management/gitea/exceptions.py @@ -0,0 +1,30 @@ +class AlreadyExistsException(Exception): + pass + + +class NotFoundException(Exception): + pass + + +class ObjectIsInvalid(Exception): + pass + + +class ConflictException(Exception): + pass + + +class RawRequestEndpointMissing(Exception): + """This ApiObject can only be obtained through other api objects and does not have + diret .request method.""" + + pass + + +class MissiongEqualyImplementation(Exception): + """ + Each Object obtained from the gitea api must be able to check itself for equality in relation to its + fields obtained from gitea. Risen if an api object is lacking the proper implementation. + """ + + pass diff --git a/app/authentication/backend/infra/code_management/gitea/gitea.py b/app/authentication/backend/infra/code_management/gitea/gitea.py new file mode 100644 index 0000000..8fe37ad --- /dev/null +++ b/app/authentication/backend/infra/code_management/gitea/gitea.py @@ -0,0 +1,392 @@ +import json +import logging +from typing import Dict, List, Union + +import requests +import urllib3 +from frozendict import frozendict + +from .api_objects import Organization, Repository, Team, User +from .exceptions import AlreadyExistsException, ConflictException, NotFoundException + + +class Gitea: + """Object to establish a session with Gitea.""" + + ADMIN_CREATE_USER = """/admin/users""" + ADMIN_EDIT_USER = """/admin/users/%s""" # + GET_USERS_ADMIN = """/admin/users""" + ADMIN_REPO_CREATE = """/admin/users/%s/repos""" # + GITEA_VERSION = """/version""" + GET_USER = """/user""" + CREATE_ORG = """/admin/users/%s/orgs""" # + CREATE_TEAM = """/orgs/%s/teams""" # + + def __init__( + self, gitea_url: str, token_text=None, auth=None, verify=True, log_level="INFO" + ): + """Initializing Gitea-instance + + Args: + gitea_url (str): The Gitea instance URL. + token_text (str, None): The access token, by default None. + auth (tuple, None): The user credentials + `(username, password)`, by default None. + verify (bool): If True, allow insecure server connections + when using SSL. + log_level (str): The log level, by default `INFO`. + """ + self.logger = logging.getLogger(__name__) + self.logger.setLevel(log_level) + self.headers = { + "Content-type": "application/json", + } + self.url = gitea_url + self.requests = requests.Session() + + # Manage authentification + if not token_text and not auth: + raise ValueError("Please provide auth or token_text, but not both") + if token_text: + self.headers["Authorization"] = "token " + token_text + if auth: + self.requests.auth = auth + + # Manage SSL certification verification + self.requests.verify = verify + if not verify: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + def __get_url(self, endpoint): + url = self.url + "/api/v1" + endpoint + self.logger.debug("Url: %s" % url) + return url + + @staticmethod + def parse_result(result) -> Dict: + """Parses the result-JSON to a dict.""" + if result.text and len(result.text) > 3: + return json.loads(result.text) + return {} + + def requests_get(self, endpoint: str, params=frozendict(), sudo=None): + combined_params = {} + combined_params.update(params) + if sudo: + combined_params["sudo"] = sudo.username + request = self.requests.get( + self.__get_url(endpoint), headers=self.headers, params=combined_params + ) + if request.status_code not in [200, 201, 204]: + message = f"Received status code: {request.status_code} ({request.url})" + if request.status_code in [404]: + raise NotFoundException(message) + if request.status_code in [403]: + raise Exception( + f"Unauthorized: {request.url} - Check your permissions and try again! ({message})" + ) + if request.status_code in [409]: + raise ConflictException(message) + raise Exception(message) + return self.parse_result(request) + + def requests_get_paginated( + self, + endpoint: str, + params=frozendict(), + sudo=None, + page_key: str = "page", + page_limit: int = 0, + ): + page = 1 + combined_params = {} + combined_params.update(params) + aggregated_result = [] + while True: + combined_params[page_key] = page + result = self.requests_get(endpoint, combined_params, sudo) + if not result: + return aggregated_result + aggregated_result.extend(result) + page += 1 + if page_limit and page > page_limit: + return aggregated_result + + def requests_put(self, endpoint: str, data: dict = None): + if not data: + data = {} + request = self.requests.put( + self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) + ) + if request.status_code not in [200, 204]: + message = f"Received status code: {request.status_code} ({request.url}) {request.text}" + self.logger.error(message) + raise Exception(message) + + def requests_delete(self, endpoint: str): + request = self.requests.delete(self.__get_url(endpoint), headers=self.headers) + if request.status_code not in [204]: + message = f"Received status code: {request.status_code} ({request.url})" + self.logger.error(message) + raise Exception(message) + + def requests_post(self, endpoint: str, data: dict): + request = self.requests.post( + self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) + ) + if request.status_code not in [200, 201, 202]: + if ( + "already exists" in request.text + or "e-mail already in use" in request.text + ): + self.logger.warning(request.text) + raise AlreadyExistsException() + self.logger.error( + f"Received status code: {request.status_code} ({request.url})" + ) + self.logger.error(f"With info: {data} ({self.headers})") + self.logger.error(f"Answer: {request.text}") + raise Exception( + f"Received status code: {request.status_code} ({request.url}), {request.text}" + ) + return self.parse_result(request) + + def requests_patch(self, endpoint: str, data: dict): + request = self.requests.patch( + self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) + ) + if request.status_code not in [200, 201]: + error_message = ( + f"Received status code: {request.status_code} ({request.url}) {data}" + ) + self.logger.error(error_message) + raise Exception(error_message) + return self.parse_result(request) + + def get_orgs_public_members_all(self, orgname): + path = "/orgs/" + orgname + "/public_members" + return self.requests_get(path) + + def get_orgs(self): + path = "/admin/orgs" + results = self.requests_get(path) + return [Organization.parse_response(self, result) for result in results] + + def get_org_by_name(self, org_name: str) -> Organization: + path = "/admin/orgs" + results = self.requests_get(path) + + for result in results: + org = Organization.parse_response(self, result) + if org.name == org_name: + return org + + def get_user(self): + result = self.requests_get(Gitea.GET_USER) + return User.parse_response(self, result) + + def get_version(self) -> str: + result = self.requests_get(Gitea.GITEA_VERSION) + return result["version"] + + def get_users(self) -> List[User]: + results = self.requests_get(Gitea.GET_USERS_ADMIN) + return [User.parse_response(self, result) for result in results] + + def get_user_by_email(self, email: str) -> User: + users = self.get_users() + for user in users: + if user.email == email or email in user.emails: + return user + return None + + def get_user_by_name(self, username: str) -> User: + users = self.get_users() + for user in users: + if user.username == username: + return user + return None + + def update_user_password(self, user_name: str, password: str): + request_data = {"password": password, "login_name": user_name} + result = self.requests_patch( + Gitea.ADMIN_EDIT_USER % user_name, data=request_data + ) + self.logger.debug("Gitea response - update_user_password(): %s", result) + + def create_user( + self, + user_name: str, + email: str, + password: str, + full_name: str = None, + login_name: str = None, + change_pw=True, + send_notify=True, + source_id=0, + ): + """Create User. + Throws: + AlreadyExistsException, if the User exists already + Exception, if something else went wrong. + """ + if not login_name: + login_name = user_name + if not full_name: + full_name = user_name + request_data = { + "source_id": source_id, + "login_name": login_name, + "full_name": full_name, + "username": user_name, + "email": email, + "password": password, + "send_notify": send_notify, + "must_change_password": change_pw, + } + + self.logger.debug("Gitea post payload: %s", request_data) + result = self.requests_post(Gitea.ADMIN_CREATE_USER, data=request_data) + if "id" in result: + self.logger.info( + "Successfully created User %s <%s> (id %s)", + result["login"], + result["email"], + result["id"], + ) + self.logger.debug("Gitea response: %s", result) + else: + self.logger.error(result["message"]) + raise Exception("User not created... (gitea: %s)" % result["message"]) + user = User.parse_response(self, result) + return user + + def create_repo( + self, + repoOwner: Union[User, Organization], + repoName: str, + description: str = "", + private: bool = False, + autoInit=True, + gitignores: str = None, + license: str = None, + readme: str = "Default", + issue_labels: str = None, + default_branch="master", + ): + """Create a Repository as the administrator + + Throws: + AlreadyExistsException: If the Repository exists already. + Exception: If something else went wrong. + + Note: + Non-admin users can not use this method. Please use instead + `gitea.User.create_repo` or `gitea.Organization.create_repo`. + """ + # although this only says user in the api, this also works for + # organizations + assert isinstance(repoOwner, User) or isinstance(repoOwner, Organization) + result = self.requests_post( + Gitea.ADMIN_REPO_CREATE % repoOwner.username, + data={ + "name": repoName, + "description": description, + "private": private, + "auto_init": autoInit, + "gitignores": gitignores, + "license": license, + "issue_labels": issue_labels, + "readme": readme, + "default_branch": default_branch, + }, + ) + if "id" in result: + self.logger.info("Successfully created Repository %s " % result["name"]) + else: + self.logger.error(result["message"]) + raise Exception("Repository not created... (gitea: %s)" % result["message"]) + return Repository.parse_response(self, result) + + def create_org( + self, + owner: User, + orgName: str, + description: str, + location="", + website="", + full_name="", + ): + assert isinstance(owner, User) + result = self.requests_post( + Gitea.CREATE_ORG % owner.username, + data={ + "username": orgName, + "description": description, + "location": location, + "website": website, + "full_name": full_name, + }, + ) + if "id" in result: + self.logger.info( + "Successfully created Organization %s" % result["username"] + ) + else: + self.logger.error( + "Organization not created... (gitea: %s)" % result["message"] + ) + self.logger.error(result["message"]) + raise Exception( + "Organization not created... (gitea: %s)" % result["message"] + ) + return Organization.parse_response(self, result) + + def create_team( + self, + org: Organization, + name: str, + description: str = "", + permission: str = "read", + can_create_org_repo: bool = False, + includes_all_repositories: bool = False, + units=( + "repo.code", + "repo.issues", + "repo.ext_issues", + "repo.wiki", + "repo.pulls", + "repo.releases", + "repo.ext_wiki", + ), + ): + """Creates a Team. + + Args: + org (Organization): Organization the Team will be part of. + name (str): The Name of the Team to be created. + description (str): Optional, None, short description of the new Team. + permission (str): Optional, 'read', What permissions the members + """ + result = self.requests_post( + Gitea.CREATE_TEAM % org.username, + data={ + "name": name, + "description": description, + "permission": permission, + "can_create_org_repo": can_create_org_repo, + "includes_all_repositories": includes_all_repositories, + "units": units, + }, + ) + if "id" in result: + self.logger.info("Successfully created Team %s" % result["name"]) + else: + self.logger.error("Team not created... (gitea: %s)" % result["message"]) + self.logger.error(result["message"]) + raise Exception("Team not created... (gitea: %s)" % result["message"]) + api_object = Team.parse_response(self, result) + setattr( + api_object, "_organization", org + ) # fixes strange behaviour of gitea not returning a valid organization here. + return api_object diff --git a/app/authentication/backend/infra/code_management/readme.md b/app/authentication/backend/infra/code_management/readme.md new file mode 100644 index 0000000..44460d3 --- /dev/null +++ b/app/authentication/backend/infra/code_management/readme.md @@ -0,0 +1,7 @@ +This folder will be extracted to a separate service - DevOps Service +Scope: +- Gitea’s code management. Continuous Deployment. +See doc: +- Freeleaps All-In-One development solution spec & high-level design.docx . + +Code temporarily put here before it's up. \ No newline at end of file diff --git a/app/authentication/backend/infra/user_profile/user_profile_handler.py b/app/authentication/backend/infra/user_profile/user_profile_handler.py new file mode 100644 index 0000000..6e588b1 --- /dev/null +++ b/app/authentication/backend/infra/user_profile/user_profile_handler.py @@ -0,0 +1,2 @@ +class UserProfileHandler: + pass diff --git a/app/authentication/backend/models/constants.py b/app/authentication/backend/models/constants.py new file mode 100644 index 0000000..f930cbf --- /dev/null +++ b/app/authentication/backend/models/constants.py @@ -0,0 +1,43 @@ +from enum import IntEnum + + +class NewUserMethod(IntEnum): + EMAIL = 1 + MOBILE = 2 + + +class UserAccountProperty(IntEnum): + EMAIL_VERIFIED = 1 + MOBILE_VERIFIED = 2 + PAYMENT_SETUP = 4 + ACCEPT_REQUEST = 8 + READY_PROVIDER = 16 + MANAGE_PROJECT = 32 + + +class UserLoginAction(IntEnum): + VERIFY_EMAIL_WITH_AUTH_CODE = 0 + EXISTING_USER_PASSWORD_REQUIRED = 1 + NEW_USER_SET_PASSWORD = 2 + EMAIL_NOT_ASSOCIATED_WITH_USER = 3 + REVIEW_AND_REVISE_FLID = 4 + USER_SIGNED_IN = 100 + + +class AuthType(IntEnum): + MOBILE = 0 + EMAIL = 1 + PASSWORD = 2 + + +class DepotStatus(IntEnum): + TO_BE_CREATED = 0 + CREATED = 1 + DELETED = 2 + + +class UserAccountStatus(IntEnum): + TO_BE_CREATED = 0 + CREATED = 1 + DELETED = 2 + DEACTIVATED = 3 diff --git a/app/authentication/backend/models/gitea/constants.py b/app/authentication/backend/models/gitea/constants.py new file mode 100644 index 0000000..efa3949 --- /dev/null +++ b/app/authentication/backend/models/gitea/constants.py @@ -0,0 +1,14 @@ +from enum import IntEnum + + +class DepotStatus(IntEnum): + TO_BE_CREATED = 0 + CREATED = 1 + DELETED = 2 + + +class UserAccountStatus(IntEnum): + TO_BE_CREATED = 0 + CREATED = 1 + DELETED = 2 + DEACTIVATED = 3 diff --git a/app/authentication/backend/models/gitea/models.py b/app/authentication/backend/models/gitea/models.py new file mode 100644 index 0000000..b55df68 --- /dev/null +++ b/app/authentication/backend/models/gitea/models.py @@ -0,0 +1,21 @@ +from typing import Dict, Optional +from datetime import datetime, timezone +from beanie import Document +from app.authentication.backend.models.gitea.constants import ( + DepotStatus, + UserAccountStatus, +) + + +class CodeDepotDoc(Document): + depot_name: str + product_id: str + depot_status: DepotStatus + collaborators: list[str] = [] + total_commits: Optional[int] = 0 + last_commiter: Optional[str] = "" + last_update: Optional[datetime] = datetime.now(timezone.utc) + weekly_commits: Optional[Dict[str, int]] = {} + + class Settings: + name = "code_depot" diff --git a/app/authentication/backend/models/gitea/readme.md b/app/authentication/backend/models/gitea/readme.md new file mode 100644 index 0000000..4d2816a --- /dev/null +++ b/app/authentication/backend/models/gitea/readme.md @@ -0,0 +1 @@ +Models here will be moved out when building the DevOps Service \ No newline at end of file diff --git a/app/authentication/backend/models/models.py b/app/authentication/backend/models/models.py new file mode 100644 index 0000000..4541868 --- /dev/null +++ b/app/authentication/backend/models/models.py @@ -0,0 +1,37 @@ +from datetime import datetime +from beanie import Document +from .constants import AuthType + + +class UserPasswordDoc(Document): + user_id: str + password: str + + class Settings: + name = "user_password" + + +class UserEmailDoc(Document): + user_id: str + email: str + + class Settings: + name = "user_email" + + +class UserMobileDoc(Document): + user_id: str + mobile: str + + class Settings: + name = "user_mobile" + + +class AuthCodeDoc(Document): + auth_code: str + method: str + method_type: AuthType + expiry: datetime + + class Settings: + name = "user_auth_code" diff --git a/app/authentication/backend/models/permission/constants.py b/app/authentication/backend/models/permission/constants.py new file mode 100644 index 0000000..89c6104 --- /dev/null +++ b/app/authentication/backend/models/permission/constants.py @@ -0,0 +1,26 @@ +from enum import IntEnum + + +class AdministrativeRole(IntEnum): + NONE = 0 + PERSONAL = 1 + BUSINESS = 2 + CONTRIBUTOR = 4 + ADMINISTRATOR = 8 + # now UI cannot siginin if user role is 8 + + +class Capability(IntEnum): + VISITOR = 1 + COMMUNICATOR = 2 + REQUESTER = 4 + PROVIDER = 8 + DEVELOPER = 16 + + +class Feature(IntEnum): + ANY = 0xFFFFFFFF + SENDMESSAGE = 0x1 + INITIATEREQUEST = 0x2 + MAKEPROPOSAL = 0x4 + CREATEPROJECT = 0x8 diff --git a/app/authentication/backend/models/user/constants.py b/app/authentication/backend/models/user/constants.py new file mode 100644 index 0000000..9a7f9dd --- /dev/null +++ b/app/authentication/backend/models/user/constants.py @@ -0,0 +1,24 @@ +from enum import IntEnum + + +class NewUserMethod(IntEnum): + EMAIL = 1 + MOBILE = 2 + + +class UserAccountProperty(IntEnum): + EMAIL_VERIFIED = 1 + MOBILE_VERIFIED = 2 + PAYMENT_SETUP = 4 + ACCEPT_REQUEST = 8 + READY_PROVIDER = 16 + MANAGE_PROJECT = 32 + + +class UserLoginAction(IntEnum): + VERIFY_EMAIL_WITH_AUTH_CODE = 0 + EXISTING_USER_PASSWORD_REQUIRED = 1 + NEW_USER_SET_PASSWORD = 2 + EMAIL_NOT_ASSOCIATED_WITH_USER = 3 + REVIEW_AND_REVISE_FLID = 4 + USER_SIGNED_IN = 100 diff --git a/app/authentication/backend/models/user/models.py b/app/authentication/backend/models/user/models.py new file mode 100644 index 0000000..e5b7657 --- /dev/null +++ b/app/authentication/backend/models/user/models.py @@ -0,0 +1,20 @@ +from typing import Optional + +from beanie import Document + +from .constants import UserAccountProperty +from .permission.constants import AdministrativeRole, Capability +from infra.models.constants import UserRegion + + +class UserAccountDoc(Document): + profile_id: Optional[str] + account_id: Optional[str] + service_plan_id: Optional[str] + properties: UserAccountProperty + capabilities: Capability + user_role: int = AdministrativeRole.NONE + preferred_region: UserRegion = UserRegion.ZH_CN + + class Settings: + name = "user_account" diff --git a/app/authentication/backend/services/auth/user_auth_service.py b/app/authentication/backend/services/auth/user_auth_service.py new file mode 100644 index 0000000..37067f0 --- /dev/null +++ b/app/authentication/backend/services/auth/user_auth_service.py @@ -0,0 +1,30 @@ +from app.authentication.backend.infra.user_management.user_auth_handler import ( + UserAuthManager, +) +from typing import Optional + + +class UserAuthService: + def __init__(self): + self.user_auth_manager = UserAuthManager() + + async def get_user_id_by_email(self, email: str) -> Optional[str]: + return await self.user_auth_manager.get_user_id_by_email(email) + + async def verify_email_code(self, email: str, code: str) -> bool: + return await self.user_auth_manager.verify_email_code(email, code) + + async def create_new_user_account(self, method: str, region: str) -> str: + return await self.user_auth_manager.create_user_account(method, region) + + async def initialize_new_user_data( + self, user_id: str, email: str, region: str, time_zone: str + ): + # Initialize user data + await self.user_auth_manager.init_user_data(user_id, email, region, time_zone) + + async def get_user_account(self, user_id: str): + return await self.user_auth_manager.get_user_account(user_id) + + async def is_password_reset_required(self, user_id: str) -> bool: + return await self.user_auth_manager.is_password_reset_required(user_id) diff --git a/app/authentication/backend/services/user/user_management_service.py b/app/authentication/backend/services/user/user_management_service.py new file mode 100644 index 0000000..1117df3 --- /dev/null +++ b/app/authentication/backend/services/user/user_management_service.py @@ -0,0 +1,80 @@ +import random +from typing import Optional, Dict, Tuple, List +from infra.log.module_logger import ModuleLogger + +from app.authentication.backend.application.models.user.constants import ( + NewUserMethod, + UserAccountProperty, + UserLoginAction, +) +from app.authentication.backend.application.user.models import UserAccountDoc +from app.authentication.backend.business.permission.constants import ( + AdministrativeRole, + Capability, +) +from app.authentication.backend.infra.auth.user_auth_handler import ( + UserAuthHandler, +) +from app.authentication.backend.infra.user.user_profile_handler import ( + UserProfileHandler, +) +from backend.infra.config.backend import settings +from backend.infra.log.log_utils import log_entry_exit_async +from backend.business.credit.user import UserAchievement +from backend.services.profile.basic import BasicProfileStore +from backend.services.profile.provider import ProviderProfileStore +from backend.services.common.constants import UserRegion +from backend.services.common.region import RegionHandler +from backend.infra.depot.depot_manager import CodeDepotManager +from backend.infra.utils.string import check_password_complexity +from backend.business.notification.notification_center import NotificationCenter +from backend.business.events.user_event_dispatcher import UserEventDispatcher +from backend.infra.exception.exceptions import InvalidDataError + + +class UserManagementService: + def __init__(self) -> None: + self.user_auth_handler = UserAuthHandler() + self.user_profile_handler = UserProfileHandler() + self.module_logger = ModuleLogger(sender_id=UserManagementService) + + @log_entry_exit_async + async def create_new_user_account( + self, method: NewUserMethod, region: UserRegion + ) -> str: + """create a new user account document in DB + + Args: + method (NewUserMethod): the method the new user came from + region : preferred user region detected via the user log-in website + + Returns: + str: id of user account + """ + if NewUserMethod.EMAIL == method: + user_account = UserAccountDoc( + profile_id=None, + account_id=None, + service_plan_id=None, + properties=int(UserAccountProperty.EMAIL_VERIFIED), + capabilities=int(Capability.VISITOR), + user_role=int(AdministrativeRole.PERSONAL), + region=region, + ) + user_account = await user_account.create() + + elif NewUserMethod.MOBILE == method: + user_account = UserAccountDoc( + profile_id=None, + account_id=None, + service_plan_id=None, + properties=int(UserAccountProperty.MOBILE_VERIFIED), + capabilities=int(Capability.VISITOR), + user_role=int(AdministrativeRole.PERSONAL), + region=region, + ) + user_account = await user_account.create() + + # Create other doc in collections for the new user + await UserAchievement(str(user_account.id)).create_activeness_achievement() + return str(user_account.id) diff --git a/app/authentication/common/config/app_settings.py b/app/authentication/common/config/app_settings.py new file mode 100644 index 0000000..353a887 --- /dev/null +++ b/app/authentication/common/config/app_settings.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings + + +class AppSettings(BaseSettings): + NAME: str = "central_storage" + + GITEA_URL: str = "" + GITEA_TOKEN: str = "" + GITEA_DEPOT_ORGANIZATION: str = "" + + CODE_DEPOT_DOMAIN_NAME: str = "" + CODE_DEPOT_SSH_PORT: str = "" + CODE_DEPOT_HTTP_PORT: str = "" + + class Config: + env_file = ".myapp.env" + env_file_encoding = "utf-8" + + +app_settings = AppSettings() diff --git a/app/authentication/webapi/bootstrap/application.py b/app/authentication/webapi/bootstrap/application.py new file mode 100644 index 0000000..7a38aa7 --- /dev/null +++ b/app/authentication/webapi/bootstrap/application.py @@ -0,0 +1,75 @@ +import logging +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from webapi.providers import common +from webapi.providers import logger +from webapi.providers import router +from webapi.providers import database +from webapi.providers import scheduler +from webapi.providers import exception_handler +from .freeleaps_app import FreeleapsApp + + +def create_app() -> FastAPI: + logging.info("App initializing") + + app = FreeleapsApp() + + register(app, exception_handler) + register(app, database) + register(app, logger) + register(app, router) + register(app, scheduler) + register(app, common) + + # Call the custom_openapi function to change the OpenAPI version + customize_openapi_security(app) + return app + + +# This function overrides the OpenAPI schema version to 3.0.0 +def customize_openapi_security(app: FastAPI) -> None: + + def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + # Generate OpenAPI schema + openapi_schema = get_openapi( + title="FreeLeaps API", + version="3.1.0", + description="FreeLeaps API Documentation", + routes=app.routes, + ) + + # Ensure the components section exists in the OpenAPI schema + if "components" not in openapi_schema: + openapi_schema["components"] = {} + + # Add security scheme to components + openapi_schema["components"]["securitySchemes"] = { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + + # Add security requirement globally + openapi_schema["security"] = [{"bearerAuth": []}] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + app.openapi = custom_openapi + + +def register(app, provider): + logging.info(provider.__name__ + " registering") + provider.register(app) + + +def boot(app, provider): + logging.info(provider.__name__ + " booting") + provider.boot(app) diff --git a/app/authentication/webapi/bootstrap/freeleaps_app.py b/app/authentication/webapi/bootstrap/freeleaps_app.py new file mode 100644 index 0000000..496633a --- /dev/null +++ b/app/authentication/webapi/bootstrap/freeleaps_app.py @@ -0,0 +1,6 @@ +from fastapi import FastAPI + + +class FreeleapsApp(FastAPI): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/app/authentication/webapi/config/site_settings.py b/app/authentication/webapi/config/site_settings.py new file mode 100644 index 0000000..43d05d6 --- /dev/null +++ b/app/authentication/webapi/config/site_settings.py @@ -0,0 +1,25 @@ +import os + +from pydantic_settings import BaseSettings + + +class SiteSettings(BaseSettings): + NAME: str = "appname" + DEBUG: bool = True + + ENV: str = "dev" + + SERVER_HOST: str = "0.0.0.0" + SERVER_PORT: int = 8103 + + URL: str = "http://localhost" + TIME_ZONE: str = "UTC" + + BASE_PATH: str = os.path.dirname(os.path.dirname((os.path.abspath(__file__)))) + + class Config: + env_file = ".devbase-webapi.env" + env_file_encoding = "utf-8" + + +site_settings = SiteSettings() diff --git a/app/authentication/webapi/main.py b/app/authentication/webapi/main.py new file mode 100755 index 0000000..df0c604 --- /dev/null +++ b/app/authentication/webapi/main.py @@ -0,0 +1,30 @@ +from webapi.bootstrap.application import create_app +from webapi.config.site_settings import site_settings +from fastapi.responses import RedirectResponse +from fastapi.middleware.cors import CORSMiddleware +from strawberry.fastapi import GraphQLRouter +from strawberry.fastapi.handlers import GraphQLTransportWSHandler, GraphQLWSHandler +import uvicorn +from typing import Any + + +app = create_app() + +@app.get("/", status_code=301) +async def root(): + """ + TODO: redirect client to /doc# + """ + return RedirectResponse("docs") + + +if __name__ == "__main__": + uvicorn.run(app="main:app", host=site_settings.SERVER_HOST, port=site_settings.SERVER_PORT) + +def get_context() -> Any: + # Define your context function. This is where you can set up authentication, database connections, etc. + return {} + +def get_root_value() -> Any: + # Define your root value function. This can be used to customize the root value for GraphQL operations. + return {} \ No newline at end of file diff --git a/app/authentication/webapi/providers/common.py b/app/authentication/webapi/providers/common.py new file mode 100644 index 0000000..1dd849f --- /dev/null +++ b/app/authentication/webapi/providers/common.py @@ -0,0 +1,31 @@ +from fastapi.middleware.cors import CORSMiddleware +from webapi.config.site_settings import site_settings + + +def register(app): + app.debug = site_settings.DEBUG + app.title = site_settings.NAME + + add_global_middleware(app) + + # This hook ensures that a connection is opened to handle any queries + # generated by the request. + @app.on_event("startup") + def startup(): + pass + + # This hook ensures that the connection is closed when we've finished + # processing the request. + @app.on_event("shutdown") + def shutdown(): + pass + + +def add_global_middleware(app): + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/app/authentication/webapi/providers/database.py b/app/authentication/webapi/providers/database.py new file mode 100644 index 0000000..448745d --- /dev/null +++ b/app/authentication/webapi/providers/database.py @@ -0,0 +1,25 @@ +from infra.config.app_settings import app_settings +from beanie import init_beanie +from motor.motor_asyncio import AsyncIOMotorClient +from app.central_storage.backend.models.models import DocumentDoc + + +def register(app): + app.debug = "auth_mongo_debug" + app.title = "auth_mongo_name" + + @app.on_event("startup") + async def start_database(): + await initiate_database() + + +async def initiate_database(): + client = AsyncIOMotorClient( + app_settings.MONGODB_URI, + serverSelectionTimeoutMS=60000, + minPoolSize=5, # Minimum number of connections in the pool + maxPoolSize=20, # Maximum number of connections in the pool + ) + await init_beanie( + database=client[app_settings.MONGODB_NAME], document_models=[DocumentDoc] + ) diff --git a/app/authentication/webapi/providers/exception_handler.py b/app/authentication/webapi/providers/exception_handler.py new file mode 100644 index 0000000..21117a5 --- /dev/null +++ b/app/authentication/webapi/providers/exception_handler.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_422_UNPROCESSABLE_ENTITY, + HTTP_500_INTERNAL_SERVER_ERROR, +) + + +async def custom_http_exception_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + ) + + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=HTTP_400_BAD_REQUEST, + content={"error": str(exc)}, + ) + +async def exception_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + content={"error": str(exc)}, + ) + + +def register(app: FastAPI): + app.add_exception_handler(HTTPException, custom_http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(Exception, exception_handler) diff --git a/app/authentication/webapi/providers/logger.py b/app/authentication/webapi/providers/logger.py new file mode 100644 index 0000000..4a3f1e7 --- /dev/null +++ b/app/authentication/webapi/providers/logger.py @@ -0,0 +1,60 @@ +import logging +import sys +from loguru import logger +from common.config.log_settings import log_settings + + +def register(app=None): + level = log_settings.LOG_LEVEL + file_path = log_settings.LOG_PATH + retention = log_settings.LOG_RETENTION + rotation = log_settings.LOG_ROTATION + + # intercept everything at the root logger + logging.root.handlers = [InterceptHandler()] + logging.root.setLevel(level) + + # remove every other logger's handlers + # and propagate to root logger + for name in logging.root.manager.loggerDict.keys(): + logging.getLogger(name).handlers = [] + logging.getLogger(name).propagate = True + + # configure loguru + logger.add( + sink=sys.stdout + ) + logger.add( + sink=file_path, + level=level, + retention=retention, + rotation=rotation + ) + + logger.disable("pika.adapters") + logger.disable("pika.connection") + logger.disable("pika.channel") + logger.disable("pika.callback") + logger.disable("pika.frame") + logger.disable("pika.spec") + logger.disable("aiormq.connection") + logger.disable("urllib3.connectionpool") + + +class InterceptHandler(logging.Handler): + def emit(self, record): + # Get corresponding Loguru level if it exists + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # Find caller from where originated the logged message + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) diff --git a/app/authentication/webapi/providers/router.py b/app/authentication/webapi/providers/router.py new file mode 100644 index 0000000..3ad11ae --- /dev/null +++ b/app/authentication/webapi/providers/router.py @@ -0,0 +1,34 @@ +from webapi.routes import api_router + +from starlette import routing + + +def register(app): + app.include_router( + api_router, + prefix="/api", + tags=["api"], + dependencies=[], + responses={404: {"description": "no page found"}}, + ) + + if app.debug: + for route in app.routes: + if not isinstance(route, routing.WebSocketRoute): + print( + { + "path": route.path, + "endpoint": route.endpoint, + "name": route.name, + "methods": route.methods, + } + ) + else: + print( + { + "path": route.path, + "endpoint": route.endpoint, + "name": route.name, + "type": "web socket route", + } + ) diff --git a/app/authentication/webapi/providers/scheduler.py b/app/authentication/webapi/providers/scheduler.py new file mode 100644 index 0000000..7ea8d6c --- /dev/null +++ b/app/authentication/webapi/providers/scheduler.py @@ -0,0 +1,8 @@ +import asyncio + + +def register(app): + @app.on_event("startup") + async def start_scheduler(): + #create your scheduler here + pass diff --git a/app/authentication/webapi/routes/__init__.py b/app/authentication/webapi/routes/__init__.py new file mode 100644 index 0000000..3237813 --- /dev/null +++ b/app/authentication/webapi/routes/__init__.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter + +api_router = APIRouter() + +websocket_router = APIRouter() diff --git a/app/authentication/webapi/routes/api.py b/app/authentication/webapi/routes/api.py new file mode 100644 index 0000000..6545552 --- /dev/null +++ b/app/authentication/webapi/routes/api.py @@ -0,0 +1,15 @@ +from fastapi.routing import APIRoute +from starlette import routing + + +def post_process_router(app) -> None: + """ + Simplify operation IDs so that generated API clients have simpler function + names. + + Should be called only after all routes have been added. + """ + for route in app.routes: + if isinstance(route, APIRoute): + if hasattr(route, "operation_id"): + route.operation_id = route.name # in this case, 'read_items' diff --git a/app/authentication/webapi/routes/signin/__init__.py b/app/authentication/webapi/routes/signin/__init__.py new file mode 100644 index 0000000..1e4e78e --- /dev/null +++ b/app/authentication/webapi/routes/signin/__init__.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter +from .try_signin_with_email import router as ts_router +from .signin_with_email_and_code import router as sw_router +from .update_user_password import router as up_router +from .signin_with_email_and_password import router as se_router +from .sign_out import router as so_router +from .reset_password_through_email import router as rp_router +from .refresh_token import router as rt_router +from .update_user_flid import router as uu_router + +router = APIRouter(prefix="/signin") + +router.include_router(ts_router, tags=["signin"]) +router.include_router(sw_router, tags=["signin"]) +router.include_router(up_router, tags=["signin"]) +router.include_router(se_router, tags=["signin"]) +router.include_router(so_router, tags=["signin"]) +router.include_router(rp_router, tags=["signin"]) +router.include_router(uu_router, tags=["signin"]) +router.include_router(rt_router, tags=["signin"]) diff --git a/app/authentication/webapi/routes/signin/reset_password_through_email.py b/app/authentication/webapi/routes/signin/reset_password_through_email.py new file mode 100644 index 0000000..d464fbe --- /dev/null +++ b/app/authentication/webapi/routes/signin/reset_password_through_email.py @@ -0,0 +1,37 @@ +from backend.application.user.user_manager import UserManager +from pydantic import BaseModel +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +router = APIRouter() + +# Web API +# reset_password_through_email +# + + +class UserSignWithEmailBody(BaseModel): + email: str + host: str + + +class UserSignWithEmailResponse(BaseModel): + signin_type: int + + +@router.post( + "/reset-password-through-email", + operation_id="user-reset-password-through-email", + summary="user reset password through email", + description="A client user forgets the password. \ + The system will send auth code the their email\ + to let the user reset the password", + response_description="action: UserLoginAction", +) +async def reset_password_through_email( + item: UserSignWithEmailBody, +): + result = await UserManager().reset_password_through_email(item.email, item.host) + result = {"action": result} + return JSONResponse(content=jsonable_encoder(result)) diff --git a/app/authentication/webapi/routes/signin/signin_with_email_and_code.py b/app/authentication/webapi/routes/signin/signin_with_email_and_code.py new file mode 100644 index 0000000..1cf5c8e --- /dev/null +++ b/app/authentication/webapi/routes/signin/signin_with_email_and_code.py @@ -0,0 +1,87 @@ +import logging +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from backend.application.user.user_manager import UserManager +from backend.infra.authentication.auth import access_security + +router = APIRouter() + +# Web API +# signin-with-email-n-code +# + + +class RequestIn(BaseModel): + email: str + code: str + host: str + time_zone: Optional[str] = "UTC" + + +class ResponseOut(BaseModel): + # 1: succeeded + signin_result: int + # the access token for futhur communication with server + access_token: Optional[str] = None + # the refresh token for new access token generation + refresh_token: Optional[str] = None + # the identity of the signed in user + identity: Optional[str] = None + # the date time when the access toke will be expired + expires_in: Optional[datetime] = None + # the system assigned role of the user. + role: Optional[int] = None + # preferred region for user + preferred_region: Optional[str] = None + + +@router.post( + "/signin-with-email-and-code", + operation_id="user-signin-with-email-and-code", + summary="try to signin with email and authentication code", + description="client user is trying to sign in with their email and the authenication code \ + the system sent to the email in previous step.", + response_model=ResponseOut, +) +async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: + ( + signed_in, + adminstrative_role, + identity, + flid, + preferred_region, + ) = await UserManager().signin_with_email_and_code( + item.email, item.code, item.host, item.time_zone + ) + + logging.debug( + f"signedin={signed_in}, adminstrative_role={adminstrative_role}, identity={identity}" + ) + + if signed_in and identity and adminstrative_role: + subject = {"id": identity, "role": adminstrative_role} + access_token = access_security.create_access_token(subject=subject) + refresh_token = access_security.create_refresh_token(subject=subject) + expires_in = datetime.now(timezone.utc) + access_security.access_expires_delta + else: + access_token = None + refresh_token = None + expires_in = None + + result = { + "signin_result": signed_in, + "access_token": access_token, + "refresh_token": refresh_token, + "identity": identity, + "expires_in": expires_in, + "role": adminstrative_role, + "flid": flid, + "preferred_region": preferred_region, + } + return JSONResponse(content=jsonable_encoder(result)) diff --git a/app/authentication/webapi/routes/signin/signin_with_email_and_password.py b/app/authentication/webapi/routes/signin/signin_with_email_and_password.py new file mode 100644 index 0000000..7df183f --- /dev/null +++ b/app/authentication/webapi/routes/signin/signin_with_email_and_password.py @@ -0,0 +1,87 @@ +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from backend.application.user.user_manager import UserManager +from infra.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +# Web API +# signin-with-email-n-code +# + + +class RequestIn(BaseModel): + email: str + code: str + host: str + time_zone: Optional[str] = "UTC" + + +class ResponseOut(BaseModel): + # 1: succeeded + signin_result: int + # the access token for futhur communication with server + access_token: Optional[str] = None + # the refresh token for new access token generation + refresh_token: Optional[str] = None + # the identity of the signed in user + identity: Optional[str] = None + # the date time when the access toke will be expired + expires_in: Optional[datetime] = None + # the system assigned role of the user. + role: Optional[int] = None + # preferred region for user + preferred_region: Optional[str] = None + + +@router.post( + "/signin-with-email-and-code", + operation_id="user-signin-with-email-and-code", + summary="try to signin with email and authentication code", + description="client user is trying to sign in with their email and the authenication code \ + the system sent to the email in previous step.", + response_model=ResponseOut, +) +async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: + ( + signed_in, + adminstrative_role, + identity, + flid, + preferred_region, + ) = await UserManager().signin_with_email_and_code( + item.email, item.code, item.host, item.time_zone + ) + + logging.debug( + f"signedin={signed_in}, adminstrative_role={adminstrative_role}, identity={identity}" + ) + + if signed_in and identity and adminstrative_role: + subject = {"id": identity, "role": adminstrative_role} + access_token = token_manager.create_access_token(subject=subject) + refresh_token = token_manager.create_refresh_token(subject=subject) + expires_in = datetime.now(timezone.utc) + timedelta(minutes=30) + else: + access_token = None + refresh_token = None + expires_in = None + + result = { + "signin_result": signed_in, + "access_token": access_token, + "refresh_token": refresh_token, + "identity": identity, + "expires_in": expires_in, + "role": adminstrative_role, + "flid": flid, + "preferred_region": preferred_region, + } + return JSONResponse(content=jsonable_encoder(result)) diff --git a/app/authentication/webapi/routes/signin/try_signin_with_email.py b/app/authentication/webapi/routes/signin/try_signin_with_email.py new file mode 100644 index 0000000..e1e3a29 --- /dev/null +++ b/app/authentication/webapi/routes/signin/try_signin_with_email.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Security +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi_jwt import JwtAuthorizationCredentials +from backend.infra.authentication.auth import access_security +from backend.application.user.user_manager import ( + UserManager, +) # Assuming UserManager handles user-related queries + + +router = APIRouter() + + +@router.get( + "/get-latest-login-by-user-id/{user_id}", + operation_id="get-latest-login-by-user-id", + summary="Get the latest login timestamp for a given user", + description="Fetches the latest login timestamp for a specific user by user_id.", + response_description="Returns the latest login timestamp in Unix time seconds, or null if no login found", +) +async def get_latest_login_by_user_id( + user_id: str, + credentials: JwtAuthorizationCredentials = Security(access_security), +): + # Assume UserManager is responsible for handling user data + result = await UserManager().fetch_latest_login(user_id) + + if result is None: + return JSONResponse(content=jsonable_encoder({"timestamp": None})) + + return JSONResponse(content=jsonable_encoder({"timestamp": result})) diff --git a/app/authentication/webapi/routes/signin/update_user_password.py b/app/authentication/webapi/routes/signin/update_user_password.py new file mode 100644 index 0000000..234f6d4 --- /dev/null +++ b/app/authentication/webapi/routes/signin/update_user_password.py @@ -0,0 +1,42 @@ +from backend.application.user.user_manager import UserManager +from pydantic import BaseModel +from fastapi import APIRouter, Security +from backend.infra.authentication.auth import access_security +from fastapi_jwt import JwtAuthorizationCredentials +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +router = APIRouter() + +# Web API +# update_user_password +# + + +class RequestIn(BaseModel): + password: str + password2: str + + +@router.post( + "/update-user-password", + operation_id="user_update_user_password", + summary="updathe user's sign-in password", + description="Update the user's sign-in password. If the password was not set yet, this will enable the user to log in using the password", + response_description="signin_type:0 meaning simplified(using email) signin, \ + 1 meaning standard(using FLID and passward) signin", +) +async def update_user_password( + item: RequestIn, + credentials: JwtAuthorizationCredentials = Security(access_security), +): + user_id = credentials["id"] + if item.password != item.password2: + return JSONResponse( + content=jsonable_encoder( + {"error": "password and password2 are not the same"} + ) + ) + else: + result = await UserManager().update_user_password(user_id, item.password) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/app/authentication/webapi/routes/tokens/__init__.py b/app/authentication/webapi/routes/tokens/__init__.py new file mode 100644 index 0000000..853d847 --- /dev/null +++ b/app/authentication/webapi/routes/tokens/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from .generate_tokens import router as generate_tokens_router +from .refresh_token import router as refresh_token_router +from .verify_token import router as verify_token_router + +router = APIRouter() + +router.include_router( + generate_tokens_router, prefix="/token", tags=["Token Management"] +) +router.include_router(refresh_token_router, prefix="/token", tags=["Token Management"]) +router.include_router(verify_token_router, prefix="/token", tags=["Token Management"]) diff --git a/app/authentication/webapi/routes/tokens/generate_tokens.py b/app/authentication/webapi/routes/tokens/generate_tokens.py new file mode 100644 index 0000000..ceec2fd --- /dev/null +++ b/app/authentication/webapi/routes/tokens/generate_tokens.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from datetime import datetime, timedelta, timezone +from infra.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() # Initialize TokenManager + + +class TokenRequest(BaseModel): + id: str + role: int + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + expires_in: datetime + + +@router.post("/generate-tokens", response_model=TokenResponse) +async def generate_tokens(request: TokenRequest): + """ + Endpoint to generate access and refresh tokens. + """ + subject = {"id": request.id, "role": request.role} + access_token = token_manager.create_access_token(subject) + refresh_token = token_manager.create_refresh_token(subject) + expires_in = datetime.now(timezone.utc) + timedelta( + minutes=token_manager.access_token_expire_minutes + ) + + return TokenResponse( + access_token=access_token, refresh_token=refresh_token, expires_in=expires_in + ) diff --git a/app/authentication/webapi/routes/tokens/refresh_token.py b/app/authentication/webapi/routes/tokens/refresh_token.py new file mode 100644 index 0000000..137265d --- /dev/null +++ b/app/authentication/webapi/routes/tokens/refresh_token.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from infra.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() # Initialize TokenManager + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + id: str + role: int + + +class RefreshTokenResponse(BaseModel): + access_token: str + + +@router.post("/refresh-token", response_model=RefreshTokenResponse) +async def refresh_token(request: RefreshTokenRequest): + """ + Endpoint to refresh the access token using a valid refresh token. + """ + subject = {"id": request.id, "role": request.role} + + try: + access_token = token_manager.refresh_access_token( + request.refresh_token, subject + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return RefreshTokenResponse(access_token=access_token) diff --git a/app/authentication/webapi/routes/tokens/verify_token.py b/app/authentication/webapi/routes/tokens/verify_token.py new file mode 100644 index 0000000..82b7f19 --- /dev/null +++ b/app/authentication/webapi/routes/tokens/verify_token.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from infra.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() # Initialize TokenManager + + +class VerifyTokenRequest(BaseModel): + token: str + + +class VerifyTokenResponse(BaseModel): + valid: bool + payload: dict + + +@router.post("/verify-token", response_model=VerifyTokenResponse) +async def verify_token(request: VerifyTokenRequest): + """ + Endpoint to verify if a token is valid and return the payload. + """ + try: + payload = token_manager.decode_token(request.token) + return VerifyTokenResponse(valid=True, payload=payload) + except ValueError: + raise HTTPException(status_code=401, detail="Invalid or expired token") diff --git a/app/central_storage/start_central_storage.sh b/app/central_storage/start_central_storage.sh deleted file mode 100755 index da25767..0000000 --- a/app/central_storage/start_central_storage.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -# This script starts the FastAPI server using uvicorn -uvicorn webapi.bootstrap.application:create_app --reload --host 0.0.0.0 --port 8005 \ No newline at end of file diff --git a/infra/exception/exceptions.py b/infra/exception/exceptions.py index 2c489c3..9097166 100644 --- a/infra/exception/exceptions.py +++ b/infra/exception/exceptions.py @@ -21,3 +21,8 @@ class InvalidOperationError(Exception): class InvalidDataError(Exception): def __init__(self, message: str = "Invalid Data"): self.message = message + + +class InvalidAuthCodeException(Exception): + def __init__(self, message: str = "Invalid Auth Code"): + self.message = message diff --git a/infra/i18n/region_handler.py b/infra/i18n/region_handler.py new file mode 100644 index 0000000..830239e --- /dev/null +++ b/infra/i18n/region_handler.py @@ -0,0 +1,19 @@ +from infra.models.constants import UserRegion + + +class RegionHandler: + def __init__(self): + self._zh_cn_patterns = [".cn", "cn.", "host"] + + def detect_from_host(self, host: str) -> UserRegion: + # Now we set user preferred region based on host + for parttern in self._zh_cn_patterns: + if parttern in host.lower(): + return UserRegion.ZH_CN + return UserRegion.OTHER + + # async def get_user_region(self, user_id: str) -> UserRegion: + # user_profile = await BasicProfileDoc.find_one( + # BasicProfileDoc.user_id == user_id + # ) + # return user_profile.region if user_profile else UserRegion.OTHER diff --git a/infra/models/constants.py b/infra/models/constants.py new file mode 100644 index 0000000..4e454dc --- /dev/null +++ b/infra/models/constants.py @@ -0,0 +1,6 @@ +from enum import IntEnum + + +class UserRegion(IntEnum): + OTHER = 0 + ZH_CN = 1 diff --git a/infra/token/token_manager.py b/infra/token/token_manager.py index 16f142e..2b54958 100644 --- a/infra/token/token_manager.py +++ b/infra/token/token_manager.py @@ -1,7 +1,6 @@ -# application/auth/token/token_manager.py from datetime import datetime, timedelta, timezone from typing import Dict -from jose import jwt +from jose import jwt, JWTError from infra.config.app_settings import app_settings @@ -15,11 +14,6 @@ class TokenManager: def create_access_token(self, subject: Dict[str, str]) -> str: """ Generates an access token with a short expiration time. - Args: - subject (Dict[str, str]): A dictionary containing user information like 'id' and 'role'. - - Returns: - str: Encoded JWT access token. """ expire = datetime.now(timezone.utc) + timedelta( minutes=self.access_token_expire_minutes @@ -31,11 +25,6 @@ class TokenManager: def create_refresh_token(self, subject: Dict[str, str]) -> str: """ Generates a refresh token with a longer expiration time. - Args: - subject (Dict[str, str]): A dictionary containing user information like 'id' and 'role'. - - Returns: - str: Encoded JWT refresh token. """ expire = datetime.now(timezone.utc) + timedelta( days=self.refresh_token_expire_days @@ -47,13 +36,28 @@ class TokenManager: def decode_token(self, token: str) -> Dict: """ Decodes a JWT token and returns the payload. - Args: - token (str): Encoded JWT token. - - Returns: - Dict: Decoded token payload. - - Raises: - JWTError: If the token is invalid or expired. """ - return jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) + try: + payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) + return payload + except JWTError: + raise ValueError("Invalid token") + + def verify_refresh_token(self, token: str) -> bool: + """ + Verifies a refresh token to ensure it is valid and not expired. + """ + try: + payload = self.decode_token(token) + return True + except ValueError: + return False + + def refresh_access_token(self, refresh_token: str, subject: Dict[str, str]) -> str: + """ + Verifies the refresh token and creates a new access token. + """ + if self.verify_refresh_token(refresh_token): + return self.create_access_token(subject) + else: + raise ValueError("Invalid refresh token") diff --git a/infra/utils/date.py b/infra/utils/date.py new file mode 100644 index 0000000..79451d1 --- /dev/null +++ b/infra/utils/date.py @@ -0,0 +1,22 @@ +import datetime +from datetime import timedelta, timezone + + +def get_sunday(date): + return date - datetime.timedelta(days=date.weekday()) + timedelta(days=6) + + +def get_last_sunday_dates(number, include_current_week=True): + now_utc = datetime.datetime.now(timezone.utc) + today = datetime.datetime(now_utc.year, now_utc.month, now_utc.day) + if include_current_week: + days_to_last_sunday = (6 - today.weekday()) % 7 + last_sunday = today + datetime.timedelta(days=days_to_last_sunday) + else: + days_to_last_sunday = (today.weekday() - 6) % 7 + last_sunday = today - datetime.timedelta(days=days_to_last_sunday) + last_n_sundays = [] + for i in range(number): + sunday = last_sunday - datetime.timedelta(days=i * 7) + last_n_sundays.append(sunday.date()) + return last_n_sundays diff --git a/infra/utils/string.py b/infra/utils/string.py new file mode 100644 index 0000000..9e81331 --- /dev/null +++ b/infra/utils/string.py @@ -0,0 +1,88 @@ +import random +import re +import jieba +from typing import List + +SKILL_TAGS = [ + "C++", + "Java", + "Python", + "TypeScript", + "iOS", + "Android", + "Web", + "Javascript", + "Vue", + "Go", +] + + +# dynamically update skill tags? maybe based on the most commonly extracted keywords to help the system adapt to change +def updateSkillTags(string): + SKILL_TAGS.append(string) + + +def generate_auth_code(): + filtered = "0123456789" + code = "".join(random.choice(filtered) for i in range(6)) + return code + + +# TODO: Need to optimize +def generate_self_intro_summary(content_html: str) -> str: + element_html = re.compile("<.*?>") + content_text = re.sub(element_html, "", content_html).strip() + return content_text[:50] + + +# TODO: Need to optimize +def extract_skill_tags(content_html: str) -> List[str]: + element_html = re.compile("<.*?>") + content_text = re.sub(element_html, "", content_html).strip() + words = set([word.lower() for word in jieba.cut(content_text) if word.strip()]) + + results = [] + for tag in SKILL_TAGS: + if tag.lower() in words: + results.append(tag) + return results + + +def extract_title(content_html: str) -> List[str]: + element_html = re.compile("<.*?>") + content_text = re.sub(element_html, "\n", content_html).strip() + + cut_point_indexes = [] + for cut_point in [".", ",", ";", "\r", "\n"]: + result = content_text.find(cut_point) + if result > 0: + cut_point_indexes.append(result) + + title = ( + content_text[: min(cut_point_indexes)] + if len(cut_point_indexes) > 0 + else content_text + ) + return title + + +def check_password_complexity(password): + lowercase_pattern = r"[a-z]" + uppercase_pattern = r"[A-Z]" + digit_pattern = r"\d" + special_pattern = r'[!@#$%^&*(),.?":{}|<>]' + + password_lowercase_one = bool(re.search(lowercase_pattern, password)) + password_uppercase_one = bool(re.search(uppercase_pattern, password)) + password_digit_one = bool(re.search(digit_pattern, password)) + password_special_one = bool(re.search(special_pattern, password)) + + if ( + password_lowercase_one + and password_uppercase_one + and password_digit_one + and password_special_one + ): + return True + else: + return False