diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py index 0d922d6..43eb3d4 100644 --- a/src/application/abstractions/repositories/i_user_repository.py +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -39,5 +39,5 @@ class IUserRepository(ABC): raise NotImplementedError @abstractmethod - async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity: + async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity: raise NotImplementedError diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 8831d29..0b859e1 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -1,6 +1,7 @@ from src.application.commands.get_me import GetMeCommand from src.application.commands.set_phone import SetPhoneCommand from src.application.commands.set_avatar import SetAvatarCommand +from src.application.commands.delete_avatar import DeleteAvatarCommand from src.application.commands.set_encrypted_mnemonic_start import SetEncryptedMnemonicStartCommand from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand diff --git a/src/application/commands/delete_avatar.py b/src/application/commands/delete_avatar.py new file mode 100644 index 0000000..916e641 --- /dev/null +++ b/src/application/commands/delete_avatar.py @@ -0,0 +1,37 @@ +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 + + async def __call__(self, user_id: str) -> UserEntity: + prior = await self._unit_of_work.user_repository.get_user_by_id(user_id) + link = prior.avatar_link + if link: + key = self._s3.object_key_from_public_url(link) + if key: + try: + await self._s3.delete_object(key=key) + except ClientError as exc: + code = exc.response.get('Error', {}).get('Code', '') + if code not in ('NoSuchKey', '404'): + self._logger.warning(f'S3 delete avatar failed user_id={user_id} code={code}: {exc}') + user = await self._clear_avatar_link(user_id) + await self._cache.set_user(user_id, user) + self._logger.info(f'Avatar removed user_id={user_id}') + return user + + @transactional + async def _clear_avatar_link(self, user_id: str) -> UserEntity: + return await self._unit_of_work.user_repository.set_avatar_link(user_id, None) diff --git a/src/application/commands/set_avatar.py b/src/application/commands/set_avatar.py index 43ce3e3..87c97be 100644 --- a/src/application/commands/set_avatar.py +++ b/src/application/commands/set_avatar.py @@ -6,6 +6,7 @@ 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 @@ -23,6 +24,8 @@ class SetAvatarCommand: self._s3 = s3 async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]: + prior = await self._unit_of_work.user_repository.get_user_by_id(user_id) + old_link = prior.avatar_link try: webp_bytes = image_bytes_to_webp(image_bytes) except UnidentifiedImageError as exc: @@ -46,6 +49,17 @@ class SetAvatarCommand: user = await self._save_avatar_link(user_id, url) await self._cache.set_user(user_id, user) + + if old_link: + old_key = self._s3.object_key_from_public_url(old_link) + if old_key and old_key != object_key: + try: + await self._s3.delete_object(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}') + self._logger.info(f'Avatar set for user_id={user_id} key={object_key}') return user, len(webp_bytes) diff --git a/src/application/contracts/i_s3.py b/src/application/contracts/i_s3.py index eeaaf2a..8164896 100644 --- a/src/application/contracts/i_s3.py +++ b/src/application/contracts/i_s3.py @@ -7,3 +7,11 @@ from typing import Protocol, 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/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index 369666d..cbcbdb0 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -89,7 +89,7 @@ class UserRepository(IUserRepository): async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity: return await self._update_field(user_id, encrypted_mnemonic=encrypted_mnemonic) - async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity: + async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity: return await self._update_field(user_id, avatar_link=avatar_link) async def get_password_hash(self, user_id: str) -> str: diff --git a/src/infrastructure/storage/s3_service.py b/src/infrastructure/storage/s3_service.py index 0dd0a56..62780a1 100644 --- a/src/infrastructure/storage/s3_service.py +++ b/src/infrastructure/storage/s3_service.py @@ -24,6 +24,52 @@ class S3Service: 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}' @@ -62,3 +108,18 @@ class S3Service: 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/presentation/dependencies/__init__.py b/src/presentation/dependencies/__init__.py index 8c1b5f3..f8015be 100644 --- a/src/presentation/dependencies/__init__.py +++ b/src/presentation/dependencies/__init__.py @@ -2,6 +2,7 @@ from src.presentation.dependencies.commands import ( get_get_me_command, get_set_phone_command, get_set_avatar_command, + get_delete_avatar_command, get_set_encrypted_mnemonic_start_command, get_set_encrypted_mnemonic_complete_command, get_update_bank_details_start_command, diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index 62bd18f..f37c215 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -1,6 +1,6 @@ from fastapi import Depends from src.application.abstractions import IUnitOfWork -from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand +from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3 from src.presentation.dependencies.cache import get_cache from src.presentation.dependencies.logger import get_logger @@ -35,6 +35,15 @@ def get_set_avatar_command( return SetAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3) +def get_delete_avatar_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), + cache: ICache = Depends(get_cache), + s3: IS3 = Depends(get_s3_storage), +) -> DeleteAvatarCommand: + return DeleteAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3) + + def get_set_encrypted_mnemonic_start_command( logger: ILogger = Depends(get_logger), unit_of_work: IUnitOfWork = Depends(get_unit_of_work), diff --git a/src/presentation/routing/account_settings.py b/src/presentation/routing/account_settings.py index 9b8bd00..f6d6280 100644 --- a/src/presentation/routing/account_settings.py +++ b/src/presentation/routing/account_settings.py @@ -1,16 +1,17 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import ORJSONResponse from starlette import status -from src.application.commands import SetPhoneCommand, SetAvatarCommand +from src.application.commands import SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand from src.application.domain.dto import AuthContext from src.presentation.decorators import require_access_token, csrf_protect from src.presentation.dependencies import ( + get_delete_avatar_command, get_set_avatar_command, get_set_phone_command, ) from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload -from src.presentation.schemas.me_public import SetAvatarPublicResponse +from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse from src.presentation.serializers import me_user_public @@ -45,6 +46,26 @@ _SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = { } +_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( @@ -64,7 +85,8 @@ async def set_phone( response_model=SetAvatarPublicResponse, summary='Обновить аватар', description=( - 'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль.' + 'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. ' + 'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).' ), response_description=( 'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.' @@ -82,6 +104,27 @@ async def set_avatar( 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='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) # async def encrypted_mnemonic_start(