From 989fb766b4f9eab6250a699d1b86cc0d44317d57 Mon Sep 17 00:00:00 2001 From: fire Date: Fri, 19 Jun 2026 11:49:45 +0000 Subject: [PATCH 01/10] fix: handle Valutix webhook response format --- .../payment_gateways/valutix.py | 55 +++++-------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/src/infrastructure/payment_gateways/valutix.py b/src/infrastructure/payment_gateways/valutix.py index 445600a1..38b65971 100644 --- a/src/infrastructure/payment_gateways/valutix.py +++ b/src/infrastructure/payment_gateways/valutix.py @@ -1,14 +1,12 @@ -import base64 +import hashlib +import hmac import uuid from decimal import Decimal -from typing import Any, Final, Optional, Union +from typing import Any, Final, Union from uuid import UUID import orjson from aiogram import Bot -from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding from fastapi import Request from httpx import AsyncClient, HTTPStatusError from loguru import logger @@ -24,7 +22,6 @@ # https://docs.panel.valutix.kz/ru/docs class ValutixGateway(BasePaymentGateway): _client: AsyncClient - _public_key_pem: Optional[str] API_BASE: Final[str] = "https://api.panel.valutix.kz" @@ -37,7 +34,6 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N f"got {type(self.data.settings).__name__}" ) - self._public_key_pem = None self._client = self._make_client( base_url=self.API_BASE, headers={"X-Api-Token": self.data.settings.api_key.get_secret_value()}, # type: ignore[union-attr] @@ -83,9 +79,9 @@ async def handle_webhook(self, request: Request) -> Union[tuple[UUID, Transactio webhook_data = orjson.loads(raw_body) logger.debug(f"Valutix webhook data: {webhook_data}") - payment_id_str = webhook_data.get("uuid") + payment_id_str = webhook_data.get("id") if not payment_id_str: - raise ValueError("Required field 'uuid' is missing") + raise ValueError("Required field 'id' is missing") status = webhook_data.get("status") payment_id = UUID(payment_id_str) @@ -114,38 +110,17 @@ def _get_payment_data(self, data: dict[str, Any]) -> PaymentResultDto: return PaymentResultDto(id=UUID(valutix_id), url=str(payment_link)) - async def _fetch_public_key(self) -> str: - response = await self._client.get("v1/orders/pubkey") - response.raise_for_status() - result: str = orjson.loads(response.content) - return result - - async def _get_public_key(self) -> str: - if self._public_key_pem is None: - self._public_key_pem = await self._fetch_public_key() - logger.debug("Fetched Valutix RSA public key") - return self._public_key_pem - async def _verify_webhook(self, request: Request, raw_body: bytes) -> None: - signature_b64 = request.headers.get("X-Signature") - if not signature_b64: + signature = request.headers.get("X-Signature") + if not signature: raise PermissionError("Valutix webhook missing X-Signature header") - try: - signature = base64.b64decode(signature_b64) - except Exception: - raise PermissionError("Valutix webhook X-Signature is not valid base64") - - pubkey_pem = await self._get_public_key() - try: - public_key = serialization.load_pem_public_key(pubkey_pem.encode()) - public_key.verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA256()) # type: ignore[union-attr,arg-type,call-arg] - except InvalidSignature: - self._public_key_pem = None - logger.warning("Valutix webhook RSA signature verification failed") - raise PermissionError("Valutix webhook verification failed") - except PermissionError: - raise - except Exception as e: - logger.error(f"Valutix webhook verification error: {e}") + api_key = self.data.settings.api_key.get_secret_value() # type: ignore[union-attr] + expected_signature = hmac.new( + api_key.encode(), + raw_body, + hashlib.sha512, + ).hexdigest() + if not hmac.compare_digest(signature.lower(), expected_signature): + logger.warning("Valutix webhook HMAC signature verification failed") raise PermissionError("Valutix webhook verification failed") From c832483d5365723f1d71be56a0dacd32e9e9812f Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 19:14:57 +0500 Subject: [PATCH 02/10] fix: worker dialog user injection, self-edit access, free payment and text button issues - inject UserMiddleware into worker dispatcher so background dialog getters (e.g. Subscription:SUCCESS) receive USER_KEY - allow actors to edit their own profile fields regardless of role comparison - call process_payment.system() for free subscription purchase/extension - handle TEXT menu buttons that carry media without text payload --- .../use_cases/user/commands/profile_edit.py | 10 +++++----- src/telegram/dispatcher.py | 4 ++++ .../dashboard/remnashop/menu_editor/handlers.py | 15 +++++++-------- src/telegram/routers/menu/handlers.py | 2 +- src/telegram/routers/subscription/handlers.py | 3 +-- src/web/endpoints/public/subscription.py | 6 ++---- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/application/use_cases/user/commands/profile_edit.py b/src/application/use_cases/user/commands/profile_edit.py index 684ad879..0473ed25 100644 --- a/src/application/use_cases/user/commands/profile_edit.py +++ b/src/application/use_cases/user/commands/profile_edit.py @@ -35,7 +35,7 @@ async def _execute(self, actor: UserDto, data: SetUserPersonalDiscountDto) -> No if not target_user: raise ValueError(f"User '{data.user_id}' not found") - if not actor.role > target_user.role: + if actor.id != target_user.id and not actor.role > target_user.role: logger.warning( f"{actor.log} denied editing user '{target_user.id}': " f"target role '{target_user.role}' >= actor role '{actor.role}'" @@ -73,7 +73,7 @@ async def _execute(self, actor: UserDto, data: SetUserPurchaseDiscountDto) -> No if not target_user: raise ValueError(f"User '{data.user_id}' not found") - if not actor.role > target_user.role: + if actor.id != target_user.id and not actor.role > target_user.role: logger.warning( f"{actor.log} denied editing user '{target_user.id}': " f"target role '{target_user.role}' >= actor role '{actor.role}'" @@ -102,7 +102,7 @@ async def _execute(self, actor: UserDto, user_id: int) -> None: if not target_user: raise ValueError(f"User '{user_id}' not found") - if not actor.role > target_user.role: + if actor.id != target_user.id and not actor.role > target_user.role: logger.warning( f"{actor.log} denied editing user '{target_user.id}': " f"target role '{target_user.role}' >= actor role '{actor.role}'" @@ -136,7 +136,7 @@ async def _execute(self, actor: UserDto, data: ChangeUserPointsDto) -> None: logger.error(f"{actor.log} User not found with id '{data.user_id}'") raise ValueError(f"User '{data.user_id}' not found") - if not actor.role > target_user.role: + if actor.id != target_user.id and not actor.role > target_user.role: logger.warning( f"{actor.log} denied editing user '{target_user.id}': " f"target role '{target_user.role}' >= actor role '{actor.role}'" @@ -173,7 +173,7 @@ async def _execute(self, actor: UserDto, user_id: int) -> None: if not target_user: raise ValueError(f"User '{user_id}' not found") - if not actor.role > target_user.role: + if actor.id != target_user.id and not actor.role > target_user.role: logger.warning( f"{actor.log} denied editing user '{target_user.id}': " f"target role '{target_user.role}' >= actor role '{actor.role}'" diff --git a/src/telegram/dispatcher.py b/src/telegram/dispatcher.py index e64d89bb..d07116af 100644 --- a/src/telegram/dispatcher.py +++ b/src/telegram/dispatcher.py @@ -11,6 +11,7 @@ from src.telegram.filters import setup_global_filters from src.telegram.message_manager import MessageManager from src.telegram.middlewares import setup_middlewares +from src.telegram.middlewares.user import UserMiddleware from src.telegram.routers import setup_routers @@ -50,5 +51,8 @@ def setup_dispatcher(dispatcher: Dispatcher) -> None: def setup_worker_dispatcher(dispatcher: Dispatcher) -> None: + # Background redirects (e.g. Subscription:SUCCESS) start dialogs via bg_manager, which + # emit AIOGD_UPDATE. Dialog getters rely on USER_KEY, so UserMiddleware must populate it. + UserMiddleware().setup_outer(dispatcher) setup_routers(dispatcher) logger.info("Worker dispatcher routers have been configured") diff --git a/src/telegram/routers/dashboard/remnashop/menu_editor/handlers.py b/src/telegram/routers/dashboard/remnashop/menu_editor/handlers.py index d7451f50..a8d9b4aa 100644 --- a/src/telegram/routers/dashboard/remnashop/menu_editor/handlers.py +++ b/src/telegram/routers/dashboard/remnashop/menu_editor/handlers.py @@ -207,14 +207,13 @@ async def on_payload_input( # noqa: C901 user, UpdateMenuButtonMediaDto(button, file_id, media_type) ) - if text: - try: - button = await update_menu_button_payload( - user, UpdateMenuButtonPayloadDto(button, text) - ) - except ValueError: - await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") - return + try: + button = await update_menu_button_payload( + user, UpdateMenuButtonPayloadDto(button, text) + ) + except ValueError: + await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") + return else: if message.text is None: await notifier.notify_user(user, i18n_key="ntf-common.invalid-value") diff --git a/src/telegram/routers/menu/handlers.py b/src/telegram/routers/menu/handlers.py index 0648da7f..7f9ef4d6 100644 --- a/src/telegram/routers/menu/handlers.py +++ b/src/telegram/routers/menu/handlers.py @@ -289,7 +289,7 @@ async def on_text_button_click( settings = await settings_dao.get() button = next((b for b in settings.menu.buttons if b.index == button_index), None) - if not button or not button.payload: + if not button or not (button.payload or button.media_file_id): return await notifier.notify_user( diff --git a/src/telegram/routers/subscription/handlers.py b/src/telegram/routers/subscription/handlers.py index 3cff30bc..6d352d89 100644 --- a/src/telegram/routers/subscription/handlers.py +++ b/src/telegram/routers/subscription/handlers.py @@ -473,8 +473,7 @@ async def on_get_subscription( payment_id = dialog_manager.dialog_data["payment_id"] gateway_type: PaymentGatewayType = dialog_manager.dialog_data[CURRENT_METHOD_KEY] logger.info(f"{user.log} Getted free subscription '{payment_id}'") - await process_payment( - user, + await process_payment.system( ProcessPaymentDto( payment_id=payment_id, new_transaction_status=TransactionStatus.COMPLETED, diff --git a/src/web/endpoints/public/subscription.py b/src/web/endpoints/public/subscription.py index 83985555..f84b0461 100644 --- a/src/web/endpoints/public/subscription.py +++ b/src/web/endpoints/public/subscription.py @@ -230,8 +230,7 @@ async def purchase_subscription( tx_status = TransactionStatus.PENDING if pricing.is_free: - await process_payment( - user, + await process_payment.system( ProcessPaymentDto( payment_id=payment.id, new_transaction_status=TransactionStatus.COMPLETED, @@ -310,8 +309,7 @@ async def extend_subscription( tx_status = TransactionStatus.PENDING if pricing.is_free: - await process_payment( - user, + await process_payment.system( ProcessPaymentDto( payment_id=payment.id, new_transaction_status=TransactionStatus.COMPLETED, From 24c7745aa09ff69a5fd5daac6314351285860be1 Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 19:39:16 +0500 Subject: [PATCH 03/10] feat: return to filtered user list and show identifier in list labels - preserve the originating list (recent/activity/search results/blacklist) when opening a user card and route the back button to it; search results are restored with the same matches - show "name (telegram_id | email | internal id)" in user list labels --- src/application/dto/user.py | 8 ++++ src/core/constants.py | 2 + .../routers/dashboard/users/dialog.py | 8 ++-- .../routers/dashboard/users/handlers.py | 12 ++++- .../routers/dashboard/users/user/dialog.py | 8 ++-- .../routers/dashboard/users/user/handlers.py | 47 ++++++++++++++++++- 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/application/dto/user.py b/src/application/dto/user.py index 213562f2..456c755b 100644 --- a/src/application/dto/user.py +++ b/src/application/dto/user.py @@ -68,6 +68,14 @@ class UserDto(BaseDto, TrackableMixin, TimestampMixin): ad_link_id: Optional[int] = None referral_code_reset_at: Optional[datetime] = None + @property + def contact_label(self) -> str: + if self.username: + return self.username + if self.telegram_id is not None: + return str(self.telegram_id) + return self.email or str(self.id) + @property def is_privileged(self) -> bool: return self.role.includes(Role.ADMIN) diff --git a/src/core/constants.py b/src/core/constants.py index 4c8850d1..e7e38f03 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -55,6 +55,8 @@ TARGET_TELEGRAM_ID: Final[str] = "target_telegram_id" TARGET_USER_ID: Final[str] = "target_user_id" FROM_REFERRAL_USER_ID: Final[str] = "from_referral_user_id" +USER_LIST_ORIGIN: Final[str] = "user_list_origin" +USER_LIST_PAYLOAD: Final[str] = "user_list_payload" INT32_MAX: Final[int] = 2_147_483_647 diff --git a/src/telegram/routers/dashboard/users/dialog.py b/src/telegram/routers/dashboard/users/dialog.py index e8e7b606..941a7aaa 100644 --- a/src/telegram/routers/dashboard/users/dialog.py +++ b/src/telegram/routers/dashboard/users/dialog.py @@ -93,7 +93,7 @@ I18nFormat("msg-users-recent-registered"), ScrollingGroup( Select( - text=Format("{item.name}"), + text=Format("{item.name} ({item.contact_label})"), id="user", item_id_getter=lambda item: item.id, items="recent_registered_users", @@ -122,7 +122,7 @@ I18nFormat("msg-users-recent-activity"), ScrollingGroup( Select( - text=Format("{item.name}"), + text=Format("{item.name} ({item.contact_label})"), id="user", item_id_getter=lambda item: item.id, items="recent_activity_users", @@ -151,7 +151,7 @@ I18nFormat("msg-users-search-results", count=F["count"]), ScrollingGroup( Select( - text=Format("{item.name}"), + text=Format("{item.name} ({item.contact_label})"), id="user", item_id_getter=lambda item: item.id, items="found_users", @@ -218,7 +218,7 @@ I18nFormat("msg-users-blacklist-list"), ScrollingGroup( Select( - text=Format("{item.name}"), + text=Format("{item.name} ({item.contact_label})"), id="user", item_id_getter=lambda item: item.id, items="blocked_users", diff --git a/src/telegram/routers/dashboard/users/handlers.py b/src/telegram/routers/dashboard/users/handlers.py index f6875683..e9336256 100644 --- a/src/telegram/routers/dashboard/users/handlers.py +++ b/src/telegram/routers/dashboard/users/handlers.py @@ -81,7 +81,17 @@ async def on_user_select( ) -> None: user: TelegramUserDto = dialog_manager.middleware_data[USER_KEY] logger.info(f"{user.log} User id '{selected_user}' selected") - await start_user_window(manager=dialog_manager, target_user_id=selected_user) + + context = dialog_manager.current_context() + origin = context.state + payload = context.start_data.get("found_users") if context.start_data else None # type: ignore[union-attr] + + await start_user_window( + manager=dialog_manager, + target_user_id=selected_user, + list_origin=origin.state, + list_payload=payload, + ) @inject diff --git a/src/telegram/routers/dashboard/users/user/dialog.py b/src/telegram/routers/dashboard/users/user/dialog.py index f5a064e8..e70460d9 100644 --- a/src/telegram/routers/dashboard/users/user/dialog.py +++ b/src/telegram/routers/dashboard/users/user/dialog.py @@ -8,7 +8,7 @@ from src.core.enums import BannerName, SubscriptionStatus from src.telegram.keyboards import back_main_menu_button from src.telegram.routers.dashboard.broadcast.handlers import on_content_input, on_preview -from src.telegram.states import DashboardRemnashop, DashboardUser, DashboardUsers +from src.telegram.states import DashboardRemnashop, DashboardUser from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate from src.telegram.widgets.kbd import ( Button, @@ -47,6 +47,7 @@ ) from .handlers import ( on_active_toggle, + on_back_to_list, on_back_to_referrals, on_block_toggle, on_current_subscription, @@ -184,11 +185,10 @@ ), ), Row( - Start( + Button( text=I18nFormat("btn-back.dashboard"), id="back", - state=DashboardUsers.MAIN, - mode=StartMode.RESET_STACK, + on_click=on_back_to_list, ), ), *back_main_menu_button, diff --git a/src/telegram/routers/dashboard/users/user/handlers.py b/src/telegram/routers/dashboard/users/user/handlers.py index b3bf0ca0..3d23f3ba 100644 --- a/src/telegram/routers/dashboard/users/user/handlers.py +++ b/src/telegram/routers/dashboard/users/user/handlers.py @@ -64,22 +64,42 @@ from src.application.use_cases.user.commands.roles import SetUserRole, SetUserRoleDto from src.application.use_cases.user.queries.plans import GetAvailablePlans from src.application.use_cases.user.queries.profile import GetUserDevices -from src.core.constants import FROM_REFERRAL_USER_ID, TARGET_TELEGRAM_ID, TARGET_USER_ID, USER_KEY +from src.core.constants import ( + FROM_REFERRAL_USER_ID, + TARGET_TELEGRAM_ID, + TARGET_USER_ID, + USER_KEY, + USER_LIST_ORIGIN, + USER_LIST_PAYLOAD, +) from src.core.enums import Role from src.core.utils.validators import is_positive_int, parse_int from src.telegram.keyboards import get_contact_support_keyboard -from src.telegram.states import DashboardUser +from src.telegram.states import DashboardUser, DashboardUsers from src.telegram.utils import is_double_click +_USER_LIST_STATES = { + DashboardUsers.RECENT_REGISTERED.state: DashboardUsers.RECENT_REGISTERED, + DashboardUsers.RECENT_ACTIVITY.state: DashboardUsers.RECENT_ACTIVITY, + DashboardUsers.BLACKLIST_USERS.state: DashboardUsers.BLACKLIST_USERS, + DashboardUsers.SEARCH_RESULTS.state: DashboardUsers.SEARCH_RESULTS, +} + async def start_user_window( manager: DialogManager, target_user_id: int, from_referral_user_id: Optional[int] = None, + list_origin: Optional[str] = None, + list_payload: Optional[list] = None, ) -> None: data: dict = {TARGET_USER_ID: target_user_id} if from_referral_user_id is not None: data[FROM_REFERRAL_USER_ID] = from_referral_user_id + if list_origin is not None: + data[USER_LIST_ORIGIN] = list_origin + if list_payload is not None: + data[USER_LIST_PAYLOAD] = list_payload await manager.start( state=DashboardUser.MAIN, data=data, @@ -87,6 +107,29 @@ async def start_user_window( ) +async def on_back_to_list( + callback: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + origin = dialog_manager.start_data.get(USER_LIST_ORIGIN) # type: ignore[union-attr] + state = _USER_LIST_STATES.get(origin) if origin else None + + if state is None: + await dialog_manager.start(state=DashboardUsers.MAIN, mode=StartMode.RESET_STACK) + return + + data: dict = {} + if state == DashboardUsers.SEARCH_RESULTS: + payload = dialog_manager.start_data.get(USER_LIST_PAYLOAD) # type: ignore[union-attr] + if not payload: + await dialog_manager.start(state=DashboardUsers.MAIN, mode=StartMode.RESET_STACK) + return + data["found_users"] = payload + + await dialog_manager.start(state=state, data=data, mode=StartMode.RESET_STACK) + + async def start_user_transaction_window( manager: DialogManager, target_user_id: int, From be41f98e7f197835f7187cfbf7e363b16002e077 Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 20:19:51 +0500 Subject: [PATCH 04/10] feat(web): enable subscription reissue endpoint --- src/web/endpoints/public/subscription.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/web/endpoints/public/subscription.py b/src/web/endpoints/public/subscription.py index f84b0461..fea9414d 100644 --- a/src/web/endpoints/public/subscription.py +++ b/src/web/endpoints/public/subscription.py @@ -22,6 +22,7 @@ from src.application.use_cases.remnawave.commands.management import ( DeleteUserDevice, DeleteUserDeviceDto, + ReissueSubscription, ) from src.application.use_cases.user.queries.plans import GetAvailablePlans from src.core.enums import ( @@ -29,6 +30,7 @@ PurchaseType, TransactionStatus, ) +from src.core.exceptions import CooldownError from src.web.schemas import ( DeviceDeleteResponse, DeviceResponse, @@ -40,6 +42,7 @@ PaymentInitResponse, PlanOfferResponse, PurchaseRequest, + ReissueResponse, SubscriptionInfoResponse, SubscriptionOffersResponse, ) @@ -169,14 +172,19 @@ async def delete_subscription_device( return DeviceDeleteResponse(deleted=deleted) -# @router.post("/reissue", response_model=ReissueResponse) -# @inject -# async def reissue_current_subscription( -# user: CurrentUser, -# reissue_subscription: FromDishka[ReissueSubscription], -# ) -> ReissueResponse: -# await reissue_subscription(user) -# return ReissueResponse(success=True) +@router.post("/reissue", response_model=ReissueResponse) +@inject +async def reissue_current_subscription( + user: CurrentUser, + reissue_subscription: FromDishka[ReissueSubscription], +) -> ReissueResponse: + try: + await reissue_subscription(user) + except CooldownError as e: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e + return ReissueResponse(success=True) @router.post("/purchase", response_model=PaymentInitResponse) From add32dd459487a5834afa5dd196e7b24e0d2dce8 Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 20:23:02 +0500 Subject: [PATCH 05/10] feat(web): add delete-all-devices endpoint --- src/web/endpoints/public/subscription.py | 17 +++++++++++++++++ src/web/schemas/__init__.py | 2 ++ src/web/schemas/subscription.py | 4 ++++ 3 files changed, 23 insertions(+) diff --git a/src/web/endpoints/public/subscription.py b/src/web/endpoints/public/subscription.py index fea9414d..74ea8a17 100644 --- a/src/web/endpoints/public/subscription.py +++ b/src/web/endpoints/public/subscription.py @@ -20,6 +20,7 @@ ) from src.application.use_cases.plan.queries.match import MatchPlan, MatchPlanDto from src.application.use_cases.remnawave.commands.management import ( + DeleteUserAllDevices, DeleteUserDevice, DeleteUserDeviceDto, ReissueSubscription, @@ -34,6 +35,7 @@ from src.web.schemas import ( DeviceDeleteResponse, DeviceResponse, + DevicesDeleteAllResponse, DevicesResponse, DurationGatewayPriceResponse, DurationOfferResponse, @@ -172,6 +174,21 @@ async def delete_subscription_device( return DeviceDeleteResponse(deleted=deleted) +@router.delete("/devices", response_model=DevicesDeleteAllResponse) +@inject +async def delete_all_subscription_devices( + user: CurrentUser, + delete_all_devices: FromDishka[DeleteUserAllDevices], +) -> DevicesDeleteAllResponse: + try: + await delete_all_devices(user) + except CooldownError as e: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e + return DevicesDeleteAllResponse(success=True) + + @router.post("/reissue", response_model=ReissueResponse) @inject async def reissue_current_subscription( diff --git a/src/web/schemas/__init__.py b/src/web/schemas/__init__.py index 21a9470a..b601282a 100644 --- a/src/web/schemas/__init__.py +++ b/src/web/schemas/__init__.py @@ -27,6 +27,7 @@ from .subscription import ( DeviceDeleteResponse, DeviceResponse, + DevicesDeleteAllResponse, DevicesResponse, DurationGatewayPriceResponse, DurationOfferResponse, @@ -72,6 +73,7 @@ # subscription "DeviceDeleteResponse", "DeviceResponse", + "DevicesDeleteAllResponse", "DevicesResponse", "DurationGatewayPriceResponse", "DurationOfferResponse", diff --git a/src/web/schemas/subscription.py b/src/web/schemas/subscription.py index 3a8cc47f..51274b1b 100644 --- a/src/web/schemas/subscription.py +++ b/src/web/schemas/subscription.py @@ -40,6 +40,10 @@ class DeviceDeleteResponse(BaseModel): deleted: bool +class DevicesDeleteAllResponse(BaseModel): + success: bool + + class ReissueResponse(BaseModel): success: bool From 1682ab61ab9d8d528dafafe2c378a2ba8914d42c Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 20:25:08 +0500 Subject: [PATCH 06/10] feat(web): add promocode activation endpoint --- src/web/endpoints/public/subscription.py | 35 +++++++++++++++++++++++- src/web/schemas/__init__.py | 4 +++ src/web/schemas/subscription.py | 9 ++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/web/endpoints/public/subscription.py b/src/web/endpoints/public/subscription.py index 74ea8a17..034de251 100644 --- a/src/web/endpoints/public/subscription.py +++ b/src/web/endpoints/public/subscription.py @@ -19,6 +19,10 @@ ProcessPaymentDto, ) from src.application.use_cases.plan.queries.match import MatchPlan, MatchPlanDto +from src.application.use_cases.promocode.commands.activate import ( + ActivatePromocode, + ActivatePromocodeDto, +) from src.application.use_cases.remnawave.commands.management import ( DeleteUserAllDevices, DeleteUserDevice, @@ -31,7 +35,13 @@ PurchaseType, TransactionStatus, ) -from src.core.exceptions import CooldownError +from src.core.exceptions import ( + CooldownError, + PromocodeAlreadyActivatedError, + PromocodeExpiredError, + PromocodeNotAvailableError, + PromocodeNotFoundError, +) from src.web.schemas import ( DeviceDeleteResponse, DeviceResponse, @@ -43,6 +53,8 @@ GatewayOfferResponse, PaymentInitResponse, PlanOfferResponse, + PromocodeActivateRequest, + PromocodeActivateResponse, PurchaseRequest, ReissueResponse, SubscriptionInfoResponse, @@ -204,6 +216,27 @@ async def reissue_current_subscription( return ReissueResponse(success=True) +@router.post("/promocode", response_model=PromocodeActivateResponse) +@inject +async def activate_promocode_web( + body: PromocodeActivateRequest, + user: CurrentUser, + activate_promocode: FromDishka[ActivatePromocode], +) -> PromocodeActivateResponse: + _assert_web_purchase_email_verified(user) + try: + promo = await activate_promocode(user, ActivatePromocodeDto(code=body.code, user=user)) + except PromocodeNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e + except ( + PromocodeExpiredError, + PromocodeAlreadyActivatedError, + PromocodeNotAvailableError, + ) as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e + return PromocodeActivateResponse(success=True, reward_type=promo.reward_type.value) + + @router.post("/purchase", response_model=PaymentInitResponse) @inject async def purchase_subscription( diff --git a/src/web/schemas/__init__.py b/src/web/schemas/__init__.py index b601282a..0c9401a8 100644 --- a/src/web/schemas/__init__.py +++ b/src/web/schemas/__init__.py @@ -35,6 +35,8 @@ GatewayOfferResponse, PaymentInitResponse, PlanOfferResponse, + PromocodeActivateRequest, + PromocodeActivateResponse, PurchaseRequest, ReissueResponse, SubscriptionInfoResponse, @@ -81,6 +83,8 @@ "GatewayOfferResponse", "PaymentInitResponse", "PlanOfferResponse", + "PromocodeActivateRequest", + "PromocodeActivateResponse", "PurchaseRequest", "ReissueResponse", "SubscriptionInfoResponse", diff --git a/src/web/schemas/subscription.py b/src/web/schemas/subscription.py index 51274b1b..32bbdf0e 100644 --- a/src/web/schemas/subscription.py +++ b/src/web/schemas/subscription.py @@ -44,6 +44,15 @@ class DevicesDeleteAllResponse(BaseModel): success: bool +class PromocodeActivateRequest(BaseModel): + code: str + + +class PromocodeActivateResponse(BaseModel): + success: bool + reward_type: str + + class ReissueResponse(BaseModel): success: bool From 63308e20ebf5b2718aa3a5eb58b25b388911f8e5 Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 21:56:38 +0500 Subject: [PATCH 07/10] feat(web): add trial activation endpoint --- src/web/endpoints/public/subscription.py | 36 ++++++++++++++++++++++++ src/web/schemas/__init__.py | 2 ++ src/web/schemas/subscription.py | 4 +++ 3 files changed, 42 insertions(+) diff --git a/src/web/endpoints/public/subscription.py b/src/web/endpoints/public/subscription.py index 034de251..ffec11c3 100644 --- a/src/web/endpoints/public/subscription.py +++ b/src/web/endpoints/public/subscription.py @@ -8,6 +8,7 @@ from src.application.common import Remnawave from src.application.common.dao import ( PaymentGatewayDao, + PlanDao, SubscriptionDao, ) from src.application.dto import PlanDto, PlanSnapshotDto, UserDto @@ -29,6 +30,10 @@ DeleteUserDeviceDto, ReissueSubscription, ) +from src.application.use_cases.subscription.commands.purchase import ( + ActivateTrialSubscription, + ActivateTrialSubscriptionDto, +) from src.application.use_cases.user.queries.plans import GetAvailablePlans from src.core.enums import ( PaymentGatewayType, @@ -41,6 +46,7 @@ PromocodeExpiredError, PromocodeNotAvailableError, PromocodeNotFoundError, + TrialNotAvailableError, ) from src.web.schemas import ( DeviceDeleteResponse, @@ -59,6 +65,7 @@ ReissueResponse, SubscriptionInfoResponse, SubscriptionOffersResponse, + TrialActivateResponse, ) from ._common import CurrentUser @@ -103,6 +110,11 @@ async def _get_available_plan_by_code( return next((plan for plan in plans if plan.public_code == plan_code), None) +async def _resolve_trial_plan(plan_dao: PlanDao) -> Optional[PlanDto]: + trial_plans = await plan_dao.get_active_trial_plans() + return trial_plans[0] if trial_plans else None + + async def _validate_gateway_for_web( gateway_type: PaymentGatewayType, payment_gateway_dao: PaymentGatewayDao, @@ -237,6 +249,30 @@ async def activate_promocode_web( return PromocodeActivateResponse(success=True, reward_type=promo.reward_type.value) +@router.post("/trial", response_model=TrialActivateResponse) +@inject +async def activate_trial_web( + user: CurrentUser, + plan_dao: FromDishka[PlanDao], + activate_trial: FromDishka[ActivateTrialSubscription], +) -> TrialActivateResponse: + _assert_web_purchase_email_verified(user) + + if not user.is_trial_available: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Trial is not available") + + plan = await _resolve_trial_plan(plan_dao) + if not plan or not plan.durations: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active trial plan") + + plan_snapshot = PlanSnapshotDto.from_plan(plan, plan.durations[0].days) + try: + await activate_trial.system(ActivateTrialSubscriptionDto(user=user, plan=plan_snapshot)) + except TrialNotAvailableError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e + return TrialActivateResponse(success=True) + + @router.post("/purchase", response_model=PaymentInitResponse) @inject async def purchase_subscription( diff --git a/src/web/schemas/__init__.py b/src/web/schemas/__init__.py index 0c9401a8..8916c42e 100644 --- a/src/web/schemas/__init__.py +++ b/src/web/schemas/__init__.py @@ -41,6 +41,7 @@ ReissueResponse, SubscriptionInfoResponse, SubscriptionOffersResponse, + TrialActivateResponse, ) __all__ = [ @@ -89,4 +90,5 @@ "ReissueResponse", "SubscriptionInfoResponse", "SubscriptionOffersResponse", + "TrialActivateResponse", ] diff --git a/src/web/schemas/subscription.py b/src/web/schemas/subscription.py index 32bbdf0e..1d696aa2 100644 --- a/src/web/schemas/subscription.py +++ b/src/web/schemas/subscription.py @@ -53,6 +53,10 @@ class PromocodeActivateResponse(BaseModel): reward_type: str +class TrialActivateResponse(BaseModel): + success: bool + + class ReissueResponse(BaseModel): success: bool From be7af5d7fe915bd8dd42a7686b3688df8d5c65ea Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 21:57:54 +0500 Subject: [PATCH 08/10] feat(auth): add Telegram Mini App initData validation --- src/application/use_cases/auth/_telegram.py | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/application/use_cases/auth/_telegram.py b/src/application/use_cases/auth/_telegram.py index 6bcbb999..d85dd64e 100644 --- a/src/application/use_cases/auth/_telegram.py +++ b/src/application/use_cases/auth/_telegram.py @@ -2,6 +2,7 @@ import hmac import time from typing import Any +from urllib.parse import parse_qsl from src.core.constants import TELEGRAM_AUTH_MAX_AGE_SECONDS @@ -22,3 +23,27 @@ def verify_telegram_auth(data: dict[str, Any], bot_token: str) -> bool: hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, telegram_hash) + + +def parse_webapp_init_data(init_data: str) -> dict[str, str]: + return dict(parse_qsl(init_data, keep_blank_values=True)) + + +def verify_telegram_webapp_init_data(init_data: str, bot_token: str) -> bool: + fields = parse_webapp_init_data(init_data) + telegram_hash = fields.pop("hash", "") + if not telegram_hash: + return False + + auth_date = int(fields.get("auth_date", 0)) + if int(time.time()) - auth_date > TELEGRAM_AUTH_MAX_AGE_SECONDS: + return False + + data_check_string = "\n".join(f"{k}={fields[k]}" for k in sorted(fields)) + secret_key = hmac.new(b"WebAppData", bot_token.encode("utf-8"), hashlib.sha256).digest() + expected = hmac.new( + secret_key, + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, telegram_hash) From 54fd7b909cbb788080586080c7e0cca7348f24ba Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 22:02:45 +0500 Subject: [PATCH 09/10] feat(auth): add Telegram Mini App authentication endpoint --- src/application/use_cases/auth/__init__.py | 3 +- .../use_cases/auth/commands/telegram.py | 109 +++++++++++++----- src/web/endpoints/public/auth.py | 15 +++ src/web/schemas/__init__.py | 2 + src/web/schemas/auth.py | 4 + 5 files changed, 106 insertions(+), 27 deletions(-) diff --git a/src/application/use_cases/auth/__init__.py b/src/application/use_cases/auth/__init__.py index 900dd0bd..6c1a2c7e 100644 --- a/src/application/use_cases/auth/__init__.py +++ b/src/application/use_cases/auth/__init__.py @@ -11,13 +11,14 @@ from .commands.password import ChangePassword from .commands.register import RegisterEmailUser from .commands.session import RefreshSession -from .commands.telegram import AuthenticateTelegram, LinkTelegram +from .commands.telegram import AuthenticateTelegram, AuthenticateTelegramWebApp, LinkTelegram AUTH_USE_CASES: Final[tuple[type[Interactor], ...]] = ( RegisterEmailUser, LoginEmailUser, RefreshSession, AuthenticateTelegram, + AuthenticateTelegramWebApp, LinkTelegram, ChangePassword, ChangeEmail, diff --git a/src/application/use_cases/auth/commands/telegram.py b/src/application/use_cases/auth/commands/telegram.py index 0577e88c..17638e8b 100644 --- a/src/application/use_cases/auth/commands/telegram.py +++ b/src/application/use_cases/auth/commands/telegram.py @@ -1,3 +1,4 @@ +import json from dataclasses import dataclass from typing import Any @@ -9,7 +10,11 @@ from src.application.common.policy import Permission from src.application.common.uow import UnitOfWork from src.application.dto import UserDto -from src.application.use_cases.auth._telegram import verify_telegram_auth +from src.application.use_cases.auth._telegram import ( + parse_webapp_init_data, + verify_telegram_auth, + verify_telegram_webapp_init_data, +) from src.application.use_cases.user.commands.web_registration import ( RegisterWebUser, RegisterWebUserDto, @@ -27,6 +32,41 @@ class TelegramAuthData: payload: dict[str, Any] +async def _get_or_create_telegram_user( + user_dao: UserDao, + register_web_user: RegisterWebUser, + config: AppConfig, + data: TelegramAuthData, +) -> UserDto: + user = await user_dao.get_by_telegram_id(data.id) + if user: + if user.is_blocked: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + return user + + name_parts = [data.first_name] + if data.last_name: + name_parts.append(data.last_name) + + new_user = UserDto( + telegram_id=data.id, + auth_type=AuthType.TELEGRAM, + username=data.username, + name=" ".join(name_parts), + language=config.default_locale, + ) + + try: + return await register_web_user.system(RegisterWebUserDto(user=new_user)) + except IntegrityError as e: + existing = await user_dao.get_by_telegram_id(data.id) + if existing: + return existing + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User creation conflict" + ) from e + + class AuthenticateTelegram(Interactor[TelegramAuthData, UserDto]): required_permission = None @@ -47,34 +87,51 @@ async def _execute(self, actor: UserDto, data: TelegramAuthData) -> UserDto: status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Telegram auth data", ) - - user = await self.user_dao.get_by_telegram_id(data.id) - if user: - if user.is_blocked: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") - return user - - name_parts = [data.first_name] - if data.last_name: - name_parts.append(data.last_name) - - new_user = UserDto( - telegram_id=data.id, - auth_type=AuthType.TELEGRAM, - username=data.username, - name=" ".join(name_parts), - language=self.config.default_locale, + return await _get_or_create_telegram_user( + self.user_dao, self.register_web_user, self.config, data ) - try: - return await self.register_web_user.system(RegisterWebUserDto(user=new_user)) - except IntegrityError as e: - existing = await self.user_dao.get_by_telegram_id(data.id) - if existing: - return existing + +class AuthenticateTelegramWebApp(Interactor[str, UserDto]): + required_permission = None + + def __init__( + self, + config: AppConfig, + user_dao: UserDao, + register_web_user: RegisterWebUser, + ) -> None: + self.config = config + self.user_dao = user_dao + self.register_web_user = register_web_user + + async def _execute(self, actor: UserDto, data: str) -> UserDto: + bot_token = self.config.bot.token.get_secret_value() + if not verify_telegram_webapp_init_data(data, bot_token): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User creation conflict" - ) from e + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Telegram WebApp init data", + ) + + fields = parse_webapp_init_data(data) + raw_user = fields.get("user") + if not raw_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing user in init data", + ) + user_payload = json.loads(raw_user) + + auth_data = TelegramAuthData( + id=int(user_payload["id"]), + first_name=str(user_payload.get("first_name", "")), + last_name=user_payload.get("last_name"), + username=user_payload.get("username"), + payload=user_payload, + ) + return await _get_or_create_telegram_user( + self.user_dao, self.register_web_user, self.config, auth_data + ) @dataclass diff --git a/src/web/endpoints/public/auth.py b/src/web/endpoints/public/auth.py index bac886ba..c572b7cc 100644 --- a/src/web/endpoints/public/auth.py +++ b/src/web/endpoints/public/auth.py @@ -21,6 +21,7 @@ from src.application.use_cases.auth.commands.session import RefreshSession, RefreshSessionDto from src.application.use_cases.auth.commands.telegram import ( AuthenticateTelegram, + AuthenticateTelegramWebApp, LinkTelegram, LinkTelegramData, TelegramAuthData, @@ -42,6 +43,7 @@ RequestEmailVerificationCodeRequest, RequestEmailVerificationCodeResponse, TelegramAuthRequest, + TelegramWebAppAuthRequest, ) from ._common import ( @@ -165,6 +167,19 @@ async def telegram_login( return await _issue_and_set(user, response, config, auth_session) +@router.post("/telegram/webapp", response_model=AuthResponse) +@inject +async def telegram_webapp_login( + body: TelegramWebAppAuthRequest, + response: Response, + config: FromDishka[AppConfig], + authenticate_webapp: FromDishka[AuthenticateTelegramWebApp], + auth_session: FromDishka[AuthSessionDao], +) -> AuthResponse: + user = await authenticate_webapp.system(body.init_data) + return await _issue_and_set(user, response, config, auth_session) + + @router.post("/telegram/link", response_model=MeResponse) @inject async def link_telegram_account( diff --git a/src/web/schemas/__init__.py b/src/web/schemas/__init__.py index 8916c42e..a79c6c9c 100644 --- a/src/web/schemas/__init__.py +++ b/src/web/schemas/__init__.py @@ -14,6 +14,7 @@ RequestEmailVerificationCodeRequest, RequestEmailVerificationCodeResponse, TelegramAuthRequest, + TelegramWebAppAuthRequest, ) from .health import ( DatabaseStatusSchema, @@ -67,6 +68,7 @@ "RequestEmailVerificationCodeRequest", "RequestEmailVerificationCodeResponse", "TelegramAuthRequest", + "TelegramWebAppAuthRequest", # plans "PublicPlanLandingListResponse", "PublicPlanLandingResponse", diff --git a/src/web/schemas/auth.py b/src/web/schemas/auth.py index e4c6d08e..1c9fe9e5 100644 --- a/src/web/schemas/auth.py +++ b/src/web/schemas/auth.py @@ -132,5 +132,9 @@ class TelegramAuthRequest(BaseModel): hash: str +class TelegramWebAppAuthRequest(BaseModel): + init_data: str + + class LogoutResponse(BaseModel): success: bool From 0fb24c3fa8bb6415cbac10e28a1307a8efb64b7b Mon Sep 17 00:00:00 2001 From: Ilay Date: Fri, 19 Jun 2026 22:33:27 +0500 Subject: [PATCH 10/10] chore: release v0.8.1 --- src/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__version__.py b/src/__version__.py index 777f190d..8088f751 100644 --- a/src/__version__.py +++ b/src/__version__.py @@ -1 +1 @@ -__version__ = "0.8.0" +__version__ = "0.8.1"