diff --git a/docker-compose.yml b/docker-compose.yml index 9108966..d48740d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,44 +14,4 @@ services: APP_WORKERS: "2" env_file: - .env - depends_on: - b2b_keydb: - condition: service_healthy restart: no - - b2b_keydb: - image: eqalpha/keydb - container_name: b2b_keydb - restart: no - expose: - - "6379" - volumes: - - b2b_keydb_data:/data - command: - - keydb-server - - --requirepass - - ${REDIS_PASSWORD} - - --dir - - /data - - --appendonly - - "yes" - - --appendfsync - - everysec - - --save - - "900" - - "1" - - --save - - "300" - - "10" - - --save - - "60" - - "10000" - healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] - interval: 5s - timeout: 2s - retries: 20 - - -volumes: - b2b_keydb_data: diff --git a/pyproject.toml b/pyproject.toml index f188caf..d35cd63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,20 +6,16 @@ requires-python = "==3.12.*" dependencies = [ "apscheduler==3.11.2", "asyncpg==0.31.0", - "bip-utils>=2.9.3", - "cryptography>=44.0.0", + "bip-utils==2.9.3", + "cryptography==44.0.0", "dotenv==0.9.9", "fastapi==0.128.7", "granian==2.6.1", "hvac==2.4.0", "itsdangerous==2.2.0", - "orjson==3.11.7", "pydantic-settings==2.12.0", "python-jose==3.5.0", "python-ulid==3.1.0", - "redis==7.2.0", "sqlalchemy==2.0.46", - "bip-utils==2.9.3", - "cryptography==44.0.0", "uvloop==0.22.1; platform_system != 'Windows'", ] diff --git a/src/application/commands/change_email_complete.py b/src/application/commands/change_email_complete.py deleted file mode 100644 index 0ccfbf7..0000000 --- a/src/application/commands/change_email_complete.py +++ /dev/null @@ -1,63 +0,0 @@ -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ILogger, ICache -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.database.decorators import transactional - - -class ChangeEmailCompleteCommand: - def __init__( - self, - unit_of_work: IUnitOfWork, - hash_service: IHashService, - cache: ICache, - logger: ILogger, - ): - self._unit_of_work = unit_of_work - self._hash_service = hash_service - self._cache = cache - self._logger = logger - - @transactional - async def __call__(self, *, user_id: str, code: str) -> bool: - code = (code or '').strip() - - NEW_USER_PREFIX = 'change_email:new_user:' - NEW_CODE_PREFIX = 'change_email:new_code:' - - new_user_key = f'{NEW_USER_PREFIX}{user_id}' - new_code_key = f'{NEW_CODE_PREFIX}{code}' - - cached_user_id = await self._cache.get(new_code_key) - if not cached_user_id: - self._logger.info(f'Change email complete failed: code not found (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - if cached_user_id != user_id: - self._logger.info(f'Change email complete failed: code-user mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - raw_value = await self._cache.get(new_user_key) - if not raw_value: - self._logger.info(f'Change email complete failed: user key missing (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - separator_idx = raw_value.index(':') - code_hash = raw_value[:separator_idx] - new_email = raw_value[separator_idx + 1:] - - ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) - if not ok: - self._logger.info(f'Change email complete failed: code hash mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - user = await self._unit_of_work.user_repository.set_email(user_id=user_id, email=new_email) - await self._cache.set_user(user_id, user) - - try: - await self._cache.delete(new_code_key) - await self._cache.delete(new_user_key) - except Exception as e: - self._logger.warning(f'Change email complete cleanup failed (user_id={user_id}): {e}') - - self._logger.info(f'Email changed for user_id={user_id}') - return True diff --git a/src/application/commands/change_email_confirm_old.py b/src/application/commands/change_email_confirm_old.py deleted file mode 100644 index 95d955f..0000000 --- a/src/application/commands/change_email_confirm_old.py +++ /dev/null @@ -1,145 +0,0 @@ -import secrets -from datetime import datetime, timezone -from ulid import ULID -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.config import settings -from src.infrastructure.context_vars import trace_id_var -from src.infrastructure.database.decorators import transactional - - -class ChangeEmailConfirmOldCommand: - def __init__( - self, - hash_service: IHashService, - cache: ICache, - unit_of_work: IUnitOfWork, - logger: ILogger, - messanger: IQueueMessanger, - ): - self._hash_service = hash_service - self._unit_of_work = unit_of_work - self._cache = cache - self._logger = logger - self._messanger = messanger - - @transactional - async def __call__(self, *, user_id: str, code: str, new_email: str) -> bool: - TTL = 300 - MAX_ATTEMPTS = 20 - - OLD_USER_PREFIX = 'change_email:old_user:' - OLD_CODE_PREFIX = 'change_email:old_code:' - NEW_USER_PREFIX = 'change_email:new_user:' - NEW_CODE_PREFIX = 'change_email:new_code:' - - code = (code or '').strip() - old_user_key = f'{OLD_USER_PREFIX}{user_id}' - old_code_key = f'{OLD_CODE_PREFIX}{code}' - - cached_user_id = await self._cache.get(old_code_key) - if not cached_user_id: - self._logger.info(f'Change email confirm-old failed: code not found (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - if cached_user_id != user_id: - self._logger.info(f'Change email confirm-old failed: code-user mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - code_hash = await self._cache.get(old_user_key) - if not code_hash: - self._logger.info(f'Change email confirm-old failed: user key missing (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) - if not ok: - self._logger.info(f'Change email confirm-old failed: code hash mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) - - if user.email and user.email.lower() == new_email.lower(): - self._logger.info(f'Change email confirm-old failed: new email same as current (user_id={user_id})') - raise ApplicationException(400, 'New email must differ from the current one') - - email_taken = await self._unit_of_work.user_repository.email_exists(email=new_email) - if email_taken: - self._logger.info(f'Change email confirm-old failed: new email already taken (user_id={user_id})') - raise ApplicationException(409, 'Email already in use') - - try: - await self._cache.delete(old_code_key) - await self._cache.delete(old_user_key) - except Exception as e: - self._logger.warning(f'Change email confirm-old cleanup failed (user_id={user_id}): {e}') - - trace_id = trace_id_var.get() - if not trace_id or trace_id == 'N/A': - trace_id = None - - new_user_key = f'{NEW_USER_PREFIX}{user_id}' - - for _ in range(MAX_ATTEMPTS): - new_code = f'{secrets.randbelow(1_000_000):06d}' - new_code_key = f'{NEW_CODE_PREFIX}{new_code}' - - new_code_hash = await self._hash_service.hash(new_code) - - reserved = await self._cache.set_nx(new_code_key, user_id, ttl=TTL) - if not reserved: - continue - - saved = await self._cache.set(new_user_key, f'{new_code_hash}:{new_email}', ttl=TTL) - if not saved: - await self._cache.delete(new_code_key) - self._logger.error(f'Change email confirm-old failed: cannot save new code hash for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - message_id = str(ULID()) - now = datetime.now(timezone.utc).isoformat() - - metadata = { - 'trace_id': trace_id, - 'source': 'user-service', - 'timestamp': now, - 'message_id': message_id, - } - - payload = { - 'email': new_email, - 'code': new_code, - 'ttl_seconds': TTL, - } - - message = { - 'event': 'change_email_new', - 'payload': payload, - 'metadata': metadata, - } - - self._logger.info(f'Change email new code created for user_id={user_id}') - - try: - await self._messanger.publish_to_queue( - queue=settings.RABBIT_EMAIL_CODE_QUEUE, - message=message, - persist=True, - correlation_id=trace_id, - message_id=message_id, - headers={'trace_id': trace_id} if trace_id else None, - ) - except Exception as exception: - try: - await self._cache.delete(new_user_key) - await self._cache.delete(new_code_key) - except Exception as rollback_err: - self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') - - self._logger.error(f'Failed to publish change email new code for user_id={user_id}: {str(exception)}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - return True - - self._logger.error(f'Change email confirm-old failed: code space exhausted for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') diff --git a/src/application/commands/change_email_start.py b/src/application/commands/change_email_start.py deleted file mode 100644 index e9047df..0000000 --- a/src/application/commands/change_email_start.py +++ /dev/null @@ -1,126 +0,0 @@ -import secrets -from datetime import datetime, timezone -from ulid import ULID -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.config import settings -from src.infrastructure.context_vars import trace_id_var -from src.infrastructure.database.decorators import transactional - - -class ChangeEmailStartCommand: - def __init__( - self, - hash_service: IHashService, - cache: ICache, - unit_of_work: IUnitOfWork, - logger: ILogger, - messanger: IQueueMessanger, - ): - self._hash_service = hash_service - self._unit_of_work = unit_of_work - self._cache = cache - self._logger = logger - self._messanger = messanger - - @transactional - async def __call__(self, user_id: str) -> bool: - TTL = 300 - LOCK_TTL = 30 - MAX_ATTEMPTS = 20 - - USER_PREFIX = 'change_email:old_user:' - CODE_PREFIX = 'change_email:old_code:' - LOCK_PREFIX = 'change_email:lock:' - - user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) - - if not user.email: - self._logger.warning(f'User {user_id} does not have an email address') - raise ApplicationException(404, f'User {user_id} does not have an email address') - - trace_id = trace_id_var.get() - if not trace_id or trace_id == 'N/A': - trace_id = None - - lock_key = f'{LOCK_PREFIX}{user_id}' - locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) - if not locked: - self._logger.info(f'Change email throttled by lock (user_id={user_id})') - raise ApplicationException(429, 'Too many requests. Please wait.') - - try: - user_key = f'{USER_PREFIX}{user_id}' - - existing = await self._cache.get(user_key) - if existing: - self._logger.info(f'Change email denied: code already exists for user_id={user_id}') - raise ApplicationException(429, 'Code already sent. Please wait before retrying.') - - for _ in range(MAX_ATTEMPTS): - code = f'{secrets.randbelow(1_000_000):06d}' - code_key = f'{CODE_PREFIX}{code}' - - code_hash = await self._hash_service.hash(code) - - reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) - if not reserved: - continue - - saved = await self._cache.set(user_key, code_hash, ttl=TTL) - if not saved: - await self._cache.delete(code_key) - self._logger.error(f'Change email failed: cannot save code hash for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - message_id = str(ULID()) - now = datetime.now(timezone.utc).isoformat() - - metadata = { - 'trace_id': trace_id, - 'source': 'user-service', - 'timestamp': now, - 'message_id': message_id, - } - - payload = { - 'email': user.email, - 'code': code, - 'ttl_seconds': TTL, - } - - message = { - 'event': 'change_email_old', - 'payload': payload, - 'metadata': metadata, - } - - self._logger.info(f'Change email old code created for user_id={user_id}') - - try: - await self._messanger.publish_to_queue( - queue=settings.RABBIT_EMAIL_CODE_QUEUE, - message=message, - persist=True, - correlation_id=trace_id, - message_id=message_id, - headers={'trace_id': trace_id} if trace_id else None, - ) - except Exception as exception: - try: - await self._cache.delete(user_key) - await self._cache.delete(code_key) - except Exception as rollback_err: - self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') - - self._logger.error(f'Failed to publish change email old code for user_id={user_id}: {str(exception)}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - return True - - self._logger.error(f'Change email failed: code space exhausted for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - finally: - await self._cache.delete(lock_key) diff --git a/src/application/commands/change_password_complete.py b/src/application/commands/change_password_complete.py deleted file mode 100644 index 9dc7367..0000000 --- a/src/application/commands/change_password_complete.py +++ /dev/null @@ -1,81 +0,0 @@ -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ILogger, ICache -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.database.decorators import transactional - - -class ChangePasswordCompleteCommand: - def __init__( - self, - unit_of_work: IUnitOfWork, - hash_service: IHashService, - cache: ICache, - logger: ILogger, - ): - self._unit_of_work = unit_of_work - self._hash_service = hash_service - self._cache = cache - self._logger = logger - - @transactional - async def __call__( - self, - *, - user_id: str, - code: str, - new_password: str, - confirm_password: str, - ) -> bool: - code = (code or '').strip() - - USER_PREFIX = 'change_password:user:' - CODE_PREFIX = 'change_password:code:' - - user_key = f'{USER_PREFIX}{user_id}' - code_key = f'{CODE_PREFIX}{code}' - - if new_password != confirm_password: - self._logger.info(f'Change password failed: passwords do not match (user_id={user_id})') - raise ApplicationException(400, 'Passwords do not match') - - cached_user_id = await self._cache.get(code_key) - if not cached_user_id: - self._logger.info(f'Change password failed: code not found (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - if cached_user_id != user_id: - self._logger.info(f'Change password failed: code-user mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - code_hash = await self._cache.get(user_key) - if not code_hash: - self._logger.info(f'Change password failed: user key missing (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) - if not ok: - self._logger.info(f'Change password failed: code hash mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - current_password_hash = await self._unit_of_work.user_repository.get_password_hash(user_id=user_id) - - is_same = await self._hash_service.verify(hashed_value=current_password_hash, plain_value=new_password) - if is_same: - self._logger.info(f'Change password failed: new password same as current (user_id={user_id})') - raise ApplicationException(400, 'New password must differ from the current one') - - new_password_hash = await self._hash_service.hash(new_password) - user = await self._unit_of_work.user_repository.set_password( - user_id=user_id, - password_hash=new_password_hash, - ) - await self._cache.set_user(user_id, user) - - try: - await self._cache.delete(code_key) - await self._cache.delete(user_key) - except Exception as e: - self._logger.warning(f'Change password cleanup failed (user_id={user_id}): {e}') - - self._logger.info(f'Password changed for user_id={user_id}') - return True diff --git a/src/application/commands/change_password_start.py b/src/application/commands/change_password_start.py deleted file mode 100644 index d3c1138..0000000 --- a/src/application/commands/change_password_start.py +++ /dev/null @@ -1,126 +0,0 @@ -import secrets -from datetime import datetime, timezone -from ulid import ULID -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.config import settings -from src.infrastructure.context_vars import trace_id_var -from src.infrastructure.database.decorators import transactional - - -class ChangePasswordStartCommand: - def __init__( - self, - hash_service: IHashService, - cache: ICache, - unit_of_work: IUnitOfWork, - logger: ILogger, - messanger: IQueueMessanger, - ): - self._hash_service = hash_service - self._unit_of_work = unit_of_work - self._cache = cache - self._logger = logger - self._messanger = messanger - - @transactional - async def __call__(self, user_id: str) -> bool: - TTL = 300 - LOCK_TTL = 30 - MAX_ATTEMPTS = 20 - - USER_PREFIX = 'change_password:user:' - CODE_PREFIX = 'change_password:code:' - LOCK_PREFIX = 'change_password:lock:' - - user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) - - if not user.email: - self._logger.warning(f'User {user_id} does not have an email address') - raise ApplicationException(404, f'User {user_id} does not have an email address') - - trace_id = trace_id_var.get() - if not trace_id or trace_id == 'N/A': - trace_id = None - - lock_key = f'{LOCK_PREFIX}{user_id}' - locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) - if not locked: - self._logger.info(f'Change password throttled by lock (user_id={user_id})') - raise ApplicationException(429, 'Too many requests. Please wait.') - - try: - user_key = f'{USER_PREFIX}{user_id}' - - existing = await self._cache.get(user_key) - if existing: - self._logger.info(f'Change password denied: code already exists for user_id={user_id}') - raise ApplicationException(429, 'Code already sent. Please wait before retrying.') - - for _ in range(MAX_ATTEMPTS): - code = f'{secrets.randbelow(1_000_000):06d}' - code_key = f'{CODE_PREFIX}{code}' - - code_hash = await self._hash_service.hash(code) - - reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) - if not reserved: - continue - - saved = await self._cache.set(user_key, code_hash, ttl=TTL) - if not saved: - await self._cache.delete(code_key) - self._logger.error(f'Change password failed: cannot save code hash for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - message_id = str(ULID()) - now = datetime.now(timezone.utc).isoformat() - - metadata = { - 'trace_id': trace_id, - 'source': 'user-service', - 'timestamp': now, - 'message_id': message_id, - } - - payload = { - 'email': user.email, - 'code': code, - 'ttl_seconds': TTL, - } - - message = { - 'event': 'change_password', - 'payload': payload, - 'metadata': metadata, - } - - self._logger.info(f'Change password code created for user_id={user_id}') - - try: - await self._messanger.publish_to_queue( - queue=settings.RABBIT_EMAIL_CODE_QUEUE, - message=message, - persist=True, - correlation_id=trace_id, - message_id=message_id, - headers={'trace_id': trace_id} if trace_id else None, - ) - except Exception as exception: - try: - await self._cache.delete(user_key) - await self._cache.delete(code_key) - except Exception as rollback_err: - self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') - - self._logger.error(f'Failed to publish change password email for user_id={user_id}: {str(exception)}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - return True - - self._logger.error(f'Change password failed: code space exhausted for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - finally: - await self._cache.delete(lock_key) diff --git a/src/application/commands/delete_avatar.py b/src/application/commands/delete_avatar.py deleted file mode 100644 index d215077..0000000 --- a/src/application/commands/delete_avatar.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from botocore.exceptions import ClientError - -from src.application.abstractions import IUnitOfWork -from src.application.contracts import ICache, ILogger, IS3 -from src.application.domain.entities import UserEntity -from src.infrastructure.database.decorators import transactional - - -class DeleteAvatarCommand: - def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache, s3: IS3): - self._unit_of_work = unit_of_work - self._logger = logger - self._cache = cache - self._s3 = s3 - - @transactional - async def _load_user(self, user_id: str) -> UserEntity: - user = await self._unit_of_work.user_repository.get_user_by_id(user_id) - self._logger.debug(f'DeleteAvatar _load_user user_id={user_id} has_avatar_link={bool(user.avatar_link)}') - return user - - async def __call__(self, user_id: str) -> UserEntity: - prior = await self._load_user(user_id) - link = prior.avatar_link - self._logger.info(f'DeleteAvatar start user_id={user_id} had_link={bool(link)}') - if link: - key = self._s3.object_key_from_public_url(link) - self._logger.debug(f'DeleteAvatar parsed_object_key user_id={user_id} has_key={bool(key)}') - if not key: - self._logger.warning( - f'DeleteAvatar could not parse avatar URL for S3 user_id={user_id} link_len={len(link)}' - ) - if key: - self._logger.info(f'DeleteAvatar S3 delete start user_id={user_id} key={key}') - try: - await self._s3.delete_object(key=key) - self._logger.info(f'DeleteAvatar S3 delete done user_id={user_id} key={key}') - except ClientError as exc: - code = exc.response.get('Error', {}).get('Code', '') - if code not in ('NoSuchKey', '404'): - self._logger.warning(f'DeleteAvatar S3 delete failed user_id={user_id} code={code}: {exc}') - else: - self._logger.debug(f'DeleteAvatar S3 object already absent user_id={user_id} code={code}') - user = await self._clear_avatar_link(user_id) - self._logger.debug(f'DeleteAvatar DB cleared user_id={user_id} entity_has_link={bool(user.avatar_link)}') - await self._cache.set_user(user_id, user) - self._logger.debug(f'DeleteAvatar cache updated user_id={user_id}') - self._logger.info(f'Avatar removed user_id={user_id}') - return user - - @transactional - async def _clear_avatar_link(self, user_id: str) -> UserEntity: - self._logger.debug(f'DeleteAvatar DB transaction set_avatar_link user_id={user_id} link=None') - return await self._unit_of_work.user_repository.set_avatar_link(user_id, None) diff --git a/src/application/commands/forgot_password_complete.py b/src/application/commands/forgot_password_complete.py deleted file mode 100644 index e06f49f..0000000 --- a/src/application/commands/forgot_password_complete.py +++ /dev/null @@ -1,83 +0,0 @@ -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ILogger, ICache -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.database.decorators import transactional - - -class ForgotPasswordCompleteCommand: - def __init__( - self, - unit_of_work: IUnitOfWork, - hash_service: IHashService, - cache: ICache, - logger: ILogger, - ): - self._unit_of_work = unit_of_work - self._hash_service = hash_service - self._cache = cache - self._logger = logger - - @staticmethod - def _normalize_email(email: str) -> str: - return email.strip().lower() - - @transactional - async def __call__( - self, - *, - email: str, - code: str, - new_password: str, - confirm_password: str, - ) -> bool: - code = (code or '').strip() - normalized = self._normalize_email(email) - - EMAIL_PREFIX = 'forgot_password:email:' - CODE_PREFIX = 'forgot_password:code:' - - if new_password != confirm_password: - self._logger.info('Forgot password failed: passwords do not match') - raise ApplicationException(400, 'Passwords do not match') - - code_key = f'{CODE_PREFIX}{code}' - cached_email = await self._cache.get(code_key) - if not cached_email: - self._logger.info('Forgot password failed: code not found') - raise ApplicationException(400, 'Invalid or expired code') - - if cached_email != normalized: - self._logger.info('Forgot password failed: code-email mismatch') - raise ApplicationException(400, 'Invalid or expired code') - - email_key = f'{EMAIL_PREFIX}{normalized}' - code_hash = await self._cache.get(email_key) - if not code_hash: - self._logger.info('Forgot password failed: email key missing') - raise ApplicationException(400, 'Invalid or expired code') - - ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) - if not ok: - self._logger.info('Forgot password failed: code hash mismatch') - raise ApplicationException(400, 'Invalid or expired code') - - user = await self._unit_of_work.user_repository.get_user_by_email(normalized) - if user is None: - self._logger.info('Forgot password failed: user not found after valid code') - raise ApplicationException(400, 'Invalid or expired code') - - new_password_hash = await self._hash_service.hash(new_password) - user = await self._unit_of_work.user_repository.set_password( - user_id=user.id, - password_hash=new_password_hash, - ) - await self._cache.set_user(user.id, user) - - try: - await self._cache.delete(code_key) - await self._cache.delete(email_key) - except Exception as e: - self._logger.warning(f'Forgot password cleanup failed (user_id={user.id}): {e}') - - self._logger.info(f'Password reset via forgot flow for user_id={user.id}') - return True diff --git a/src/application/commands/forgot_password_start.py b/src/application/commands/forgot_password_start.py deleted file mode 100644 index da7f86d..0000000 --- a/src/application/commands/forgot_password_start.py +++ /dev/null @@ -1,132 +0,0 @@ -import secrets -from datetime import datetime, timezone -from ulid import ULID -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.config import settings -from src.infrastructure.context_vars import trace_id_var -from src.infrastructure.database.decorators import transactional - - -class ForgotPasswordStartCommand: - def __init__( - self, - hash_service: IHashService, - cache: ICache, - unit_of_work: IUnitOfWork, - logger: ILogger, - messanger: IQueueMessanger, - ): - self._hash_service = hash_service - self._unit_of_work = unit_of_work - self._cache = cache - self._logger = logger - self._messanger = messanger - - @staticmethod - def _normalize_email(email: str) -> str: - return email.strip().lower() - - @transactional - async def __call__(self, email: str) -> bool: - TTL = 300 - LOCK_TTL = 30 - MAX_ATTEMPTS = 20 - - EMAIL_PREFIX = 'forgot_password:email:' - CODE_PREFIX = 'forgot_password:code:' - LOCK_PREFIX = 'forgot_password:lock:' - - normalized = self._normalize_email(email) - user = await self._unit_of_work.user_repository.get_user_by_email(normalized) - if user is None: - self._logger.info(f'Forgot password start: no user for email hash lookup') - return True - - trace_id = trace_id_var.get() - if not trace_id or trace_id == 'N/A': - trace_id = None - - lock_key = f'{LOCK_PREFIX}{normalized}' - locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) - if not locked: - self._logger.info(f'Forgot password throttled by lock (user_id={user.id})') - raise ApplicationException(429, 'Too many requests. Please wait.') - - try: - email_key = f'{EMAIL_PREFIX}{normalized}' - - existing = await self._cache.get(email_key) - if existing: - self._logger.info(f'Forgot password denied: code already exists for user_id={user.id}') - raise ApplicationException(429, 'Code already sent. Please wait before retrying.') - - for _ in range(MAX_ATTEMPTS): - code = f'{secrets.randbelow(1_000_000):06d}' - code_key = f'{CODE_PREFIX}{code}' - - code_hash = await self._hash_service.hash(code) - - reserved = await self._cache.set_nx(code_key, normalized, ttl=TTL) - if not reserved: - continue - - saved = await self._cache.set(email_key, code_hash, ttl=TTL) - if not saved: - await self._cache.delete(code_key) - self._logger.error(f'Forgot password failed: cannot save code hash for user_id={user.id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - message_id = str(ULID()) - now = datetime.now(timezone.utc).isoformat() - - metadata = { - 'trace_id': trace_id, - 'source': 'user-service', - 'timestamp': now, - 'message_id': message_id, - } - - payload = { - 'email': normalized, - 'code': code, - 'ttl_seconds': TTL, - } - - message = { - 'event': 'forgot_password', - 'payload': payload, - 'metadata': metadata, - } - - self._logger.info(f'Forgot password code created for user_id={user.id}') - - try: - await self._messanger.publish_to_queue( - queue=settings.RABBIT_EMAIL_CODE_QUEUE, - message=message, - persist=True, - correlation_id=trace_id, - message_id=message_id, - headers={'trace_id': trace_id} if trace_id else None, - ) - except Exception as exception: - try: - await self._cache.delete(email_key) - await self._cache.delete(code_key) - except Exception as rollback_err: - self._logger.error( - f'Publish failed and rollback cache failed for user_id={user.id}: {str(rollback_err)}' - ) - - self._logger.error(f'Failed to publish forgot password email for user_id={user.id}: {str(exception)}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - return True - - self._logger.error(f'Forgot password failed: code space exhausted for user_id={user.id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - finally: - await self._cache.delete(lock_key) diff --git a/src/application/commands/get_me.py b/src/application/commands/get_me.py deleted file mode 100644 index 7da5eb8..0000000 --- a/src/application/commands/get_me.py +++ /dev/null @@ -1,17 +0,0 @@ -from src.application.abstractions import IUnitOfWork -from src.application.contracts import ILogger, ICache -from src.application.domain.entities import UserEntity -from src.infrastructure.database.decorators import transactional - - -class GetMeCommand: - def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache): - self._unit_of_work = unit_of_work - self._logger = logger - self._cache = cache - - @transactional - async def __call__(self, user_id: str) -> UserEntity: - user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) - self._logger.info(f'User ID: {user.id}') - return user \ No newline at end of file diff --git a/src/application/commands/set_avatar.py b/src/application/commands/set_avatar.py deleted file mode 100644 index 281aa23..0000000 --- a/src/application/commands/set_avatar.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -from PIL import UnidentifiedImageError -from ulid import ULID - -from botocore.exceptions import ClientError - -from src.application.abstractions import IUnitOfWork -from src.application.contracts import ICache, ILogger, IS3 -from src.application.domain.entities import UserEntity -from src.application.domain.exceptions import BadRequestException, ServiceUnavailableException -from src.infrastructure.config import settings -from src.infrastructure.database.decorators import transactional -from src.infrastructure.media.webp import image_bytes_to_webp - - -class SetAvatarCommand: - def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache, s3: IS3): - self._unit_of_work = unit_of_work - self._logger = logger - self._cache = cache - self._s3 = s3 - - @transactional - async def _load_user(self, user_id: str) -> UserEntity: - user = await self._unit_of_work.user_repository.get_user_by_id(user_id) - self._logger.debug(f'Avatar _load_user user_id={user_id} has_avatar_link={bool(user.avatar_link)}') - return user - - async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]: - prior = await self._load_user(user_id) - old_link = prior.avatar_link - self._logger.info( - f'SetAvatar start user_id={user_id} input_bytes={len(image_bytes)} had_previous_link={bool(old_link)}' - ) - try: - webp_bytes = image_bytes_to_webp(image_bytes) - except UnidentifiedImageError as exc: - raise BadRequestException(message='Unsupported or corrupt image') from exc - except Exception as exc: - self._logger.exception(str(exc)) - raise BadRequestException(message='Could not process image') from exc - - self._logger.debug(f'SetAvatar webp_ready bytes={len(webp_bytes)}') - - pid = user_id.replace('/', '').replace('.', '_') - name_id = str(ULID()) - ts = int(datetime.now(timezone.utc).timestamp() * 1000) - prefix = settings.S3_AVATAR_KEY_PREFIX.strip().strip('/') - fname = f'{name_id}_{pid}_{ts}.webp' - object_key = f'{prefix}/{fname}' if prefix else fname - - self._logger.info(f'SetAvatar S3 upload start user_id={user_id} key={object_key} webp_bytes={len(webp_bytes)}') - - try: - url = await self._s3.upload_bytes(key=object_key, body=webp_bytes, content_type='image/webp') - except ClientError as exc: - self._logger.exception(str(exc)) - raise ServiceUnavailableException(message='S3 upload failed') from exc - - self._logger.info(f'SetAvatar S3 upload done user_id={user_id} key={object_key} public_url_len={len(url)}') - - user = await self._save_avatar_link(user_id, url) - self._logger.info( - f'SetAvatar DB updated user_id={user_id} key={object_key} ' - f'entity_avatar_link_len={len(user.avatar_link or "")}' - ) - await self._cache.set_user(user_id, user) - self._logger.debug(f'SetAvatar cache updated user_id={user_id}') - - if old_link: - old_key = self._s3.object_key_from_public_url(old_link) - if not old_key: - self._logger.warning( - f'SetAvatar could not parse old avatar URL for S3 delete user_id={user_id} ' - f'old_link_len={len(old_link)}' - ) - elif old_key == object_key: - self._logger.debug(f'SetAvatar skip delete same object key user_id={user_id} key={object_key}') - else: - self._logger.info(f'SetAvatar S3 delete old object user_id={user_id} old_key={old_key}') - try: - await self._s3.delete_object(key=old_key) - self._logger.info(f'SetAvatar S3 old object removed user_id={user_id} old_key={old_key}') - except ClientError as exc: - code = exc.response.get('Error', {}).get('Code', '') - if code not in ('NoSuchKey', '404'): - self._logger.warning(f'S3 delete old avatar failed user_id={user_id} code={code}: {exc}') - else: - self._logger.debug(f'SetAvatar old object already gone user_id={user_id} code={code}') - - self._logger.info(f'Avatar set for user_id={user_id} key={object_key}') - return user, len(webp_bytes) - - @transactional - async def _save_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity: - self._logger.debug(f'SetAvatar DB transaction set_avatar_link user_id={user_id} link_len={len(avatar_link)}') - return await self._unit_of_work.user_repository.set_avatar_link(user_id, avatar_link) diff --git a/src/application/commands/set_encrypted_mnemonic_complete.py b/src/application/commands/set_encrypted_mnemonic_complete.py deleted file mode 100644 index 0112421..0000000 --- a/src/application/commands/set_encrypted_mnemonic_complete.py +++ /dev/null @@ -1,68 +0,0 @@ -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ILogger, ICache -from src.application.domain.entities import UserEntity -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.database.decorators import transactional - - -class SetEncryptedMnemonicCompleteCommand: - def __init__( - self, - unit_of_work: IUnitOfWork, - hash_service: IHashService, - cache: ICache, - logger: ILogger, - ): - self._unit_of_work = unit_of_work - self._hash_service = hash_service - self._cache = cache - self._logger = logger - - @transactional - async def __call__(self, *, user_id: str, code: str, encrypted_mnemonic: str) -> UserEntity: - code = (code or '').strip() - - USER_PREFIX = 'encrypted_mnemonic:user:' - CODE_PREFIX = 'encrypted_mnemonic:code:' - - user_key = f'{USER_PREFIX}{user_id}' - code_key = f'{CODE_PREFIX}{code}' - - user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) - if user.encrypted_mnemonic is not None: - self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}') - raise ApplicationException(409, 'Encrypted mnemonic already set and cannot be changed') - - cached_user_id = await self._cache.get(code_key) - if not cached_user_id: - self._logger.info(f'Encrypted mnemonic set failed: code not found (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - if cached_user_id != user_id: - self._logger.info(f'Encrypted mnemonic set failed: code-user mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - code_hash = await self._cache.get(user_key) - if not code_hash: - self._logger.info(f'Encrypted mnemonic set failed: user key missing (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) - if not ok: - self._logger.info(f'Encrypted mnemonic set failed: code hash mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - user = await self._unit_of_work.user_repository.set_encrypted_mnemonic( - user_id=user_id, - encrypted_mnemonic=encrypted_mnemonic, - ) - await self._cache.set_user(user_id, user) - - try: - await self._cache.delete(code_key) - await self._cache.delete(user_key) - except Exception as e: - self._logger.warning(f'Encrypted mnemonic set cleanup failed (user_id={user_id}): {e}') - - self._logger.info(f'Encrypted mnemonic set for user_id={user_id}') - return user diff --git a/src/application/commands/set_encrypted_mnemonic_start.py b/src/application/commands/set_encrypted_mnemonic_start.py deleted file mode 100644 index 2b115fe..0000000 --- a/src/application/commands/set_encrypted_mnemonic_start.py +++ /dev/null @@ -1,130 +0,0 @@ -import secrets -from datetime import datetime, timezone -from ulid import ULID -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.config import settings -from src.infrastructure.context_vars import trace_id_var -from src.infrastructure.database.decorators import transactional - - -class SetEncryptedMnemonicStartCommand: - def __init__( - self, - hash_service: IHashService, - cache: ICache, - unit_of_work: IUnitOfWork, - logger: ILogger, - messanger: IQueueMessanger, - ): - self._hash_service = hash_service - self._unit_of_work = unit_of_work - self._cache = cache - self._logger = logger - self._messanger = messanger - - @transactional - async def __call__(self, user_id: str) -> bool: - TTL = 300 - LOCK_TTL = 30 - MAX_ATTEMPTS = 20 - - USER_PREFIX = 'encrypted_mnemonic:user:' - CODE_PREFIX = 'encrypted_mnemonic:code:' - LOCK_PREFIX = 'encrypted_mnemonic:lock:' - - user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) - - if user.encrypted_mnemonic is not None: - self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}') - raise ApplicationException(409, 'Encrypted mnemonic already set and cannot be changed') - - if not user.email: - self._logger.warning(f'User {user_id} does not have an email address') - raise ApplicationException(404, f'User {user_id} does not have an email address') - - trace_id = trace_id_var.get() - if not trace_id or trace_id == 'N/A': - trace_id = None - - lock_key = f'{LOCK_PREFIX}{user_id}' - locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) - if not locked: - self._logger.info(f'Encrypted mnemonic set throttled by lock (user_id={user_id})') - raise ApplicationException(429, 'Too many requests. Please wait.') - - try: - user_key = f'{USER_PREFIX}{user_id}' - - existing = await self._cache.get(user_key) - if existing: - self._logger.info(f'Encrypted mnemonic set denied: code already exists for user_id={user_id}') - raise ApplicationException(429, 'Code already sent. Please wait before retrying.') - - for _ in range(MAX_ATTEMPTS): - code = f'{secrets.randbelow(1_000_000):06d}' - code_key = f'{CODE_PREFIX}{code}' - - code_hash = await self._hash_service.hash(code) - - reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) - if not reserved: - continue - - saved = await self._cache.set(user_key, code_hash, ttl=TTL) - if not saved: - await self._cache.delete(code_key) - self._logger.error(f'Encrypted mnemonic set failed: cannot save code hash for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - message_id = str(ULID()) - now = datetime.now(timezone.utc).isoformat() - - metadata = { - 'trace_id': trace_id, - 'source': 'user-service', - 'timestamp': now, - 'message_id': message_id, - } - - payload = { - 'email': user.email, - 'code': code, - 'ttl_seconds': TTL, - } - - message = { - 'event': 'encrypted_mnemonic_set', - 'payload': payload, - 'metadata': metadata, - } - - self._logger.info(f'Encrypted mnemonic set code created for user_id={user_id}') - - try: - await self._messanger.publish_to_queue( - queue=settings.RABBIT_EMAIL_CODE_QUEUE, - message=message, - persist=True, - correlation_id=trace_id, - message_id=message_id, - headers={'trace_id': trace_id} if trace_id else None, - ) - except Exception as exception: - try: - await self._cache.delete(user_key) - await self._cache.delete(code_key) - except Exception as rollback_err: - self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') - - self._logger.error(f'Failed to publish encrypted mnemonic set email for user_id={user_id}: {str(exception)}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - return True - - self._logger.error(f'Encrypted mnemonic set failed: code space exhausted for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - finally: - await self._cache.delete(lock_key) diff --git a/src/application/commands/set_phone.py b/src/application/commands/set_phone.py deleted file mode 100644 index 6537533..0000000 --- a/src/application/commands/set_phone.py +++ /dev/null @@ -1,18 +0,0 @@ -from src.application.abstractions import IUnitOfWork -from src.application.contracts import ILogger, ICache -from src.application.domain.entities import UserEntity -from src.infrastructure.database.decorators import transactional - - -class SetPhoneCommand: - def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache): - self._unit_of_work = unit_of_work - self._logger = logger - self._cache = cache - - @transactional - async def __call__(self, user_id: str, phone: str) -> UserEntity: - user = await self._unit_of_work.user_repository.set_phone(user_id=user_id, phone=phone) - await self._cache.set_user(user_id, user) - self._logger.info(f'Set phone for user {user_id}') - return user \ No newline at end of file diff --git a/src/application/commands/update_bank_details_complete.py b/src/application/commands/update_bank_details_complete.py deleted file mode 100644 index 1c5ba8a..0000000 --- a/src/application/commands/update_bank_details_complete.py +++ /dev/null @@ -1,76 +0,0 @@ -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ILogger, ICache -from src.application.domain.entities import UserEntity -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.database.decorators import transactional - - -class UpdateBankDetailsCompleteCommand: - def __init__( - self, - unit_of_work: IUnitOfWork, - hash_service: IHashService, - cache: ICache, - logger: ILogger, - ): - self._unit_of_work = unit_of_work - self._hash_service = hash_service - self._cache = cache - self._logger = logger - - @transactional - async def __call__( - self, - *, - user_id: str, - code: str, - passport_data: str | None = None, - inn: str | None = None, - erc20: str | None = None, - ) -> UserEntity: - code = (code or '').strip() - - USER_PREFIX = 'bank_details:user:' - CODE_PREFIX = 'bank_details:code:' - - user_key = f'{USER_PREFIX}{user_id}' - code_key = f'{CODE_PREFIX}{code}' - - cached_user_id = await self._cache.get(code_key) - if not cached_user_id: - self._logger.info(f'Bank details update failed: code not found (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - if cached_user_id != user_id: - self._logger.info(f'Bank details update failed: code-user mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - code_hash = await self._cache.get(user_key) - if not code_hash: - self._logger.info(f'Bank details update failed: user key missing (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) - if not ok: - self._logger.info(f'Bank details update failed: code hash mismatch (user_id={user_id})') - raise ApplicationException(400, 'Invalid or expired code') - - fields = {} - if passport_data is not None: - fields['passport_data'] = passport_data - if inn is not None: - fields['inn'] = inn - if erc20 is not None: - fields['erc20'] = erc20 - - user = await self._unit_of_work.user_repository.set_bank_details(user_id, **fields) - await self._cache.set_user(user_id, user) - - try: - await self._cache.delete(code_key) - await self._cache.delete(user_key) - except Exception as e: - self._logger.warning(f'Bank details update cleanup failed (user_id={user_id}): {e}') - - self._logger.info(f'Bank details updated for user_id={user_id}, fields={list(fields.keys())}') - return user diff --git a/src/application/commands/update_bank_details_start.py b/src/application/commands/update_bank_details_start.py deleted file mode 100644 index f3d8148..0000000 --- a/src/application/commands/update_bank_details_start.py +++ /dev/null @@ -1,126 +0,0 @@ -import secrets -from datetime import datetime, timezone -from ulid import ULID -from src.application.abstractions import IUnitOfWork -from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.config import settings -from src.infrastructure.context_vars import trace_id_var -from src.infrastructure.database.decorators import transactional - - -class UpdateBankDetailsStartCommand: - def __init__( - self, - hash_service: IHashService, - cache: ICache, - unit_of_work: IUnitOfWork, - logger: ILogger, - messanger: IQueueMessanger, - ): - self._hash_service = hash_service - self._unit_of_work = unit_of_work - self._cache = cache - self._logger = logger - self._messanger = messanger - - @transactional - async def __call__(self, user_id: str) -> bool: - TTL = 300 - LOCK_TTL = 30 - MAX_ATTEMPTS = 20 - - USER_PREFIX = 'bank_details:user:' - CODE_PREFIX = 'bank_details:code:' - LOCK_PREFIX = 'bank_details:lock:' - - user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) - - if not user.email: - self._logger.warning(f'User {user_id} does not have an email address') - raise ApplicationException(status_code=404, message=f'User {user_id} does not have an email address') - - trace_id = trace_id_var.get() - if not trace_id or trace_id == 'N/A': - trace_id = None - - lock_key = f'{LOCK_PREFIX}{user_id}' - locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) - if not locked: - self._logger.info(f'Bank details update throttled by lock (user_id={user_id})') - raise ApplicationException(429, 'Too many requests. Please wait.') - - try: - user_key = f'{USER_PREFIX}{user_id}' - - existing = await self._cache.get(user_key) - if existing: - self._logger.info(f'Bank details update denied: code already exists for user_id={user_id}') - raise ApplicationException(429, 'Code already sent. Please wait before retrying.') - - for _ in range(MAX_ATTEMPTS): - code = f'{secrets.randbelow(1_000_000):06d}' - code_key = f'{CODE_PREFIX}{code}' - - code_hash = await self._hash_service.hash(code) - - reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) - if not reserved: - continue - - saved = await self._cache.set(user_key, code_hash, ttl=TTL) - if not saved: - await self._cache.delete(code_key) - self._logger.error(f'Bank details update failed: cannot save code hash for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - message_id = str(ULID()) - now = datetime.now(timezone.utc).isoformat() - - metadata = { - 'trace_id': trace_id, - 'source': 'user-service', - 'timestamp': now, - 'message_id': message_id, - } - - payload = { - 'email': user.email, - 'code': code, - 'ttl_seconds': TTL, - } - - message = { - 'event': 'bank_details_update', - 'payload': payload, - 'metadata': metadata, - } - - self._logger.info(f'Bank details update code created for user_id={user_id}') - - try: - await self._messanger.publish_to_queue( - queue=settings.RABBIT_EMAIL_CODE_QUEUE, - message=message, - persist=True, - correlation_id=trace_id, - message_id=message_id, - headers={'trace_id': trace_id} if trace_id else None, - ) - except Exception as exception: - try: - await self._cache.delete(user_key) - await self._cache.delete(code_key) - except Exception as rollback_err: - self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') - - self._logger.error(f'Failed to publish bank details update email for user_id={user_id}: {str(exception)}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - return True - - self._logger.error(f'Bank details update failed: code space exhausted for user_id={user_id}') - raise ApplicationException(503, 'Temporary error. Please try again.') - - finally: - await self._cache.delete(lock_key) diff --git a/src/application/contracts/__init__.py b/src/application/contracts/__init__.py index 6dd2d4b..e0bc299 100644 --- a/src/application/contracts/__init__.py +++ b/src/application/contracts/__init__.py @@ -1,7 +1,3 @@ from src.application.contracts.i_logger import ILogger from src.application.contracts.i_jwt_service import IJwtService from src.application.contracts.i_csrf_service import ICsrfService -from src.application.contracts.i_cache import ICache -from src.application.contracts.i_hash_service import IHashService -from src.application.contracts.i_queue_messanger import IQueueMessanger -from src.application.contracts.i_s3 import IS3 diff --git a/src/application/contracts/i_cache.py b/src/application/contracts/i_cache.py deleted file mode 100644 index 9627ad8..0000000 --- a/src/application/contracts/i_cache.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations -from abc import ABC, abstractmethod -from src.application.domain.entities.user import UserEntity - - -class ICache(ABC): - - @abstractmethod - async def set(self, key: str, value: str, ttl: int) -> bool: - raise NotImplementedError - - @abstractmethod - async def set_nx(self, key: str, value: str, ttl: int) -> bool: - raise NotImplementedError - - @abstractmethod - async def get(self, key: str) -> str | None: - raise NotImplementedError - - @abstractmethod - async def delete(self, key: str) -> bool: - raise NotImplementedError - - @abstractmethod - async def get_user(self, user_id: str) -> dict | None: - raise NotImplementedError - - @abstractmethod - async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None: - raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_hash_service.py b/src/application/contracts/i_hash_service.py deleted file mode 100644 index 438090f..0000000 --- a/src/application/contracts/i_hash_service.py +++ /dev/null @@ -1,12 +0,0 @@ -from abc import ABC, abstractmethod - - -class IHashService(ABC): - - @abstractmethod - async def hash(self, value: str) -> str: - raise NotImplementedError - - @abstractmethod - async def verify(self, hashed_value: str, plain_value: str) -> bool: - raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_queue_messanger.py b/src/application/contracts/i_queue_messanger.py deleted file mode 100644 index ab7be50..0000000 --- a/src/application/contracts/i_queue_messanger.py +++ /dev/null @@ -1,40 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Mapping, Any - - -class IQueueMessanger(ABC): - - @abstractmethod - async def connect(self) -> None: - raise NotImplementedError - - @abstractmethod - async def close(self) -> None: - raise NotImplementedError - - @abstractmethod - async def publish_to_queue( - self, - queue: str, - message: Any, - *, - persist: bool = True, - headers: Mapping[str, Any] | None = None, - correlation_id: str | None = None, - message_id: str | None = None, - ) -> None: - raise NotImplementedError - - @abstractmethod - async def publish( - self, - message: Any, - *, - exchange: str, - routing_key: str, - persist: bool = True, - headers: Mapping[str, Any] | None = None, - correlation_id: str | None = None, - message_id: str | None = None, - ) -> None: - raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_s3.py b/src/application/contracts/i_s3.py deleted file mode 100644 index 8164896..0000000 --- a/src/application/contracts/i_s3.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import Protocol, runtime_checkable - - -@runtime_checkable -class IS3(Protocol): - async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str: - ... - - - async def delete_object(self, *, key: str) -> None: - ... - - - def object_key_from_public_url(self, url: str) -> str | None: - ... diff --git a/src/application/domain/password_policy.py b/src/application/domain/password_policy.py deleted file mode 100644 index 169072f..0000000 --- a/src/application/domain/password_policy.py +++ /dev/null @@ -1,21 +0,0 @@ -import re - -SPECIAL_CHARS = '!@#$%^&*()_+-=.,:;?/[]{}<>' - - -def validate_password_strength(password: str) -> str: - if re.search(r'\s', password): - raise ValueError('Password must not contain whitespace') - if len(password) < 12: - raise ValueError('Password must be at least 12 characters') - if not re.search(r'[a-z]', password): - raise ValueError('Password must contain at least one lowercase letter') - if not re.search(r'[A-Z]', password): - raise ValueError('Password must contain at least one uppercase letter') - if not re.search(r'\d', password): - raise ValueError('Password must contain at least one digit') - if not any(c in SPECIAL_CHARS for c in password): - raise ValueError( - 'Password must contain at least one special character from: !@#$%^&*()_+-=.,:;?/[]{}<>' - ) - return password diff --git a/src/infrastructure/cache/__init__.py b/src/infrastructure/cache/__init__.py deleted file mode 100644 index 552c4f5..0000000 --- a/src/infrastructure/cache/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from src.infrastructure.cache.client import create_redis_client -from src.infrastructure.cache.keydb_client import KeydbCache \ No newline at end of file diff --git a/src/infrastructure/cache/client.py b/src/infrastructure/cache/client.py deleted file mode 100644 index 4c8b59a..0000000 --- a/src/infrastructure/cache/client.py +++ /dev/null @@ -1,16 +0,0 @@ -import redis.asyncio as redis -from redis.asyncio.client import Redis -from src.infrastructure.config import settings - - -def create_redis_client() -> Redis: - return redis.from_url( - settings.REDIS_URL, - max_connections=50, - decode_responses=True, - socket_timeout=5, - socket_connect_timeout=5, - health_check_interval=30, - retry_on_timeout=True, - socket_keepalive=True, - ) \ No newline at end of file diff --git a/src/infrastructure/cache/keydb_client.py b/src/infrastructure/cache/keydb_client.py deleted file mode 100644 index c6986ad..0000000 --- a/src/infrastructure/cache/keydb_client.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations -import orjson -from redis.asyncio.client import Redis -from src.application.contracts import ICache -from src.application.domain.entities.user import UserEntity - - -class KeydbCache(ICache): - USER_PREFIX = 'user:me' - - def __init__(self, redis_client: Redis): - self._r = redis_client - - async def set(self, key: str, value: str, ttl: int) -> bool: - return bool(await self._r.set(key, value, ex=ttl)) - - async def set_nx(self, key: str, value: str, ttl: int) -> bool: - return bool(await self._r.set(key, value, ex=ttl, nx=True)) - - async def get(self, key: str) -> str | None: - return await self._r.get(key) - - async def delete(self, key: str) -> bool: - return (await self._r.delete(key)) > 0 - - async def get_user(self, user_id: str) -> dict | None: - raw = await self._r.get(f'{self.USER_PREFIX}:{user_id}') - if raw is None: - return None - return orjson.loads(raw) - - async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None: - data = orjson.dumps({ - 'id': user.id, - 'email': user.email, - 'first_name': user.first_name, - 'middle_name': user.middle_name, - 'last_name': user.last_name, - 'birth_date': str(user.birth_date) if user.birth_date else None, - 'encrypted_mnemonic': user.encrypted_mnemonic, - 'phone': user.phone, - 'passport_data': user.passport_data, - 'inn': user.inn, - 'erc20': user.erc20, - 'avatar_link': user.avatar_link, - 'kyc_verified': user.kyc_verified, - 'is_deleted': user.is_deleted, - 'created_at': user.created_at.isoformat() if user.created_at else None, - 'updated_at': user.updated_at.isoformat() if user.updated_at else None, - 'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None, - }) - await self._r.set(f'{self.USER_PREFIX}:{user_id}', data, ex=ttl) diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py index a1de366..1d05c0c 100644 --- a/src/infrastructure/config/settings.py +++ b/src/infrastructure/config/settings.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import lru_cache -from typing import Any, List, Literal, Mapping +from typing import Any, List, Literal from urllib.parse import quote from dotenv import find_dotenv, load_dotenv @@ -34,13 +34,11 @@ class Settings(BaseSettings): VAULT_NAMESPACE: str | None = None VAULT_MOUNT_POINT: str = 'dev-secrets' VAULT_DATABASE_SECRET_PATH: str = 'database' - VAULT_RABBIT_SECRET_PATH: str = 'rabbitmq' VAULT_CSRF_SECRET_PATH: str = 'csrf' VAULT_DOCS_SECRET_PATH: str = 'docs' VAULT_JWT_KID_PATH: str = 'jwt/kid' VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids' VAULT_CRYPTO_MASTER_KEY_PATH: str = 'crypto' - VAULT_S3_SECRET_PATH: str = 's3/avatars' DATABASE_URL_DIRECT: str | None = Field(default=None, validation_alias='DATABASE_URL') DATABASE_HOST: str = '' @@ -76,30 +74,6 @@ class Settings(BaseSettings): JWT_ALGORITHM: str = 'RS256' JWT_KEYS_REFRESH_SECONDS: int = 3600 - REDIS_HOST: str = 'keydb' - REDIS_PORT: int = 6379 - REDIS_PASSWORD: str | None = None - REDIS_DB: int = 0 - - RABBIT_HOST: str = 'localhost' - RABBIT_PORT: int = 5672 - RABBIT_USER: str = 'guest' - RABBIT_PASSWORD: str = 'guest' - RABBIT_VHOST: str = '/' - - RABBIT_PUBLISH_PERSIST: bool = True - RABBIT_CONNECT_TIMEOUT: int = 5 - RABBIT_EMAIL_CODE_QUEUE: str = 'email.verification_code' - - S3_BUCKET: str = '' - S3_REGION: str = 'us-east-1' - S3_ACCESS_KEY_ID: str = '' - S3_SECRET_ACCESS_KEY: str = '' - S3_ENDPOINT_URL: str = '' - S3_PUBLIC_BASE_URL: str = '' - S3_REGRU_PUBLIC_WEBSITE_HOST: bool = False - S3_AVATAR_KEY_PREFIX: str = 'avatars' - LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO' LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON' @@ -110,78 +84,7 @@ class Settings(BaseSettings): return str(value) return '' - def _reset_s3_config(self) -> None: - object.__setattr__(self, 'S3_BUCKET', '') - object.__setattr__(self, 'S3_ACCESS_KEY_ID', '') - object.__setattr__(self, 'S3_SECRET_ACCESS_KEY', '') - object.__setattr__(self, 'S3_ENDPOINT_URL', '') - object.__setattr__(self, 'S3_PUBLIC_BASE_URL', '') - object.__setattr__(self, 'S3_REGION', 'us-east-1') - object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', False) - object.__setattr__(self, 'S3_AVATAR_KEY_PREFIX', 'avatars') - - @staticmethod - def _vault_kv(mapping: Mapping[str, Any], *keys: str) -> Any: - for k in keys: - if k in mapping and mapping[k] is not None: - return mapping[k] - return None - - def _apply_s3_from_vault_secret(self, s3: dict[str, Any]) -> None: - bucket_name = ( - self._vault_kv(s3, 'bucket_name', 'BUCKET_NAME', 'bucket') - or self._vault_kv(s3, 'S3_BUCKET', 'bucketName') - ) - endpoint_url = ( - self._vault_kv(s3, 's3_endpoint_url', 'S3_ENDPOINT_URL', 'endpoint_url', 'ENDPOINT_URL') - or self._vault_kv(s3, 'endpoint') - ) - ak = ( - self._vault_kv(s3, 's3_access_key_id', 'S3_ACCESS_KEY_ID', 'ACCESS_KEY_ID', 'access_key_id') - or self._vault_kv(s3, 'AWS_ACCESS_KEY_ID') - ) - sk = ( - self._vault_kv(s3, 's3_secret_access_key', 'S3_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY') - or self._vault_kv(s3, 'AWS_SECRET_ACCESS_KEY') - ) - if bucket_name is None or str(bucket_name).strip() == '': - raise ValueError('Vault S3 secret must contain bucket_name') - if endpoint_url is None or str(endpoint_url).strip() == '': - raise ValueError('Vault S3 secret must contain s3_endpoint_url') - if ak is None or str(ak).strip() == '': - raise ValueError('Vault S3 secret must contain s3_access_key_id') - if sk is None or str(sk).strip() == '': - raise ValueError('Vault S3 secret must contain s3_secret_access_key') - object.__setattr__(self, 'S3_BUCKET', str(bucket_name).strip()) - object.__setattr__(self, 'S3_ENDPOINT_URL', str(endpoint_url).strip()) - object.__setattr__(self, 'S3_ACCESS_KEY_ID', str(ak).strip()) - object.__setattr__(self, 'S3_SECRET_ACCESS_KEY', str(sk).strip()) - region = ( - self._vault_kv(s3, 's3_region', 'S3_REGION', 'region') - ) - if region is not None and str(region).strip() != '': - object.__setattr__(self, 'S3_REGION', str(region).strip()) - public_base = ( - self._vault_kv(s3, 's3_public_base_url', 'S3_PUBLIC_BASE_URL', 'public_base_url') - or self._vault_kv(s3, 'public_url') - ) - if public_base is not None and str(public_base).strip() != '': - object.__setattr__(self, 'S3_PUBLIC_BASE_URL', str(public_base).strip()) - prefix = self._vault_kv(s3, 'avatar_key_prefix', 'S3_AVATAR_KEY_PREFIX', 's3_avatar_key_prefix') - if prefix is not None and str(prefix).strip() != '': - object.__setattr__(self, 'S3_AVATAR_KEY_PREFIX', str(prefix).strip()) - rf = ( - self._vault_kv(s3, 's3_reg_ru_public_website_host', 'S3_REGRU_PUBLIC_WEBSITE_HOST') - ) - if rf is not None: - v = str(rf).strip().lower() - if v in {'1', 'true', 'yes', 'on'}: - object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', True) - elif v in {'0', 'false', 'no', 'off'}: - object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', False) - def model_post_init(self, __context: Any) -> None: - self._reset_s3_config() if not self.VAULT_ROLE_ID.strip() or not self.VAULT_SECRET_ID.strip(): if not self.DATABASE_URL: raise ValueError( @@ -218,19 +121,6 @@ class Settings(BaseSettings): if kv(db, 'PASSWORD', 'password') is not None: object.__setattr__(self, 'DATABASE_PASSWORD', str(kv(db, 'PASSWORD', 'password'))) - rabbit = client.read_secret_optional(self.VAULT_RABBIT_SECRET_PATH) - if rabbit: - if kv(rabbit, 'HOST', 'host') is not None: - object.__setattr__(self, 'RABBIT_HOST', str(kv(rabbit, 'HOST', 'host'))) - if kv(rabbit, 'PORT', 'port') is not None: - object.__setattr__(self, 'RABBIT_PORT', _as_int(kv(rabbit, 'PORT', 'port'), self.RABBIT_PORT)) - if kv(rabbit, 'USER', 'user') is not None: - object.__setattr__(self, 'RABBIT_USER', str(kv(rabbit, 'USER', 'user'))) - if kv(rabbit, 'PASSWORD', 'password') is not None: - object.__setattr__(self, 'RABBIT_PASSWORD', str(kv(rabbit, 'PASSWORD', 'password'))) - if kv(rabbit, 'VHOST', 'vhost') is not None: - object.__setattr__(self, 'RABBIT_VHOST', str(kv(rabbit, 'VHOST', 'vhost'))) - csrf = client.read_secret_optional(self.VAULT_CSRF_SECRET_PATH) if csrf and kv(csrf, 'KEY', 'key') is not None: key = str(kv(csrf, 'KEY', 'key')) @@ -246,12 +136,6 @@ class Settings(BaseSettings): if p is not None: object.__setattr__(self, 'DOCS_PASSWORD', str(p)) - s3_rel_path = self.VAULT_S3_SECRET_PATH.strip() - if s3_rel_path: - s3_secret_data = client.read_secret_optional(s3_rel_path) - if s3_secret_data: - self._apply_s3_from_vault_secret(s3_secret_data) - if not self.DATABASE_URL: raise ValueError('Database URL could not be built from Vault database secret') @@ -289,16 +173,6 @@ class Settings(BaseSettings): quoted_password = quote(password, safe='') return f'postgresql+asyncpg://{quoted_user}:{quoted_password}@{host}:{port}/{name}' - @property - def REDIS_URL(self) -> str: - auth = f':{self.REDIS_PASSWORD}@' if self.REDIS_PASSWORD else '' - return f'redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' - - @property - def RABBIT_URL(self) -> str: - vhost = '%2F' if self.RABBIT_VHOST == '/' else self.RABBIT_VHOST.lstrip('/') - return f'amqp://{self.RABBIT_USER}:{self.RABBIT_PASSWORD}@{self.RABBIT_HOST}:{self.RABBIT_PORT}/{vhost}' - @property def EXCLUDED_PATHS(self) -> List[str]: return ['/docs', '/redoc', '/openapi.json', '/ping', '/health'] diff --git a/src/infrastructure/media/webp.py b/src/infrastructure/media/webp.py deleted file mode 100644 index 64002f7..0000000 --- a/src/infrastructure/media/webp.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from io import BytesIO - -from PIL import Image - - -def image_bytes_to_webp(raw: bytes, *, quality: int = 82) -> bytes: - im = Image.open(BytesIO(raw)) - if im.mode == 'P': - im = im.convert('RGBA') - elif im.mode == 'LA': - im = im.convert('RGBA') - elif im.mode not in ('RGBA', 'RGB'): - im = im.convert('RGB') - out = BytesIO() - im.save(out, format='WEBP', quality=quality) - return out.getvalue() diff --git a/src/infrastructure/messanger/__init__.py b/src/infrastructure/messanger/__init__.py deleted file mode 100644 index 0369f8a..0000000 --- a/src/infrastructure/messanger/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from src.infrastructure.messanger.rabbit_client import RabbitClient \ No newline at end of file diff --git a/src/infrastructure/messanger/rabbit_client.py b/src/infrastructure/messanger/rabbit_client.py deleted file mode 100644 index d18db3f..0000000 --- a/src/infrastructure/messanger/rabbit_client.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import Any, Mapping -from faststream.rabbit import RabbitBroker -from src.application.contracts import IQueueMessanger -from src.infrastructure.config import settings - - -class RabbitClient(IQueueMessanger): - def __init__(self) -> None: - self._broker = RabbitBroker( - settings.RABBIT_URL, - ) - self._connected = False - - async def connect(self) -> None: - if self._connected: - return - await self._broker.connect() - self._connected = True - - async def close(self) -> None: - if not self._connected: - return - await self._broker.close() - self._connected = False - - async def _ensure_connected(self) -> None: - if not self._connected: - await self.connect() - - async def publish_to_queue( - self, - queue: str, - message: Any, - *, - persist: bool | None = None, - headers: Mapping[str, Any] | None = None, - correlation_id: str | None = None, - message_id: str | None = None, - ) -> None: - await self._ensure_connected() - - await self._broker.publish( - message, - queue=queue, - persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist, - headers=headers, - correlation_id=correlation_id, - message_id=message_id, - ) - - async def publish( - self, - message: Any, - *, - exchange: str, - routing_key: str, - persist: bool | None = None, - headers: Mapping[str, Any] | None = None, - correlation_id: str | None = None, - message_id: str | None = None, - ) -> None: - await self._ensure_connected() - - await self._broker.publish( - message, - exchange=exchange, - routing_key=routing_key, - persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist, - headers=headers, - correlation_id=correlation_id, - message_id=message_id, - ) diff --git a/src/infrastructure/security/hash.py b/src/infrastructure/security/hash.py deleted file mode 100644 index 94d92b8..0000000 --- a/src/infrastructure/security/hash.py +++ /dev/null @@ -1,17 +0,0 @@ -import bcrypt -from src.application.contracts import IHashService, ILogger - - -class HashService(IHashService): - - def __init__(self, logger: ILogger): - self._logger = logger - - async def hash(self, value: str) -> str: - hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt()) - self._logger.info(f'Hash value {hashed_value.decode()}') - return hashed_value.decode() - - async def verify(self, hashed_value: str, plain_value: str) -> bool: - self._logger.info(f'Hash value {hashed_value[:10]}') - return bcrypt.checkpw(plain_value.encode(), hashed_value.encode()) \ No newline at end of file diff --git a/src/infrastructure/storage/s3_service.py b/src/infrastructure/storage/s3_service.py deleted file mode 100644 index 62780a1..0000000 --- a/src/infrastructure/storage/s3_service.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -from aiobotocore.session import get_session - - -class S3Service: - def __init__( - self, - *, - bucket: str, - region: str, - access_key_id: str | None, - secret_access_key: str | None, - public_base_url: str | None, - endpoint_url: str | None, - use_reg_ru_website_public_host: bool, - ): - self._bucket = bucket - self._region = region or 'us-east-1' - self._access_key_id = access_key_id - self._secret_access_key = secret_access_key - pb = (public_base_url or '').strip().rstrip('/') - self._public_base_url = pb if pb else None - self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None - self._use_reg_ru_website_public_host = use_reg_ru_website_public_host - - @staticmethod - def _url_prefix_variants(prefix: str) -> list[str]: - p = prefix.rstrip('/') + '/' - out = [p] - if p.startswith('https://'): - out.append('http://' + p[8:]) - elif p.startswith('http://'): - out.append('https://' + p[7:]) - return out - - def _public_url_prefixes(self) -> list[str]: - acc: list[str] = [] - pb = self._public_base_url - if pb: - acc.extend(self._url_prefix_variants(pb)) - ep = self._endpoint_url - if ep: - base = f'{ep.rstrip("/")}/{self._bucket}' - acc.extend(self._url_prefix_variants(base)) - if ep and self._use_reg_ru_website_public_host and 's3.regru.cloud' in ep.lower(): - wh = f'https://{self._bucket}.website.regru.cloud' - acc.extend(self._url_prefix_variants(wh)) - if not ep: - if self._region == 'us-east-1': - h = f'https://{self._bucket}.s3.amazonaws.com' - else: - h = f'https://{self._bucket}.s3.{self._region}.amazonaws.com' - acc.extend(self._url_prefix_variants(h)) - seen: set[str] = set() - uniq: list[str] = [] - for x in sorted(acc, key=len, reverse=True): - if x not in seen: - seen.add(x) - uniq.append(x) - return uniq - - def object_key_from_public_url(self, url: str) -> str | None: - u = (url or '').strip() - if not u: - return None - for p in self._public_url_prefixes(): - if u.startswith(p): - k = u[len(p):].split('?', 1)[0].split('#', 1)[0] - return k if k else None - return None - - def _object_url(self, key: str) -> str: - if self._public_base_url: - return f'{self._public_base_url}/{key}' - endpoint = self._endpoint_url - if endpoint: - if ( - self._use_reg_ru_website_public_host - and 's3.regru.cloud' in endpoint.lower() - ): - return f'https://{self._bucket}.website.regru.cloud/{key}' - return f'{endpoint}/{self._bucket}/{key}' - region = self._region - if region == 'us-east-1': - host = 's3.amazonaws.com' - else: - host = f's3.{region}.amazonaws.com' - return f'https://{self._bucket}.{host}/{key}' - - async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str: - session = get_session() - kw: dict[str, object] = {'region_name': self._region} - aid = self._access_key_id - sk = self._secret_access_key - ep = self._endpoint_url - if aid: - kw['aws_access_key_id'] = aid - if sk: - kw['aws_secret_access_key'] = sk - if ep: - kw['endpoint_url'] = ep - async with session.create_client('s3', **kw) as client: - await client.put_object( - Bucket=self._bucket, - Key=key, - Body=body, - ContentType=content_type, - ) - return self._object_url(key) - - async def delete_object(self, *, key: str) -> None: - session = get_session() - kw: dict[str, object] = {'region_name': self._region} - aid = self._access_key_id - sk = self._secret_access_key - ep = self._endpoint_url - if aid: - kw['aws_access_key_id'] = aid - if sk: - kw['aws_secret_access_key'] = sk - if ep: - kw['endpoint_url'] = ep - async with session.create_client('s3', **kw) as client: - await client.delete_object(Bucket=self._bucket, Key=key) diff --git a/src/main.py b/src/main.py index b730d07..3b1df65 100644 --- a/src/main.py +++ b/src/main.py @@ -10,7 +10,6 @@ from starlette.middleware.cors import CORSMiddleware from starlette.exceptions import HTTPException from fastapi.exceptions import RequestValidationError from src.application.domain.exceptions import ApplicationException, UnauthorizedException -from src.infrastructure.cache import create_redis_client from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler from src.infrastructure.utils import generate_instance_id from src.infrastructure.logger import logger @@ -46,8 +45,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.set_instance_id(instance_id) logger.info(f'B2B service instance started with id {instance_id}') - app.state.redis = create_redis_client() - if not settings.VAULT_ROLE_ID.strip() or not settings.VAULT_SECRET_ID.strip(): raise RuntimeError('VAULT_ROLE_ID and VAULT_SECRET_ID must be set') @@ -78,7 +75,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ) logger.info('Crypto master key loaded from Vault') yield - await app.state.redis.aclose() logger.info(f'B2B service instance ended with id {instance_id}') diff --git a/src/presentation/decorators/cache.py b/src/presentation/decorators/cache.py deleted file mode 100644 index a7cbdaf..0000000 --- a/src/presentation/decorators/cache.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations -import functools -from typing import Any, Awaitable, Callable -from fastapi import Request -from fastapi.responses import ORJSONResponse -from src.infrastructure.cache import KeydbCache -from src.infrastructure.logger import get_logger -from src.presentation.dependencies.cache import get_redis - - -def cached(*, prefix: str) -> Callable: - - def decorator(func: Callable[..., Awaitable[Any]]): - @functools.wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - logger = get_logger() - - request = kwargs.get('request') - if not isinstance(request, Request): - for a in args: - if isinstance(a, Request): - request = a - break - - auth = kwargs.get('auth') - user_id = getattr(auth, 'user_id', None) if auth else None - - if request is None or user_id is None: - return await func(*args, **kwargs) - - cache_key = f'{prefix}:{user_id}' - - try: - redis = get_redis(request) - cache = KeydbCache(redis) - hit = await cache.get_user(user_id) - if hit is not None: - logger.debug(f'Cache hit key={cache_key}') - return ORJSONResponse(status_code=200, content=hit) - except Exception as e: - logger.warning(f'Cache read failed key={cache_key} error={e}') - - return await func(*args, **kwargs) - - return wrapper - return decorator diff --git a/src/presentation/decorators/rate_limit.py b/src/presentation/decorators/rate_limit.py deleted file mode 100644 index 6ff0094..0000000 --- a/src/presentation/decorators/rate_limit.py +++ /dev/null @@ -1,171 +0,0 @@ -from __future__ import annotations -import functools -import inspect -import hashlib -from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtime_checkable -from fastapi import Request -from redis.asyncio.client import Redis -from src.application.contracts import ILogger -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.logger import get_logger -from src.presentation.dependencies import get_redis - - -def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request: - req = kwargs.get('request') - if isinstance(req, Request): - return req - for a in args: - if isinstance(a, Request): - return a - raise RuntimeError('rate_limit decorator requires fastapi.Request argument') - - -def _client_ip(request: Request) -> str: - xff = request.headers.get('x-forwarded-for') - if xff: - return xff.split(',')[0].strip() - if request.client: - return request.client.host - return 'unknown' - - -_LUA_INCR_EXPIRE_TTL = ''' -local key = KEYS[1] -local window = tonumber(ARGV[1]) - -local current = redis.call('INCR', key) -if current == 1 then - redis.call('EXPIRE', key, window) -end - -local ttl = redis.call('TTL', key) -return { current, ttl } -''' - - -Scope = Literal['ip', 'device', 'user', 'key'] - - -@runtime_checkable -class KeyBuilder1(Protocol): - def __call__(self, request: Request) -> str: ... - - -@runtime_checkable -class KeyBuilder3(Protocol): - def __call__(self, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: ... - - -KeyBuilder = KeyBuilder1 | KeyBuilder3 - - -def _call_key_builder(builder: KeyBuilder, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: - try: - sig = inspect.signature(builder) - if len(sig.parameters) >= 3: - return builder(request, args, kwargs) - return builder(request) - except Exception as e: - try: - return builder(request, args, kwargs) - except Exception: - raise e - -def _email_rl_key(request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: - - body = kwargs.get('body') - if body is None and args: - for a in args: - if hasattr(a, 'email'): - body = a - break - - email = (getattr(body, 'email', '') or '').strip().lower() - if not email: - email = _client_ip(request) - - digest = hashlib.sha256(email.encode('utf-8')).hexdigest()[:24] - return f'email:{digest}' - -def rate_limit( - *, - limit: int, - window_seconds: int, - scope: Scope = 'ip', - key_prefix: str = 'rl', - key_builder: Optional[KeyBuilder] = None, - fail_open: bool = True, -) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]: - - if limit <= 0: - raise ValueError('rate_limit: limit must be > 0') - if window_seconds <= 0: - raise ValueError('rate_limit: window_seconds must be > 0') - if scope == 'key' and not key_builder: - raise ValueError('rate_limit: scope="key" requires key_builder') - - def decorator(func: Callable[..., Awaitable[Any]]): - @functools.wraps(func) - async def wrapper(*args: Any, **kwargs: Any): - request = _find_request(args, kwargs) - logger: ILogger = get_logger() - - if scope == 'ip': - ident = _client_ip(request) - elif scope == 'device': - ident = request.cookies.get('device_id') or _client_ip(request) - elif scope == 'user': - user = getattr(request.state, 'user', None) - user_id = getattr(user, 'id', None) if user else None - ident = str(user_id) if user_id else _client_ip(request) - else: - try: - ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type] - except Exception as e: - logger.error(f'RateLimit key_builder failed error={str(e)}') - raise ApplicationException(500, 'Rate limiter key_builder failed') - - route = request.url.path - method = request.method - redis_key = f'{key_prefix}:{scope}:{method}:{route}:{ident}' - - logger.debug(f'RateLimit check key={redis_key} limit={limit} window={window_seconds}') - - try: - redis: Redis = get_redis(request) - - result = await redis.eval( - _LUA_INCR_EXPIRE_TTL, - 1, - redis_key, - str(window_seconds), - ) - - count = int(result[0]) - ttl_raw = int(result[1]) if result and len(result) > 1 else window_seconds - ttl = window_seconds if ttl_raw < 0 else ttl_raw - - except Exception as e: - logger.error(f'RateLimit redis failure key={redis_key} error={str(e)}') - - if fail_open: - logger.warning(f'RateLimit fail-open activated key={redis_key}') - return await func(*args, **kwargs) - - raise ApplicationException(503, 'Rate limiter unavailable') - - if count > limit: - retry_after = max(ttl, 0) - logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}') - raise ApplicationException( - status_code=429, - message='Too Many Requests', - headers={'Retry-After': str(retry_after)}, - ) - - logger.debug(f'RateLimit passed key={redis_key} count={count}') - return await func(*args, **kwargs) - - return wrapper - return decorator \ No newline at end of file diff --git a/src/presentation/dependencies/__init__.py b/src/presentation/dependencies/__init__.py index 425344a..afa2bf9 100644 --- a/src/presentation/dependencies/__init__.py +++ b/src/presentation/dependencies/__init__.py @@ -4,4 +4,3 @@ from src.presentation.dependencies.commands import ( get_list_purchase_requests_command, ) from src.presentation.dependencies.security import get_jwt_service -from src.presentation.dependencies.cache import get_redis, get_cache diff --git a/src/presentation/dependencies/cache.py b/src/presentation/dependencies/cache.py deleted file mode 100644 index fb4fc7a..0000000 --- a/src/presentation/dependencies/cache.py +++ /dev/null @@ -1,12 +0,0 @@ -from fastapi import Depends, Request -from redis.asyncio.client import Redis -from src.application.contracts import ICache -from src.infrastructure.cache import KeydbCache - - -def get_redis(request: Request) -> Redis: - return request.app.state.redis - - -def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache: - return KeydbCache(redis_client) diff --git a/src/presentation/dependencies/queue_messanger.py b/src/presentation/dependencies/queue_messanger.py deleted file mode 100644 index 87a2d3a..0000000 --- a/src/presentation/dependencies/queue_messanger.py +++ /dev/null @@ -1,8 +0,0 @@ -from functools import lru_cache -from src.application.contracts import IQueueMessanger -from src.infrastructure.messanger import RabbitClient - - -@lru_cache(maxsize=1) -def get_rabbit() -> IQueueMessanger: - return RabbitClient() diff --git a/src/presentation/dependencies/s3_storage.py b/src/presentation/dependencies/s3_storage.py deleted file mode 100644 index 5e62c1c..0000000 --- a/src/presentation/dependencies/s3_storage.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from src.application.contracts import IS3 -from src.application.domain.exceptions import ServiceUnavailableException -from src.infrastructure.config import settings -from src.infrastructure.storage.s3_service import S3Service - -_s3singleton: IS3 | None = None - - -def get_s3_storage() -> IS3: - global _s3singleton - if _s3singleton is not None: - return _s3singleton - if not settings.S3_BUCKET.strip(): - raise ServiceUnavailableException(message='S3 is not configured') - endpoint = settings.S3_ENDPOINT_URL.strip() - pub = settings.S3_PUBLIC_BASE_URL.strip() - if not pub and not endpoint: - raise ServiceUnavailableException(message='Set S3_ENDPOINT_URL (or S3_PUBLIC_BASE_URL for a custom CDN base)') - ak = settings.S3_ACCESS_KEY_ID.strip() or None - sk = settings.S3_SECRET_ACCESS_KEY.strip() or None - _s3singleton = S3Service( - bucket=settings.S3_BUCKET.strip(), - region=settings.S3_REGION.strip() or 'us-east-1', - access_key_id=ak, - secret_access_key=sk, - public_base_url=pub if pub else None, - endpoint_url=endpoint if endpoint else None, - use_reg_ru_website_public_host=settings.S3_REGRU_PUBLIC_WEBSITE_HOST, - ) - return _s3singleton diff --git a/src/presentation/dependencies/security.py b/src/presentation/dependencies/security.py index f8d2e3a..2581b04 100644 --- a/src/presentation/dependencies/security.py +++ b/src/presentation/dependencies/security.py @@ -1,22 +1,11 @@ from functools import lru_cache from fastapi import Depends -from src.application.contracts import IJwtService, ILogger, IHashService +from src.application.contracts import IJwtService, ILogger from src.infrastructure.security.jwt import JwtService from src.infrastructure.vault import JwtKeyStore from src.presentation.dependencies.logger import get_logger -@lru_cache(maxsize=1) -def _hash_service(logger: ILogger) -> IHashService: - from src.infrastructure.security.hash import HashService - - return HashService(logger=logger) - - -def get_hash_service(logger: ILogger = Depends(get_logger)) -> IHashService: - return _hash_service(logger) - - @lru_cache(maxsize=1) def _jwt_service(logger: ILogger) -> IJwtService: key_store = JwtKeyStore.get_instance() diff --git a/src/presentation/routing/account.py b/src/presentation/routing/account.py deleted file mode 100644 index 65fd7d2..0000000 --- a/src/presentation/routing/account.py +++ /dev/null @@ -1,55 +0,0 @@ -from fastapi import APIRouter, Depends, Request -from fastapi.responses import ORJSONResponse -from starlette import status -from src.application.commands.get_me import GetMeCommand -from src.application.contracts import ILogger -from src.application.domain.dto import AuthContext -from src.presentation.decorators import require_access_token -from src.presentation.dependencies.commands import get_get_me_command -from src.presentation.dependencies.logger import get_logger -from src.presentation.decorators import csrf_protect -from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload -from src.presentation.schemas.me_public import MeUserPublicResponse -from src.presentation.serializers import me_user_public - -account_router = APIRouter() - - -@account_router.get( - path='/', - response_class=ORJSONResponse, - status_code=status.HTTP_200_OK, - response_model=MeUserPublicResponse, - summary='Текущий пользователь', - description=( - 'Возвращает профиль, в том числе поле avatar_link — публичный HTTPS URL ' - 'текущего аватара (WebP в объектном хранилище), либо null, если аватар не задан. ' - 'Изображение по этому URL отдаёт не Users API, а хранилище; отдельного эндпоинта загрузки байтов аватара нет. ' - 'Загрузить или сменить аватар можно через PATCH /me/settings/avatar. ' - 'Защита CSRF: cookie csrf_token и заголовок X-CSRF-Token с тем же значением.' - ), - responses={ - status.HTTP_401_UNAUTHORIZED: { - 'description': 'Не передан или неверен access token.', - 'model': ApiErrorPayload, - }, - status.HTTP_403_FORBIDDEN: { - 'description': 'Ошибка проверки CSRF (нет пары cookie/заголовка, несовпадение или просрочен токен).', - 'model': ApiErrorPayload, - }, - status.HTTP_422_UNPROCESSABLE_ENTITY: { - 'description': 'Ошибка валидации входных данных (например, заголовков).', - 'model': ApiValidationErrorsPayload, - }, - }, -) -@csrf_protect() -async def me( - request: Request, - auth: AuthContext = Depends(require_access_token), - command: GetMeCommand = Depends(get_get_me_command), - logger: ILogger = Depends(get_logger), -) -> MeUserPublicResponse: - user = await command(user_id=auth.user_id) - logger.info(f'Get user: {user.id}') - return me_user_public(user) diff --git a/src/presentation/routing/account_settings.py b/src/presentation/routing/account_settings.py deleted file mode 100644 index 30abd28..0000000 --- a/src/presentation/routing/account_settings.py +++ /dev/null @@ -1,384 +0,0 @@ -from fastapi import APIRouter, Depends, Request -from fastapi.responses import ORJSONResponse -from starlette import status -from src.application.commands import ( - SetPhoneCommand, - SetAvatarCommand, - DeleteAvatarCommand, - ChangePasswordStartCommand, - ChangePasswordCompleteCommand, - ForgotPasswordStartCommand, - ForgotPasswordCompleteCommand, -) -from src.application.domain.dto import AuthContext -from src.presentation.decorators import require_access_token, csrf_protect, rate_limit, email_rl_key -from src.presentation.dependencies import ( - get_delete_avatar_command, - get_set_avatar_command, - get_set_phone_command, - get_change_password_start_command, - get_change_password_complete_command, - get_forgot_password_start_command, - get_forgot_password_complete_command, -) -from src.presentation.schemas import ( - SetAvatarRequest, - SetPhoneRequest, - ChangePasswordConfirmRequest, - ForgotPasswordStartRequest, - ForgotPasswordCompleteRequest, -) -from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload -from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse -from src.presentation.serializers import me_user_public - - -account_settings_router = APIRouter(prefix='/settings') - - -_SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = { - status.HTTP_400_BAD_REQUEST: { - 'description': 'Битый или неподдерживаемый формат изображения, либо Base64 ошибочный.', - 'model': ApiErrorPayload, - }, - status.HTTP_401_UNAUTHORIZED: { - 'description': 'Не передан или неверен access token.', - 'model': ApiErrorPayload, - }, - status.HTTP_404_NOT_FOUND: { - 'description': 'Учётная запись не найдена.', - 'model': ApiErrorPayload, - }, - status.HTTP_422_UNPROCESSABLE_ENTITY: { - 'description': 'Тело запроса не соответствует схеме (например, неверный Base64 или превышен размер).', - 'model': ApiValidationErrorsPayload, - }, - status.HTTP_500_INTERNAL_SERVER_ERROR: { - 'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.', - 'model': ApiErrorPayload, - }, - status.HTTP_503_SERVICE_UNAVAILABLE: { - 'description': 'S3 не сконфигурирован, ошибка записи в хранилище или временная недоступность сервиса.', - 'model': ApiErrorPayload, - }, -} - - -_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = { - status.HTTP_400_BAD_REQUEST: { - 'description': 'Неверный или просроченный код, пароли не совпадают или совпадают с текущим.', - 'model': ApiErrorPayload, - }, - status.HTTP_401_UNAUTHORIZED: { - 'description': 'Не передан или неверен access token.', - 'model': ApiErrorPayload, - }, - status.HTTP_404_NOT_FOUND: { - 'description': 'Учётная запись не найдена или у пользователя нет email.', - 'model': ApiErrorPayload, - }, - status.HTTP_422_UNPROCESSABLE_ENTITY: { - 'description': 'Тело запроса не соответствует схеме (код, длина пароля).', - 'model': ApiValidationErrorsPayload, - }, - status.HTTP_429_TOO_MANY_REQUESTS: { - 'description': 'Код уже отправлен или слишком частые запросы.', - 'model': ApiErrorPayload, - }, - status.HTTP_500_INTERNAL_SERVER_ERROR: { - 'description': 'Внутренняя ошибка сервера.', - 'model': ApiErrorPayload, - }, - status.HTTP_503_SERVICE_UNAVAILABLE: { - 'description': 'Временная ошибка отправки кода или сохранения в кеш.', - 'model': ApiErrorPayload, - }, -} - - -_FORGOT_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = { - status.HTTP_400_BAD_REQUEST: { - 'description': 'Неверный или просроченный код, пароли не совпадают.', - 'model': ApiErrorPayload, - }, - status.HTTP_422_UNPROCESSABLE_ENTITY: { - 'description': 'Тело запроса не соответствует схеме.', - 'model': ApiValidationErrorsPayload, - }, - status.HTTP_429_TOO_MANY_REQUESTS: { - 'description': 'Код уже отправлен или слишком частые запросы.', - 'model': ApiErrorPayload, - }, - status.HTTP_500_INTERNAL_SERVER_ERROR: { - 'description': 'Внутренняя ошибка сервера.', - 'model': ApiErrorPayload, - }, - status.HTTP_503_SERVICE_UNAVAILABLE: { - 'description': 'Временная ошибка отправки кода или сохранения в кеш.', - 'model': ApiErrorPayload, - }, -} - - -_DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = { - status.HTTP_401_UNAUTHORIZED: { - 'description': 'Не передан или неверен access token.', - 'model': ApiErrorPayload, - }, - status.HTTP_404_NOT_FOUND: { - 'description': 'Учётная запись не найдена.', - 'model': ApiErrorPayload, - }, - status.HTTP_500_INTERNAL_SERVER_ERROR: { - 'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.', - 'model': ApiErrorPayload, - }, - status.HTTP_503_SERVICE_UNAVAILABLE: { - 'description': 'S3 не сконфигурирован или временная недоступность удаления объекта.', - 'model': ApiErrorPayload, - }, -} - - -@account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -@csrf_protect() -async def set_phone( - request: Request, - body: SetPhoneRequest, - auth: AuthContext = Depends(require_access_token), - command: SetPhoneCommand = Depends(get_set_phone_command), -): - user = await command(user_id=auth.user_id, phone=body.phone) - return ORJSONResponse(status_code=status.HTTP_200_OK, content={'phone': user.phone}) - - -@account_settings_router.patch( - path='/avatar', - response_class=ORJSONResponse, - status_code=status.HTTP_200_OK, - response_model=SetAvatarPublicResponse, - summary='Обновить аватар', - description=( - 'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. ' - 'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).' - ), - response_description=( - 'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.' - ), - responses=_SET_AVATAR_ERROR_RESPONSES, -) -@csrf_protect() -async def set_avatar( - request: Request, - body: SetAvatarRequest, - auth: AuthContext = Depends(require_access_token), - command: SetAvatarCommand = Depends(get_set_avatar_command), -) -> SetAvatarPublicResponse: - user, webp_size = await command(user_id=auth.user_id, image_bytes=body.decoded_bytes) - pub = me_user_public(user) - return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size) - - -@account_settings_router.delete( - path='/avatar', - response_class=ORJSONResponse, - status_code=status.HTTP_200_OK, - response_model=MeUserPublicResponse, - summary='Удалить аватар', - description=( - 'Удаляет файл в объектном хранилище при известном URL и обнуляет avatar_link в профиле.' - ), - responses=_DELETE_AVATAR_ERROR_RESPONSES, -) -@csrf_protect() -async def delete_avatar( - request: Request, - auth: AuthContext = Depends(require_access_token), - command: DeleteAvatarCommand = Depends(get_delete_avatar_command), -) -> MeUserPublicResponse: - user = await command(user_id=auth.user_id) - return me_user_public(user) - - -@account_settings_router.post( - path='/password/start', - response_class=ORJSONResponse, - status_code=status.HTTP_200_OK, - summary='Запросить код для смены пароля', - description='Отправляет шестизначный код на email текущего пользователя. Повторный запрос возможен после истечения TTL.', - responses=_PASSWORD_ERROR_RESPONSES, -) -@csrf_protect() -async def change_password_start( - request: Request, - auth: AuthContext = Depends(require_access_token), - command: ChangePasswordStartCommand = Depends(get_change_password_start_command), -): - result = await command(user_id=auth.user_id) - return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) - - -@account_settings_router.post( - path='/password/complete', - response_class=ORJSONResponse, - status_code=status.HTTP_200_OK, - summary='Подтвердить смену пароля', - description=( - 'Принимает код из письма, новый пароль и его подтверждение. ' - 'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).' - ), - responses=_PASSWORD_ERROR_RESPONSES, -) -@csrf_protect() -async def change_password_complete( - request: Request, - body: ChangePasswordConfirmRequest, - auth: AuthContext = Depends(require_access_token), - command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command), -): - result = await command( - user_id=auth.user_id, - code=body.code, - new_password=body.new_password, - confirm_password=body.confirm_password, - ) - return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) - - -@account_settings_router.post( - path='/password/forgot/start', - response_class=ORJSONResponse, - status_code=status.HTTP_200_OK, - summary='Запросить код для восстановления пароля', - description=( - 'Принимает email. Если учётная запись существует, отправляет шестизначный код. ' - 'Ответ всегда успешный при валидном email (без раскрытия наличия аккаунта).' - ), - responses=_FORGOT_PASSWORD_ERROR_RESPONSES, -) -@rate_limit(limit=5, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key) -async def forgot_password_start( - request: Request, - body: ForgotPasswordStartRequest, - command: ForgotPasswordStartCommand = Depends(get_forgot_password_start_command), -): - result = await command(email=body.email) - return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) - - -@account_settings_router.post( - path='/password/forgot/complete', - response_class=ORJSONResponse, - status_code=status.HTTP_200_OK, - summary='Установить новый пароль по коду из письма', - description=( - 'Принимает email, код из письма, новый пароль и подтверждение. ' - 'Пароль: минимум 12 символов, строчная и заглавная буква, цифра, спецсимвол, без пробелов.' - ), - responses=_FORGOT_PASSWORD_ERROR_RESPONSES, -) -@rate_limit(limit=10, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key) -async def forgot_password_complete( - request: Request, - body: ForgotPasswordCompleteRequest, - command: ForgotPasswordCompleteCommand = Depends(get_forgot_password_complete_command), -): - result = await command( - email=body.email, - code=body.code, - new_password=body.new_password, - confirm_password=body.confirm_password, - ) - return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) - - -# -# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def encrypted_mnemonic_start( -# request: Request, -# auth: AuthContext = Depends(require_access_token), -# command: SetEncryptedMnemonicStartCommand = Depends(get_set_encrypted_mnemonic_start_command), -# ): -# result = await command(user_id=auth.user_id) -# return {'success': result} -# -# -# @account_settings_router.post(path='/encrypted-mnemonic/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def encrypted_mnemonic_complete( -# request: Request, -# body: EncryptedMnemonicConfirmRequest, -# auth: AuthContext = Depends(require_access_token), -# command: SetEncryptedMnemonicCompleteCommand = Depends(get_set_encrypted_mnemonic_complete_command), -# ): -# user = await command( -# user_id=auth.user_id, -# code=body.code, -# encrypted_mnemonic=body.encrypted_mnemonic, -# ) -# return {'encrypted_mnemonic': user.encrypted_mnemonic} -# -# -# @account_settings_router.post(path='/email/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def change_email_start( -# request: Request, -# auth: AuthContext = Depends(require_access_token), -# command: ChangeEmailStartCommand = Depends(get_change_email_start_command), -# ): -# result = await command(user_id=auth.user_id) -# return {'success': result} -# -# -# @account_settings_router.post(path='/email/confirm-old', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def change_email_confirm_old( -# request: Request, -# body: ChangeEmailConfirmOldRequest, -# auth: AuthContext = Depends(require_access_token), -# command: ChangeEmailConfirmOldCommand = Depends(get_change_email_confirm_old_command), -# ): -# result = await command(user_id=auth.user_id, code=body.code, new_email=body.new_email) -# return {'success': result} -# -# -# @account_settings_router.post(path='/email/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def change_email_complete( -# request: Request, -# body: ChangeEmailCompleteRequest, -# auth: AuthContext = Depends(require_access_token), -# command: ChangeEmailCompleteCommand = Depends(get_change_email_complete_command), -# ): -# result = await command(user_id=auth.user_id, code=body.code) -# return {'success': result} -# -# -# @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def bank_details_start( -# request: Request, -# auth: AuthContext = Depends(require_access_token), -# command: UpdateBankDetailsStartCommand = Depends(get_update_bank_details_start_command), -# ): -# result = await command(user_id=auth.user_id) -# return {'success': result} -# -# -# @account_settings_router.post(path='/bank/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def bank_details_complete( -# request: Request, -# body: BankConfirmRequest, -# auth: AuthContext = Depends(require_access_token), -# command: UpdateBankDetailsCompleteCommand = Depends(get_update_bank_details_complete_command), -# ): -# user = await command( -# user_id=auth.user_id, -# code=body.code, -# passport_data=body.passport_data, -# inn=body.inn, -# erc20=body.erc20, -# ) -# return ORJSONResponse( -# status_code=status.HTTP_200_OK, -# content={ -# 'passport_data': user.passport_data, -# 'inn': user.inn, -# 'erc20': user.erc20, -# }, -# ) diff --git a/src/presentation/routing/deals.py b/src/presentation/routing/deals.py deleted file mode 100644 index 1a77b6e..0000000 --- a/src/presentation/routing/deals.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import APIRouter - -deals_router = APIRouter(prefix='/deals') - -@deals_router.get(path='') -async def deals(): - pass diff --git a/src/presentation/routing/devices.py b/src/presentation/routing/devices.py deleted file mode 100644 index b2aa203..0000000 --- a/src/presentation/routing/devices.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi import APIRouter, Request -from fastapi.responses import ORJSONResponse -from starlette import status - -devices_router = APIRouter(prefix='/devices') - -@devices_router.get(path='/', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -async def devices( - request: Request, -): - pass diff --git a/src/presentation/routing/purchase_requests.py b/src/presentation/routing/purchase_requests.py index 2c2f1a2..573ec54 100644 --- a/src/presentation/routing/purchase_requests.py +++ b/src/presentation/routing/purchase_requests.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, Query, Request, status from src.application.commands import ( CreatePurchaseRequestCommand, @@ -7,6 +7,7 @@ from src.application.commands import ( ) from src.application.domain.dto import AuthContext from src.presentation.decorators.auth import require_access_token +from src.presentation.decorators.csrf import csrf_protect from src.presentation.dependencies.commands import ( get_create_purchase_request_command, get_get_purchase_request_command, @@ -23,7 +24,9 @@ purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchas @purchase_requests_router.post('', response_model=PurchaseRequestResponse, status_code=status.HTTP_201_CREATED) +@csrf_protect() async def create_purchase_request( + request: Request, body: CreatePurchaseRequestBody, auth: AuthContext = Depends(require_access_token), command: CreatePurchaseRequestCommand = Depends(get_create_purchase_request_command), @@ -39,7 +42,9 @@ async def create_purchase_request( @purchase_requests_router.get('', response_model=PurchaseRequestListResponse) +@csrf_protect() async def list_purchase_requests( + request: Request, status_filter: str | None = Query(default=None, alias='status'), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), @@ -59,7 +64,9 @@ async def list_purchase_requests( @purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse) +@csrf_protect() async def get_purchase_request( + request: Request, request_id: str, auth: AuthContext = Depends(require_access_token), command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command), diff --git a/src/presentation/schemas/__init__.py b/src/presentation/schemas/__init__.py index 3428adf..e69de29 100644 --- a/src/presentation/schemas/__init__.py +++ b/src/presentation/schemas/__init__.py @@ -1,10 +0,0 @@ -from src.presentation.schemas.avatar import SetAvatarRequest -from src.presentation.schemas.phone import SetPhoneRequest -from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest -from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest -from src.presentation.schemas.password import ( - ChangePasswordConfirmRequest, - ForgotPasswordStartRequest, - ForgotPasswordCompleteRequest, -) -from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest \ No newline at end of file diff --git a/src/presentation/schemas/api_errors.py b/src/presentation/schemas/api_errors.py deleted file mode 100644 index c8f6c16..0000000 --- a/src/presentation/schemas/api_errors.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel, Field - - -class ApiErrorPayload(BaseModel): - detail: str = Field(description='Текстовое описание ошибки для клиента') - - -class ValidationErrorDetailItem(BaseModel): - loc: list[str | int] = Field(description='Путь к полю, вызвавшему ошибку') - msg: str = Field(description='Сообщение') - type: str = Field(description='Тип ошибки валидации') - - -class ApiValidationErrorsPayload(BaseModel): - detail: list[ValidationErrorDetailItem] = Field( - description='Список ошибок валидации тела или параметров запроса' - ) diff --git a/src/presentation/schemas/avatar.py b/src/presentation/schemas/avatar.py deleted file mode 100644 index d1a95ad..0000000 --- a/src/presentation/schemas/avatar.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import base64 -import binascii - -from pydantic import BaseModel, Field, model_validator - - -AVATAR_MAX_BYTES = 10 * 1024 * 1024 - -_IMAGE_SIGNATURES = ( - b'\xff\xd8\xff', - b'\x89PNG\r\n\x1a\n', - b'GIF87a', - b'GIF89a', -) - - -def _avatar_payload_to_bytes(photo_base64: str) -> bytes: - s = photo_base64.strip() - if not s: - raise ValueError('photo_base64 must not be empty') - if s.startswith('data:'): - parts = s.split(',', 1) - if len(parts) != 2: - raise ValueError('Invalid data URL') - s = parts[1].strip() - try: - data = base64.b64decode(s, validate=True) - except binascii.Error as exc: - raise ValueError('Invalid base64') from exc - if len(data) > AVATAR_MAX_BYTES: - raise ValueError(f'Photo must not exceed {AVATAR_MAX_BYTES} bytes') - if len(data) < 12: - raise ValueError('Photo data is too small') - ok = False - for sig in _IMAGE_SIGNATURES: - if data.startswith(sig): - ok = True - break - if not ok and data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP': - ok = True - if not ok: - raise ValueError('Photo must be JPEG, PNG, GIF or WebP') - return data - - -class SetAvatarRequest(BaseModel): - photo_base64: str = Field( - ..., - description='Изображение JPEG, PNG, GIF или WebP в Base64; допустим data URL (data:image/...;base64,...). Максимум 10 МБ после декодирования.', - ) - decoded_bytes: bytes = Field(exclude=True) - - @model_validator(mode='before') - @classmethod - def _decode_input(cls, data: Any): - if not isinstance(data, dict): - return data - raw = data.get('photo_base64') - if raw is None: - return data - if not isinstance(raw, str): - raise ValueError('photo_base64 must be a string') - stripped = raw.strip() - decoded = _avatar_payload_to_bytes(stripped) - return {'photo_base64': stripped, 'decoded_bytes': decoded} diff --git a/src/presentation/schemas/bank.py b/src/presentation/schemas/bank.py deleted file mode 100644 index 9f2a0f3..0000000 --- a/src/presentation/schemas/bank.py +++ /dev/null @@ -1,116 +0,0 @@ -import re -from typing import Self -from pydantic import BaseModel, field_validator, model_validator - - -class BankUpdateRequest(BaseModel): - passport_data: str | None = None - inn: str | None = None - erc20: str | None = None - - @model_validator(mode='after') - def at_least_one(self) -> Self: - if not any([self.passport_data, self.inn, self.erc20]): - raise ValueError('At least one field is required') - return self - - @field_validator('passport_data', 'inn', 'erc20') - @classmethod - def strip_optional(cls, v: str | None) -> str | None: - if v is None: - return None - s = v.strip() - return s or None - - @field_validator('inn') - @classmethod - def validate_inn(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if not re.match(r'^\d{10}(\d{2})?$', v): - raise ValueError('INN must be 10 or 12 digits') - if len(v) > 12: - raise ValueError('INN is too long') - return v - - @field_validator('erc20') - @classmethod - def validate_erc20(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if not re.match(r'^0x[a-fA-F0-9]{40}$', v): - raise ValueError('ERC20 address must be 0x followed by 40 hex characters') - return v - - @field_validator('passport_data') - @classmethod - def validate_passport_data(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if len(v) > 255: - raise ValueError('Passport data is too long') - return v - - -class BankConfirmRequest(BaseModel): - code: str - passport_data: str | None = None - inn: str | None = None - erc20: str | None = None - - @model_validator(mode='after') - def at_least_one_field(self) -> Self: - if not any([self.passport_data, self.inn, self.erc20]): - raise ValueError('At least one field is required') - return self - - @field_validator('code') - @classmethod - def validate_code(cls, v: str) -> str: - v = v.strip() - if not re.match(r'^\d{6}$', v): - raise ValueError('Code must be exactly 6 digits') - return v - - @field_validator('passport_data', 'inn', 'erc20') - @classmethod - def strip_optional(cls, v: str | None) -> str | None: - if v is None: - return None - s = v.strip() - return s or None - - @field_validator('inn') - @classmethod - def validate_inn(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if not re.match(r'^\d{10}(\d{2})?$', v): - raise ValueError('INN must be 10 or 12 digits') - if len(v) > 12: - raise ValueError('INN is too long') - return v - - @field_validator('erc20') - @classmethod - def validate_erc20(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if not re.match(r'^0x[a-fA-F0-9]{40}$', v): - raise ValueError('ERC20 address must be 0x followed by 40 hex characters') - return v - - @field_validator('passport_data') - @classmethod - def validate_passport_data(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if len(v) > 255: - raise ValueError('Passport data is too long') - return v diff --git a/src/presentation/schemas/email.py b/src/presentation/schemas/email.py deleted file mode 100644 index 30e0874..0000000 --- a/src/presentation/schemas/email.py +++ /dev/null @@ -1,35 +0,0 @@ -import re -from pydantic import BaseModel, field_validator - - -class ChangeEmailConfirmOldRequest(BaseModel): - code: str - new_email: str - - @field_validator('code') - @classmethod - def validate_code(cls, v: str) -> str: - v = v.strip() - if not re.match(r'^\d{6}$', v): - raise ValueError('Code must be exactly 6 digits') - return v - - @field_validator('new_email') - @classmethod - def validate_new_email(cls, v: str) -> str: - v = v.strip().lower() - if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v): - raise ValueError('Invalid email address') - return v - - -class ChangeEmailCompleteRequest(BaseModel): - code: str - - @field_validator('code') - @classmethod - def validate_code(cls, v: str) -> str: - v = v.strip() - if not re.match(r'^\d{6}$', v): - raise ValueError('Code must be exactly 6 digits') - return v diff --git a/src/presentation/schemas/encrypted_mnemonic.py b/src/presentation/schemas/encrypted_mnemonic.py deleted file mode 100644 index 54a6049..0000000 --- a/src/presentation/schemas/encrypted_mnemonic.py +++ /dev/null @@ -1,23 +0,0 @@ -import re -from pydantic import BaseModel, field_validator - - -class EncryptedMnemonicConfirmRequest(BaseModel): - code: str - encrypted_mnemonic: str - - @field_validator('code') - @classmethod - def validate_code(cls, v: str) -> str: - v = v.strip() - if not re.match(r'^\d{6}$', v): - raise ValueError('Code must be exactly 6 digits') - return v - - @field_validator('encrypted_mnemonic') - @classmethod - def validate_encrypted_mnemonic(cls, v: str) -> str: - v = v.strip() - if not v: - raise ValueError('encrypted_mnemonic must not be empty') - return v diff --git a/src/presentation/schemas/me_public.py b/src/presentation/schemas/me_public.py deleted file mode 100644 index 07a6b58..0000000 --- a/src/presentation/schemas/me_public.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from datetime import date, datetime - -from pydantic import BaseModel, ConfigDict, Field - -from src.application.domain.entities import UserEntity - - -class MeUserPublicResponse(BaseModel): - model_config = ConfigDict(from_attributes=False) - - id: str | None = Field(None, description='Идентификатор пользователя') - email: str | None = Field(None, description='Email') - first_name: str | None = Field(None, description='Имя') - middle_name: str | None = Field(None, description='Отчество') - last_name: str | None = Field(None, description='Фамилия') - birth_date: date | None = Field(None, description='Дата рождения') - encrypted_mnemonic: str | None = Field(None, description='Шифрованная мнемоника') - phone: str | None = Field(None, description='Телефон') - passport_data: str | None = Field(None, description='Паспортные данные') - inn: str | None = Field(None, description='ИНН') - erc20: str | None = Field(None, description='ERC-20 адрес') - avatar_link: str | None = Field(None, description='HTTPS-ссылка на текущий аватар в хранилище') - kyc_verified: bool | None = Field(None, description='Признак пройденного KYC') - is_deleted: bool | None = Field(None, description='Удалён ли аккаунт') - created_at: datetime | None = Field(None, description='Время создания записи') - updated_at: datetime | None = Field(None, description='Время последнего обновления') - kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC') - - @classmethod - def from_user(cls, user: UserEntity) -> MeUserPublicResponse: - return cls( - id=user.id, - email=user.email, - first_name=user.first_name, - middle_name=user.middle_name, - last_name=user.last_name, - birth_date=user.birth_date, - encrypted_mnemonic=user.encrypted_mnemonic, - phone=user.phone, - passport_data=user.passport_data, - inn=user.inn, - erc20=user.erc20, - avatar_link=user.avatar_link, - kyc_verified=user.kyc_verified, - is_deleted=user.is_deleted, - created_at=user.created_at, - updated_at=user.updated_at, - kyc_verified_at=user.kyc_verified_at, - ) - - -class SetAvatarPublicResponse(MeUserPublicResponse): - webp_size_bytes: int = Field( - ..., - ge=0, - description='Размер сохранённого файла аватара в формате WebP, байты', - ) diff --git a/src/presentation/schemas/password.py b/src/presentation/schemas/password.py deleted file mode 100644 index 6da729c..0000000 --- a/src/presentation/schemas/password.py +++ /dev/null @@ -1,75 +0,0 @@ -import re -from typing import Self -from pydantic import BaseModel, field_validator, model_validator -from src.application.domain.password_policy import validate_password_strength - - -class ForgotPasswordStartRequest(BaseModel): - email: str - - @field_validator('email') - @classmethod - def validate_email(cls, v: str) -> str: - v = v.strip().lower() - if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v): - raise ValueError('Invalid email address') - return v - - -class ForgotPasswordCompleteRequest(BaseModel): - email: str - code: str - new_password: str - confirm_password: str - - @field_validator('email') - @classmethod - def validate_email(cls, v: str) -> str: - v = v.strip().lower() - if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v): - raise ValueError('Invalid email address') - return v - - @model_validator(mode='after') - def passwords_match(self) -> Self: - if self.new_password != self.confirm_password: - raise ValueError('Passwords do not match') - return self - - @field_validator('code') - @classmethod - def validate_code(cls, v: str) -> str: - v = v.strip() - if not re.match(r'^\d{6}$', v): - raise ValueError('Code must be exactly 6 digits') - return v - - @field_validator('new_password') - @classmethod - def validate_new_password(cls, v: str) -> str: - return validate_password_strength(v) - - -class ChangePasswordConfirmRequest(BaseModel): - code: str - new_password: str - confirm_password: str - - @model_validator(mode='after') - def passwords_match(self) -> Self: - if self.new_password != self.confirm_password: - raise ValueError('Passwords do not match') - return self - - @field_validator('code') - @classmethod - def validate_code(cls, v: str) -> str: - v = v.strip() - if not re.match(r'^\d{6}$', v): - raise ValueError('Code must be exactly 6 digits') - return v - - @field_validator('new_password') - @classmethod - def validate_new_password(cls, v: str) -> str: - return validate_password_strength(v) diff --git a/src/presentation/schemas/phone.py b/src/presentation/schemas/phone.py deleted file mode 100644 index 1a81088..0000000 --- a/src/presentation/schemas/phone.py +++ /dev/null @@ -1,16 +0,0 @@ -import re -from pydantic import BaseModel, field_validator - - -class SetPhoneRequest(BaseModel): - phone: str - - @field_validator('phone') - @classmethod - def validate_russian_phone(cls, v: str) -> str: - cleaned = re.sub(r'[\s\-\(\)]', '', v) - pattern = r'^(\+7|8)\d{10}$' - if not re.match(pattern, cleaned): - raise ValueError('Invalid Russian phone number') - normalized = '+7' + cleaned[-10:] - return normalized diff --git a/src/presentation/serializers/__init__.py b/src/presentation/serializers/__init__.py index a92cc01..e69de29 100644 --- a/src/presentation/serializers/__init__.py +++ b/src/presentation/serializers/__init__.py @@ -1 +0,0 @@ -from src.presentation.serializers.me_user import me_user_payload, me_user_public diff --git a/src/presentation/serializers/me_user.py b/src/presentation/serializers/me_user.py deleted file mode 100644 index 169ce69..0000000 --- a/src/presentation/serializers/me_user.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from src.application.domain.entities import UserEntity - -from src.presentation.schemas.me_public import MeUserPublicResponse - - -def me_user_public(user: UserEntity) -> MeUserPublicResponse: - return MeUserPublicResponse.from_user(user) - - -def me_user_payload(user: UserEntity) -> dict: - return me_user_public(user).model_dump(mode='json') diff --git a/uv.lock b/uv.lock index 5a10303..f32e6c9 100644 --- a/uv.lock +++ b/uv.lock @@ -75,11 +75,9 @@ dependencies = [ { name = "granian" }, { name = "hvac" }, { name = "itsdangerous" }, - { name = "orjson" }, { name = "pydantic-settings" }, { name = "python-jose" }, { name = "python-ulid" }, - { name = "redis" }, { name = "sqlalchemy" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, ] @@ -89,19 +87,15 @@ requires-dist = [ { name = "apscheduler", specifier = "==3.11.2" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "bip-utils", specifier = "==2.9.3" }, - { name = "bip-utils", specifier = ">=2.9.3" }, { name = "cryptography", specifier = "==44.0.0" }, - { name = "cryptography", specifier = ">=44.0.0" }, { name = "dotenv", specifier = "==0.9.9" }, { name = "fastapi", specifier = "==0.128.7" }, { name = "granian", specifier = "==2.6.1" }, { name = "hvac", specifier = "==2.4.0" }, { name = "itsdangerous", specifier = "==2.2.0" }, - { name = "orjson", specifier = "==3.11.7" }, { name = "pydantic-settings", specifier = "==2.12.0" }, { name = "python-jose", specifier = "==3.5.0" }, { name = "python-ulid", specifier = "==3.1.0" }, - { name = "redis", specifier = "==7.2.0" }, { name = "sqlalchemy", specifier = "==2.0.46" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "==0.22.1" }, ] @@ -387,29 +381,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] -[[package]] -name = "orjson" -version = "3.11.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, - { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, - { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, - { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, - { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, - { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, - { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, -] - [[package]] name = "py-sr25519-bindings" version = "0.2.3" @@ -580,15 +551,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, ] -[[package]] -name = "redis" -version = "7.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, -] - [[package]] name = "requests" version = "2.32.5"