From 1d8ad0f24d34393fdfcc05fc4a945758105e19f0 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Fri, 5 Jun 2026 21:37:37 +0300 Subject: [PATCH 01/14] fix(payment): increase timeout error on create_transaction --- backend/bot/payment_providers/platega.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index e4f15fc..879d643 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -157,7 +157,7 @@ class PlategaService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=20) + self._init_http_client(total_timeout=60) if not self.configured: logging.warning( "PlategaService initialized but not fully configured. Payments disabled." From 1158a2d8358c33574ff3f03daa9781a550c60fc3 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Fri, 5 Jun 2026 21:50:07 +0300 Subject: [PATCH 02/14] chore: update action jobs for fork support --- .github/workflows/_docker-build-push.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/_docker-build-push.yml b/.github/workflows/_docker-build-push.yml index e428d86..47f72f2 100644 --- a/.github/workflows/_docker-build-push.yml +++ b/.github/workflows/_docker-build-push.yml @@ -2,7 +2,7 @@ name: Docker build & push (reusable) # Reusable workflow that builds the three image targets defined in # deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes -# them to both ghcr.io and Docker Hub under the 3252a8/ namespace. +# them to both ghcr.io and Docker Hub under the repository owner's namespace. # # Called by: # - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev @@ -84,13 +84,19 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Resolve image namespace + id: image_namespace + run: | + owner="${{ github.repository_owner }}" + echo "owner=${owner,,}" >> "$GITHUB_OUTPUT" + - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: images: | - 3252a8/${{ matrix.image }} - ghcr.io/3252a8/${{ matrix.image }} + ${{ steps.image_namespace.outputs.owner }}/${{ matrix.image }} + ghcr.io/${{ steps.image_namespace.outputs.owner }}/${{ matrix.image }} tags: | type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }} type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }} From 766f2a5780df1124f6bbac65a089ca828235b3d7 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Fri, 5 Jun 2026 23:40:02 +0300 Subject: [PATCH 03/14] Add global payment request timeout setting --- .../bot/app/web/admin_settings_manifest.py | 10 ++++++++++ backend/bot/payment_providers/freekassa.py | 2 +- backend/bot/payment_providers/heleket.py | 2 +- backend/bot/payment_providers/paykilla.py | 2 +- backend/bot/payment_providers/platega.py | 2 +- backend/bot/payment_providers/severpay.py | 2 +- backend/bot/payment_providers/wata.py | 2 +- backend/config/settings.py | 5 +++++ docs/configuration/env-vars.md | 1 + .../webapp/settingsManifest.generated.json | 20 +++++++++++++++++++ locales/en.json | 3 +++ locales/ru.json | 3 +++ tests/test_admin_settings_manifest_i18n.py | 6 ++++++ 13 files changed, 54 insertions(+), 6 deletions(-) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index a4c4c57..76570b9 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -348,6 +348,16 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Английская версия текста на этапе оплаты.", subsection="checkout", ), + SettingField( + "PAYMENT_REQUEST_TIMEOUT_SECONDS", + "float", + "payments", + "Таймаут запроса к провайдеру", + "Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.", + optional=False, + min=1, + subsection="checkout", + ), # ─── Payment providers (toggles) ─────────────────────────────── # Common SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"), diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 1d830a5..8212f1d 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -153,7 +153,7 @@ class FreeKassaService(HttpClientMixin): self.default_currency: str = default_payment_currency_code_for_settings(settings).upper() self.api_base_url: str = "https://api.fk.life/v1" - self._init_http_client(total_timeout=15) + self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) self._nonce_lock = asyncio.Lock() self._last_nonce = int(time.time() * 1000) diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index 5faa786..f11bed5 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -243,7 +243,7 @@ class HeleketService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=20) + self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning( "HeleketService initialized but not fully configured. Payments disabled." diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py index 7917d83..0242f6e 100644 --- a/backend/bot/payment_providers/paykilla.py +++ b/backend/bot/payment_providers/paykilla.py @@ -369,7 +369,7 @@ class PaykillaService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=20) + self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning( "PaykillaService initialized but not fully configured. Payments disabled." diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 879d643..a05ffe2 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -157,7 +157,7 @@ class PlategaService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=60) + self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning( "PlategaService initialized but not fully configured. Payments disabled." diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 2822e4a..92fd910 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -136,7 +136,7 @@ class SeverPayService(HttpClientMixin): self.referral_service = referral_service self._default_return_url = default_return_url - self._init_http_client(total_timeout=15) + self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning( diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 3d0109e..4c4e2ec 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -206,7 +206,7 @@ class WataService(HttpClientMixin): self._default_return_url = default_return_url self._cached_public_key_pem = None # populated by webhook on first verify - self._init_http_client(total_timeout=10) + self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS) if not self.configured: logging.warning("WataService initialized but not fully configured. Payments disabled.") diff --git a/backend/config/settings.py b/backend/config/settings.py index 43795a3..7568d55 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -319,6 +319,11 @@ class Settings(BaseSettings): default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN, description="English subscription description shown before purchase/renewal options.", ) + PAYMENT_REQUEST_TIMEOUT_SECONDS: float = Field( + default=20, + ge=1, + description="Maximum total time for one payment provider API request, in seconds.", + ) MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 6bea97d..3f93d9d 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -200,6 +200,7 @@ | `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. | +| `PAYMENT_REQUEST_TIMEOUT_SECONDS` | Общий таймаут одного API-запроса к платёжному провайдеру, в секундах. По умолчанию `20`. | | `PAYMENT__WEBAPP_LABEL_RU` / `PAYMENT__WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. | | `PAYMENT__WEBAPP_ICON` | Lucide-иконка кнопки в Web App. | | `PAYMENT__TELEGRAM_LABEL_RU` / `PAYMENT__TELEGRAM_LABEL_EN` | Текст кнопки в Telegram. | diff --git a/frontend/src/lib/webapp/settingsManifest.generated.json b/frontend/src/lib/webapp/settingsManifest.generated.json index 36ac6d3..00ef193 100644 --- a/frontend/src/lib/webapp/settingsManifest.generated.json +++ b/frontend/src/lib/webapp/settingsManifest.generated.json @@ -603,6 +603,26 @@ "overridden": false, "updated_at": null }, + { + "key": "PAYMENT_REQUEST_TIMEOUT_SECONDS", + "type": "float", + "section": "payments", + "section_order": 4, + "subsection": "checkout", + "label": "Таймаут запроса к провайдеру", + "description": "Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.", + "i18n_label_key": "admin_settings_field_payment_request_timeout_seconds_label", + "i18n_description_key": "admin_settings_field_payment_request_timeout_seconds_description", + "i18n_subsection_key": "admin_settings_subsection_checkout", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": false, + "secret": false, + "min": 1, + "value": "", + "overridden": false, + "updated_at": null + }, { "key": "STARS_ENABLED", "type": "bool", diff --git a/locales/en.json b/locales/en.json index f234711..cd94f0d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1170,6 +1170,7 @@ "admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_wata": "Wata", "admin_settings_subsection_heleket": "Heleket", + "admin_settings_subsection_paykilla": "PayKilla", "admin_settings_provider_webhook_url": "Webhook URL", "admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.", "admin_settings_panel_webhook_url_hint": "Use this URL as WEBHOOK_URL in Remnawave Panel.", @@ -1676,6 +1677,8 @@ "admin_settings_field_subscription_purchase_description_ru_description": "Russian text shown during checkout.", "admin_settings_field_subscription_purchase_description_en_label": "Subscription description (EN)", "admin_settings_field_subscription_purchase_description_en_description": "English text shown during checkout.", + "admin_settings_field_payment_request_timeout_seconds_label": "Payment provider request timeout", + "admin_settings_field_payment_request_timeout_seconds_description": "Maximum total time for one payment provider API request, in seconds.", "admin_settings_field_stars_enabled_label": "Stars Enabled", "admin_settings_field_yookassa_enabled_label": "YooKassa Enabled", "admin_settings_field_yookassa_shop_id_label": "YooKassa Shop ID", diff --git a/locales/ru.json b/locales/ru.json index 6900916..ecacb52 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1170,6 +1170,7 @@ "admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_wata": "Wata", "admin_settings_subsection_heleket": "Heleket", + "admin_settings_subsection_paykilla": "PayKilla", "admin_settings_provider_webhook_url": "Webhook URL", "admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.", "admin_settings_panel_webhook_url_hint": "Укажите этот адрес как WEBHOOK_URL в Remnawave Panel.", @@ -1676,6 +1677,8 @@ "admin_settings_field_subscription_purchase_description_ru_description": "Русская версия текста на этапе оплаты.", "admin_settings_field_subscription_purchase_description_en_label": "Описание подписки (EN)", "admin_settings_field_subscription_purchase_description_en_description": "Английская версия текста на этапе оплаты.", + "admin_settings_field_payment_request_timeout_seconds_label": "Таймаут запроса к провайдеру", + "admin_settings_field_payment_request_timeout_seconds_description": "Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.", "admin_settings_field_stars_enabled_label": "Telegram Stars", "admin_settings_field_yookassa_enabled_label": "Включена", "admin_settings_field_yookassa_shop_id_label": "Shop ID", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 1228bcd..fed24a5 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -24,6 +24,7 @@ SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = ( "SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED", "SUBSCRIPTION_PURCHASE_DESCRIPTION_RU", "SUBSCRIPTION_PURCHASE_DESCRIPTION_EN", + "PAYMENT_REQUEST_TIMEOUT_SECONDS", ) SUBSCRIPTION_GUIDE_SETTINGS = ( @@ -177,6 +178,11 @@ def test_support_settings_i18n_keys_exist_in_admin_locales(): def test_subscription_purchase_description_settings_i18n_keys_exist(): manifest = _manifest_by_key() + timeout_field = manifest["PAYMENT_REQUEST_TIMEOUT_SECONDS"] + assert timeout_field["type"] == "float" + assert timeout_field["optional"] is False + assert timeout_field["min"] == 1 + for language in ("ru", "en"): messages = _locale(language) for setting_key in SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS: From cc74ddec10665c761cff5c735d5c8b033e00bf63 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 00:37:03 +0300 Subject: [PATCH 04/14] Log trusted forwarded client IPs --- backend/bot/app/web/web_server.py | 43 +++++++++++++++++++++++++++++-- tests/test_security.py | 22 ++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/backend/bot/app/web/web_server.py b/backend/bot/app/web/web_server.py index 2b1ae96..797a531 100644 --- a/backend/bot/app/web/web_server.py +++ b/backend/bot/app/web/web_server.py @@ -1,13 +1,16 @@ import asyncio +import functools import hmac import logging from aiogram import Bot, Dispatcher from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application from aiohttp import web +from aiohttp.web_log import AccessLogger, KeyMethod from sqlalchemy.orm import sessionmaker from bot.payment_providers import iter_provider_specs, iter_service_keys +from bot.utils.request_security import request_client_ip from config.settings import Settings @@ -18,6 +21,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler): return hmac.compare_digest(telegram_secret_token, self.secret_token) +class TrustedProxyAccessLogger(AccessLogger): + """Aiohttp access logger that respects trusted X-Forwarded-For headers.""" + + def compile_format(self, log_format): + methods = [] + for atom in self.FORMAT_RE.findall(log_format): + if atom[1] == "": + format_key = self.LOG_FORMAT_MAP[atom[0]] + method = getattr(type(self), f"_format_{atom[0]}", None) + if method is None: + method = getattr(AccessLogger, f"_format_{atom[0]}") + methods.append(KeyMethod(format_key, method)) + else: + format_key = (self.LOG_FORMAT_MAP[atom[2]], atom[1]) + method = getattr(type(self), f"_format_{atom[2]}", None) + if method is None: + method = getattr(AccessLogger, f"_format_{atom[2]}") + methods.append(KeyMethod(format_key, functools.partial(method, atom[1]))) + + compiled = self.FORMAT_RE.sub(r"%s", log_format) + compiled = self.CLEANUP_RE.sub(r"%\1", compiled) + return compiled, methods + + @staticmethod + def _format_a(request, response, time): + if request is None: + return "-" + settings = request.app.get("settings") if hasattr(request, "app") else None + trusted_proxies = getattr(settings, "trusted_proxies", None) + client_ip = request_client_ip(request, trusted_proxies=trusted_proxies) + return client_ip or "-" + + def _inject_shared_instances( app: web.Application, dp: Dispatcher, @@ -110,7 +146,7 @@ async def build_and_start_web_app( runners = [] - webhooks_runner = web.AppRunner(app) + webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger) await webhooks_runner.setup() runners.append(webhooks_runner) site = web.TCPSite( @@ -133,7 +169,10 @@ async def build_and_start_web_app( settings, async_session_factory, ) - subscription_runner = web.AppRunner(subscription_app) + subscription_runner = web.AppRunner( + subscription_app, + access_log_class=TrustedProxyAccessLogger, + ) await subscription_runner.setup() runners.append(subscription_runner) subscription_site = web.TCPSite( diff --git a/tests/test_security.py b/tests/test_security.py index fed1dc4..e8b3f58 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -11,6 +11,7 @@ from aiohttp import web from bot.app.web import admin_api, subscription_webapp from bot.app.web.admin_api_impl import settings as admin_settings_routes +from bot.app.web.web_server import TrustedProxyAccessLogger from bot.app.web.webapp import account as account_routes from bot.app.web.webapp_auth import ( create_telegram_oauth_nonce, @@ -47,6 +48,27 @@ class RequestSecurityTests(unittest.IsolatedAsyncioTestCase): "198.51.100.7", ) + async def test_access_logger_uses_forwarded_ip_only_for_trusted_proxy(self): + trusted_request = SimpleNamespace( + remote="172.19.0.7", + headers={"X-Forwarded-For": "203.0.113.10"}, + app={"settings": SimpleNamespace(trusted_proxies=["172.19.0.0/16"])}, + ) + untrusted_request = SimpleNamespace( + remote="172.19.0.7", + headers={"X-Forwarded-For": "203.0.113.10"}, + app={"settings": SimpleNamespace(trusted_proxies=["127.0.0.1"])}, + ) + + self.assertEqual( + TrustedProxyAccessLogger._format_a(trusted_request, object(), 0), + "203.0.113.10", + ) + self.assertEqual( + TrustedProxyAccessLogger._format_a(untrusted_request, object(), 0), + "172.19.0.7", + ) + async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self): request = SimpleNamespace( app={ From f07031f32c909836038a79c122456da9b9efcf5c Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 00:45:21 +0300 Subject: [PATCH 05/14] fix(security): resolve forwarded client ip chain --- backend/bot/utils/request_security.py | 31 ++++++++++++++++++++------- tests/test_security.py | 21 +++++++++++++----- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/backend/bot/utils/request_security.py b/backend/bot/utils/request_security.py index bba0bc5..b1b579e 100644 --- a/backend/bot/utils/request_security.py +++ b/backend/bot/utils/request_security.py @@ -34,12 +34,27 @@ def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]: return None -def _last_forwarded_ip(header_value: str) -> Optional[str]: - candidates = [item.strip() for item in header_value.split(",") if item.strip()] - if not candidates: +def _forwarded_ips(header_value: str) -> list[ipaddress._BaseAddress]: + parsed: list[ipaddress._BaseAddress] = [] + for item in header_value.split(","): + ip = _parse_ip(item.strip()) + if ip is not None: + parsed.append(ip) + return parsed + + +def _trusted_forwarded_ip( + header_value: str, + trusted_networks: Sequence[ipaddress._BaseNetwork], +) -> Optional[str]: + forwarded_ips = _forwarded_ips(header_value) + if not forwarded_ips: return None - candidate = candidates[-1] - return candidate if _parse_ip(candidate) is not None else None + + for ip in reversed(forwarded_ips): + if not any(ip in network for network in trusted_networks): + return str(ip) + return str(forwarded_ips[0]) def request_client_ip( @@ -53,15 +68,15 @@ def request_client_ip( if remote_ip and forwarded_for: trusted_networks = parse_ip_entries(trusted_proxies) if any(remote_ip in network for network in trusted_networks): - forwarded_ip = _last_forwarded_ip(forwarded_for) + forwarded_ip = _trusted_forwarded_ip(forwarded_for, trusted_networks) if forwarded_ip: return forwarded_ip if remote_ip: return str(remote_ip) - forwarded_ip = _last_forwarded_ip(forwarded_for) - return forwarded_ip + forwarded_ips = _forwarded_ips(forwarded_for) + return str(forwarded_ips[-1]) if forwarded_ips else None def ip_in_allowlist( diff --git a/tests/test_security.py b/tests/test_security.py index e8b3f58..22a56bf 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -48,15 +48,26 @@ class RequestSecurityTests(unittest.IsolatedAsyncioTestCase): "198.51.100.7", ) + async def test_request_client_ip_skips_trusted_forwarded_proxy_chain(self): + request = SimpleNamespace( + remote="172.19.0.6", + headers={"X-Forwarded-For": "203.0.113.10, 172.19.0.7"}, + ) + + self.assertEqual( + request_client_ip(request, trusted_proxies=["172.19.0.0/16"]), + "203.0.113.10", + ) + async def test_access_logger_uses_forwarded_ip_only_for_trusted_proxy(self): trusted_request = SimpleNamespace( - remote="172.19.0.7", - headers={"X-Forwarded-For": "203.0.113.10"}, + remote="172.19.0.6", + headers={"X-Forwarded-For": "203.0.113.10, 172.19.0.7"}, app={"settings": SimpleNamespace(trusted_proxies=["172.19.0.0/16"])}, ) untrusted_request = SimpleNamespace( - remote="172.19.0.7", - headers={"X-Forwarded-For": "203.0.113.10"}, + remote="172.19.0.6", + headers={"X-Forwarded-For": "203.0.113.10, 172.19.0.7"}, app={"settings": SimpleNamespace(trusted_proxies=["127.0.0.1"])}, ) @@ -66,7 +77,7 @@ class RequestSecurityTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual( TrustedProxyAccessLogger._format_a(untrusted_request, object(), 0), - "172.19.0.7", + "172.19.0.6", ) async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self): From e5b0daf639bb62732d5cf8223c63d10b74961ee2 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 15:39:34 +0300 Subject: [PATCH 06/14] fix(payments): avoid stale provider connections --- .../payment_providers/shared/http_client.py | 11 +++++++++-- tests/test_payment_http_client.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/test_payment_http_client.py diff --git a/backend/bot/payment_providers/shared/http_client.py b/backend/bot/payment_providers/shared/http_client.py index c5dbd6d..b8cb1d4 100644 --- a/backend/bot/payment_providers/shared/http_client.py +++ b/backend/bot/payment_providers/shared/http_client.py @@ -4,7 +4,7 @@ import json import logging from typing import Any, Callable, Dict, Mapping, Optional, Tuple -from aiohttp import ClientSession, ClientTimeout +from aiohttp import ClientSession, ClientTimeout, TCPConnector SuccessCheck = Callable[[int, Any], bool] @@ -76,18 +76,25 @@ class HttpClientMixin: Each subclass calls ``self._init_http_client(total_timeout=...)`` from ``__init__`` and inherits ``_get_session`` / ``close``. The session is created on first use and recreated transparently if it was closed. + + Payment provider calls are infrequent but user-facing, so the default + connector does not reuse TCP connections. This avoids intermittent hangs + on stale keep-alive sockets after long idle periods. """ _timeout: ClientTimeout _session: Optional[ClientSession] + _connector_force_close: bool def _init_http_client(self, *, total_timeout: float = 20.0) -> None: self._timeout = ClientTimeout(total=total_timeout) self._session = None + self._connector_force_close = True async def _get_session(self) -> ClientSession: if self._session is None or self._session.closed: - self._session = ClientSession(timeout=self._timeout) + connector = TCPConnector(force_close=self._connector_force_close) + self._session = ClientSession(timeout=self._timeout, connector=connector) return self._session async def close(self) -> None: diff --git a/tests/test_payment_http_client.py b/tests/test_payment_http_client.py new file mode 100644 index 0000000..4fbfea8 --- /dev/null +++ b/tests/test_payment_http_client.py @@ -0,0 +1,18 @@ +import unittest + +from bot.payment_providers.shared.http_client import HttpClientMixin + + +class _DummyHttpClient(HttpClientMixin): + def __init__(self): + self._init_http_client(total_timeout=20) + + +class PaymentHttpClientTests(unittest.IsolatedAsyncioTestCase): + async def test_http_client_does_not_reuse_provider_tcp_connections(self): + client = _DummyHttpClient() + try: + session = await client._get_session() + self.assertTrue(session.connector.force_close) + finally: + await client.close() From a75d2d7ac0231a27167beee090fa44666b2ff640 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 17:10:52 +0300 Subject: [PATCH 07/14] fix(payments): retry provider connect failures --- .../payment_providers/shared/http_client.py | 104 +++++++++++++----- tests/test_payment_http_client.py | 16 ++- 2 files changed, 91 insertions(+), 29 deletions(-) diff --git a/backend/bot/payment_providers/shared/http_client.py b/backend/bot/payment_providers/shared/http_client.py index b8cb1d4..6d38239 100644 --- a/backend/bot/payment_providers/shared/http_client.py +++ b/backend/bot/payment_providers/shared/http_client.py @@ -1,12 +1,19 @@ from __future__ import annotations +import asyncio import json import logging from typing import Any, Callable, Dict, Mapping, Optional, Tuple -from aiohttp import ClientSession, ClientTimeout, TCPConnector +from aiohttp import ClientError, ClientSession, ClientTimeout, TCPConnector, TraceConfig SuccessCheck = Callable[[int, Any], bool] +_TRANSPORT_ATTEMPTS = 2 +_PAYMENT_REQUEST_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/125.0.0.0 Safari/537.36" +) def http_ok(status: int, _body: Any) -> bool: @@ -14,6 +21,29 @@ def http_ok(status: int, _body: Any) -> bool: return status == 200 +def _trace_request_ctx(trace_config_ctx: Any) -> Optional[dict]: + ctx = getattr(trace_config_ctx, "trace_request_ctx", None) + return ctx if isinstance(ctx, dict) else None + + +async def _mark_request_headers_sent(session, trace_config_ctx, params) -> None: + ctx = _trace_request_ctx(trace_config_ctx) + if ctx is not None: + ctx["headers_sent"] = True + + +def _payment_trace_config() -> TraceConfig: + trace_config = TraceConfig() + trace_config.on_request_headers_sent.append(_mark_request_headers_sent) + return trace_config + + +def _should_retry_transport_error(exc: Exception, trace_ctx: Mapping[str, Any]) -> bool: + if trace_ctx.get("headers_sent"): + return False + return isinstance(exc, (asyncio.TimeoutError, ClientError, OSError)) + + async def post_json_request( session: ClientSession, url: str, @@ -29,34 +59,47 @@ async def post_json_request( returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers can decide what to do (typically: mark the payment as ``failed_creation``). """ - try: - async with session.post( - url, - json=body, - headers=dict(headers) if headers else None, - ) as response: - response_text = await response.text() - try: - response_data = json.loads(response_text) if response_text else {} - except json.JSONDecodeError: - logging.error("%s: invalid JSON response: %s", log_prefix, response_text) - return False, { - "status": response.status, - "message": "invalid_json", - "raw": response_text, - } - if not is_success(response.status, response_data): - logging.error( - "%s: API returned error (status=%s, body=%s)", + for attempt in range(1, _TRANSPORT_ATTEMPTS + 1): + trace_ctx: dict[str, Any] = {"headers_sent": False} + try: + async with session.post( + url, + json=body, + headers=dict(headers) if headers else None, + trace_request_ctx=trace_ctx, + ) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("%s: invalid JSON response: %s", log_prefix, response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + if not is_success(response.status, response_data): + logging.error( + "%s: API returned error (status=%s, body=%s)", + log_prefix, + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + return True, response_data + except Exception as exc: + if attempt < _TRANSPORT_ATTEMPTS and _should_retry_transport_error(exc, trace_ctx): + logging.warning( + "%s: transport failed before request headers were sent; retrying (%s/%s): %s", # noqa: E501 log_prefix, - response.status, - response_data, + attempt + 1, + _TRANSPORT_ATTEMPTS, + exc, ) - return False, {"status": response.status, "message": response_data} - return True, response_data - except Exception as exc: - logging.exception("%s: request failed.", log_prefix) - return False, {"message": str(exc)} + continue + logging.exception("%s: request failed.", log_prefix) + return False, {"message": str(exc)} + return False, {"message": "request_failed"} def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]: @@ -94,7 +137,12 @@ class HttpClientMixin: async def _get_session(self) -> ClientSession: if self._session is None or self._session.closed: connector = TCPConnector(force_close=self._connector_force_close) - self._session = ClientSession(timeout=self._timeout, connector=connector) + self._session = ClientSession( + timeout=self._timeout, + connector=connector, + headers={"User-Agent": _PAYMENT_REQUEST_USER_AGENT}, + trace_configs=[_payment_trace_config()], + ) return self._session async def close(self) -> None: diff --git a/tests/test_payment_http_client.py b/tests/test_payment_http_client.py index 4fbfea8..a46602a 100644 --- a/tests/test_payment_http_client.py +++ b/tests/test_payment_http_client.py @@ -1,6 +1,10 @@ import unittest -from bot.payment_providers.shared.http_client import HttpClientMixin +from bot.payment_providers.shared.http_client import ( + HttpClientMixin, + _PAYMENT_REQUEST_USER_AGENT, + _should_retry_transport_error, +) class _DummyHttpClient(HttpClientMixin): @@ -14,5 +18,15 @@ class PaymentHttpClientTests(unittest.IsolatedAsyncioTestCase): try: session = await client._get_session() self.assertTrue(session.connector.force_close) + self.assertTrue(session.trace_configs) + self.assertEqual(session.headers["User-Agent"], _PAYMENT_REQUEST_USER_AGENT) finally: await client.close() + + async def test_http_client_retries_only_before_headers_are_sent(self): + self.assertTrue( + _should_retry_transport_error(TimeoutError(), {"headers_sent": False}) + ) + self.assertFalse( + _should_retry_transport_error(TimeoutError(), {"headers_sent": True}) + ) From e3f35a461c4bb140757500133af9374fc6430f44 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 17:53:16 +0300 Subject: [PATCH 08/14] fix(payments): restore default provider connections --- .../bot/payment_providers/shared/http_client.py | 17 +++-------------- tests/test_payment_http_client.py | 6 ++---- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/backend/bot/payment_providers/shared/http_client.py b/backend/bot/payment_providers/shared/http_client.py index 6d38239..73787e2 100644 --- a/backend/bot/payment_providers/shared/http_client.py +++ b/backend/bot/payment_providers/shared/http_client.py @@ -5,15 +5,10 @@ import json import logging from typing import Any, Callable, Dict, Mapping, Optional, Tuple -from aiohttp import ClientError, ClientSession, ClientTimeout, TCPConnector, TraceConfig +from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig SuccessCheck = Callable[[int, Any], bool] _TRANSPORT_ATTEMPTS = 2 -_PAYMENT_REQUEST_USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/125.0.0.0 Safari/537.36" -) def http_ok(status: int, _body: Any) -> bool: @@ -120,27 +115,21 @@ class HttpClientMixin: ``__init__`` and inherits ``_get_session`` / ``close``. The session is created on first use and recreated transparently if it was closed. - Payment provider calls are infrequent but user-facing, so the default - connector does not reuse TCP connections. This avoids intermittent hangs - on stale keep-alive sockets after long idle periods. + Provider API calls are traced so callers can retry transport failures only + when aiohttp has not sent request headers yet. """ _timeout: ClientTimeout _session: Optional[ClientSession] - _connector_force_close: bool def _init_http_client(self, *, total_timeout: float = 20.0) -> None: self._timeout = ClientTimeout(total=total_timeout) self._session = None - self._connector_force_close = True async def _get_session(self) -> ClientSession: if self._session is None or self._session.closed: - connector = TCPConnector(force_close=self._connector_force_close) self._session = ClientSession( timeout=self._timeout, - connector=connector, - headers={"User-Agent": _PAYMENT_REQUEST_USER_AGENT}, trace_configs=[_payment_trace_config()], ) return self._session diff --git a/tests/test_payment_http_client.py b/tests/test_payment_http_client.py index a46602a..67e6b9a 100644 --- a/tests/test_payment_http_client.py +++ b/tests/test_payment_http_client.py @@ -2,7 +2,6 @@ import unittest from bot.payment_providers.shared.http_client import ( HttpClientMixin, - _PAYMENT_REQUEST_USER_AGENT, _should_retry_transport_error, ) @@ -13,13 +12,12 @@ class _DummyHttpClient(HttpClientMixin): class PaymentHttpClientTests(unittest.IsolatedAsyncioTestCase): - async def test_http_client_does_not_reuse_provider_tcp_connections(self): + async def test_http_client_tracks_sent_headers_for_safe_retries(self): client = _DummyHttpClient() try: session = await client._get_session() - self.assertTrue(session.connector.force_close) + self.assertFalse(session.connector.force_close) self.assertTrue(session.trace_configs) - self.assertEqual(session.headers["User-Agent"], _PAYMENT_REQUEST_USER_AGENT) finally: await client.close() From 6d0084dd3a7d6a95f6830b95b4e92b76b7f37bed Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 18:10:30 +0300 Subject: [PATCH 09/14] fix(admin): close support user modal reliably --- frontend/src/admin/AdminPanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index c37f59e..1f9a246 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -412,13 +412,13 @@ if (!Number.isFinite(uid) || uid === 0) return; const next = normalizeSection("users"); sidebarOpen = false; + usersStore.setActive(next); if (active !== next) { active = next; paymentsStore.closePayment({ skipPush: true }); supportStore.closeTicketView({ skipPush: true }); onSectionChange(next, uid); } - usersStore.setActive(next); usersStore.openUser(uid); } From 2ec7376981cccc34129768243370d90ef33f92e4 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 19:30:29 +0300 Subject: [PATCH 10/14] fix(admin): ignore stale user modal loads --- frontend/src/lib/admin/stores/usersStore.js | 73 +++++++++++++++------ 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 716e1e8..9be9c82 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -54,6 +54,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { let _activeRef = "stats"; // fallback if active isn't tracked let _pathContext = null; + let _openUserRequestId = 0; function setActive(active) { _activeRef = active; @@ -124,6 +125,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId); if (!userId) return; + const requestId = ++_openUserRequestId; _setPathContext(opts.pathContext); state.update((s) => ({ @@ -161,39 +163,72 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { const hasHwidLimit = sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined; const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null; - state.update((s) => ({ - ...s, - openedUserDetail: res, - openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, - premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override), - premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "", - regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), - regularBonusGbDraft: - regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", - hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, - hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", - grantTrafficGbDraft: "", - grantTrafficKindDraft: "regular", - })); + state.update((s) => { + if ( + requestId !== _openUserRequestId || + !s.openedUser || + s.openedUser.user_id !== userId + ) { + return s; + } + return { + ...s, + openedUserDetail: res, + openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, + premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override), + premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "", + regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), + regularBonusGbDraft: + regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", + hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, + hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", + }; + }); } else { - onToast(res?.error || "load_failed"); - state.update((s) => ({ ...s, openedUser: null })); - if (!opts.skipPush) _pushUserPath(null); - _pathContext = null; + let shouldClearPath = false; + let shouldShowError = false; + state.update((s) => { + if ( + requestId !== _openUserRequestId || + !s.openedUser || + s.openedUser.user_id !== userId + ) { + return s; + } + shouldShowError = true; + shouldClearPath = true; + _pathContext = null; + return { ...s, openedUser: null }; + }); + if (shouldShowError) onToast(res?.error || "load_failed"); + if (shouldClearPath && !opts.skipPush) _pushUserPath(null); } } finally { - state.update((s) => ({ ...s, userDetailLoading: false })); + state.update((s) => { + if ( + requestId !== _openUserRequestId || + !s.openedUser || + s.openedUser.user_id !== userId + ) { + return s; + } + return { ...s, userDetailLoading: false }; + }); } } function closeUser(opts = {}) { let wasOpen = false; + _openUserRequestId += 1; state.update((s) => { wasOpen = Boolean(s.openedUser); return { ...s, openedUser: null, openedUserDetail: null, + userDetailLoading: false, userDeleteOpen: false, userBanConfirmOpen: false, userMessageConfirmOpen: false, From e0b3940b9858617a1206787ee9638736446948b4 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 19:47:57 +0300 Subject: [PATCH 11/14] fix(admin): reset user modal state consistently --- frontend/src/lib/admin/stores/usersStore.js | 157 ++++++++------------ 1 file changed, 64 insertions(+), 93 deletions(-) diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 9be9c82..7a0f6a7 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -16,39 +16,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { usersSort: "", usersLoading: false, - openedUser: null, - openedUserDetail: null, - userDetailLoading: false, - userMessageDraft: "", - userExtendDays: 30, - userExtendHwidDevices: true, - userActionBusy: false, - userDeleteOpen: false, - userBanConfirmOpen: false, - userMessageConfirmOpen: false, - userReferralsOpen: false, - userReferralsLoading: false, - userReferrals: [], - userReferralsTotal: 0, - userReferralsPage: 0, + ..._closedUserModalState(), userReferralsPageSize: USERS_PAGE_SIZE, - userReferralsInviter: null, - userDetailTab: "profile", - premiumUnlimitedDraft: false, - premiumBonusGbDraft: "", - regularUnlimitedDraft: false, - regularBonusGbDraft: "", - hwidUnlimitedDraft: false, - hwidDeviceLimitDraft: "", - grantTrafficGbDraft: "", - grantTrafficKindDraft: "regular", - - userLogs: [], - userLogsTotal: 0, - userLogsPage: 0, - userLogsLoading: false, - userLogsLoaded: false, - userLogsUserId: null, userLogsPageSize: USER_LOGS_PAGE_SIZE, }); @@ -56,6 +25,60 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { let _pathContext = null; let _openUserRequestId = 0; + function _closedUserModalState() { + return { + openedUser: null, + openedUserDetail: null, + userDetailLoading: false, + userMessageDraft: "", + userExtendDays: 30, + userExtendHwidDevices: true, + userActionBusy: false, + userDeleteOpen: false, + userBanConfirmOpen: false, + userMessageConfirmOpen: false, + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, + userReferralsInviter: null, + userDetailTab: "profile", + premiumUnlimitedDraft: false, + premiumBonusGbDraft: "", + regularUnlimitedDraft: false, + regularBonusGbDraft: "", + hwidUnlimitedDraft: false, + hwidDeviceLimitDraft: "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", + userLogs: [], + userLogsTotal: 0, + userLogsPage: 0, + userLogsLoading: false, + userLogsLoaded: false, + userLogsUserId: null, + }; + } + + function _openingUserModalState(user, userId) { + return { + ..._closedUserModalState(), + openedUser: user, + userDetailLoading: true, + userDetailTab: "subscription", + userLogsUserId: userId, + }; + } + + function _isCurrentUserRequest(s, requestId, userId) { + return ( + requestId === _openUserRequestId && + Boolean(s.openedUser) && + s.openedUser.user_id === userId + ); + } + function setActive(active) { _activeRef = active; } @@ -127,30 +150,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { if (!userId) return; const requestId = ++_openUserRequestId; _setPathContext(opts.pathContext); + const openedUser = + typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }; state.update((s) => ({ ...s, - openedUser: - typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }, - openedUserDetail: null, - userMessageDraft: "", - userMessageConfirmOpen: false, - userExtendDays: 30, - userExtendHwidDevices: true, - userDetailLoading: true, - userDetailTab: "subscription", - userReferralsOpen: false, - userReferralsLoading: false, - userReferrals: [], - userReferralsTotal: 0, - userReferralsPage: 0, - userReferralsInviter: null, - userLogs: [], - userLogsTotal: 0, - userLogsPage: 0, - userLogsLoading: false, - userLogsLoaded: false, - userLogsUserId: userId, + ..._openingUserModalState(openedUser, userId), + userActionBusy: s.userActionBusy, })); if (!opts.skipPush) _pushUserPath(userId); @@ -164,13 +170,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined; const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null; state.update((s) => { - if ( - requestId !== _openUserRequestId || - !s.openedUser || - s.openedUser.user_id !== userId - ) { - return s; - } + if (!_isCurrentUserRequest(s, requestId, userId)) return s; return { ...s, openedUserDetail: res, @@ -190,30 +190,18 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { let shouldClearPath = false; let shouldShowError = false; state.update((s) => { - if ( - requestId !== _openUserRequestId || - !s.openedUser || - s.openedUser.user_id !== userId - ) { - return s; - } + if (!_isCurrentUserRequest(s, requestId, userId)) return s; shouldShowError = true; shouldClearPath = true; _pathContext = null; - return { ...s, openedUser: null }; + return { ...s, ..._closedUserModalState() }; }); if (shouldShowError) onToast(res?.error || "load_failed"); if (shouldClearPath && !opts.skipPush) _pushUserPath(null); } } finally { state.update((s) => { - if ( - requestId !== _openUserRequestId || - !s.openedUser || - s.openedUser.user_id !== userId - ) { - return s; - } + if (!_isCurrentUserRequest(s, requestId, userId)) return s; return { ...s, userDetailLoading: false }; }); } @@ -226,24 +214,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { wasOpen = Boolean(s.openedUser); return { ...s, - openedUser: null, - openedUserDetail: null, - userDetailLoading: false, - userDeleteOpen: false, - userBanConfirmOpen: false, - userMessageConfirmOpen: false, - userReferralsOpen: false, - userReferralsLoading: false, - userReferrals: [], - userReferralsTotal: 0, - userReferralsPage: 0, - userReferralsInviter: null, - userLogs: [], - userLogsTotal: 0, - userLogsPage: 0, - userLogsLoading: false, - userLogsLoaded: false, - userLogsUserId: null, + ..._closedUserModalState(), }; }); if (wasOpen && !opts.skipPush) _pushUserPath(null); From ae3d6a9b9929b18ce6bca57982abd4167b522fc8 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 20:05:47 +0300 Subject: [PATCH 12/14] fix(admin): clear user route on modal close --- frontend/src/admin/AdminPanel.svelte | 8 + .../src/admin/sections/UserDetailModal.svelte | 3 +- frontend/src/lib/admin/stores/usersStore.js | 184 +++++++++--------- 3 files changed, 99 insertions(+), 96 deletions(-) diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index 1f9a246..e290043 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -422,6 +422,13 @@ usersStore.openUser(uid); } + function closeUserCard() { + usersStore.closeUser({ skipPush: true }); + if (active === "users" || active === "payments") { + onSectionChange(active); + } + } + function resolvedAvatarUrl(user) { return userAvatarUrl(user) || (user?.email ? gravatarCache.gravatarUrl(user.email) : ""); } @@ -892,4 +899,5 @@ {trafficPercentValue} {trafficLeftLabel} {trafficOfLabel} + onClose={closeUserCard} /> diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index 1bfac32..30f5fab 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -42,6 +42,7 @@ export let userTelegramProfileLink = () => ""; export let userTelegramProfileLinkKind = () => ""; export let openTelegramProfileLink = () => false; + export let onClose = () => usersStore.closeUser(); let avatarPreviewOpen = false; let avatarPreviewUrl = ""; @@ -184,7 +185,7 @@ : ""} description={openedUser?.username ? "@" + openedUser.username : ""} closeLabel={at("close", {}, "Закрыть")} - onclose={usersStore.closeUser} + onclose={onClose} class="admin-dialog admin-user-dialog" > {#if openedUser} diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 7a0f6a7..716e1e8 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -16,68 +16,44 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { usersSort: "", usersLoading: false, - ..._closedUserModalState(), + openedUser: null, + openedUserDetail: null, + userDetailLoading: false, + userMessageDraft: "", + userExtendDays: 30, + userExtendHwidDevices: true, + userActionBusy: false, + userDeleteOpen: false, + userBanConfirmOpen: false, + userMessageConfirmOpen: false, + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, userReferralsPageSize: USERS_PAGE_SIZE, + userReferralsInviter: null, + userDetailTab: "profile", + premiumUnlimitedDraft: false, + premiumBonusGbDraft: "", + regularUnlimitedDraft: false, + regularBonusGbDraft: "", + hwidUnlimitedDraft: false, + hwidDeviceLimitDraft: "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", + + userLogs: [], + userLogsTotal: 0, + userLogsPage: 0, + userLogsLoading: false, + userLogsLoaded: false, + userLogsUserId: null, userLogsPageSize: USER_LOGS_PAGE_SIZE, }); let _activeRef = "stats"; // fallback if active isn't tracked let _pathContext = null; - let _openUserRequestId = 0; - - function _closedUserModalState() { - return { - openedUser: null, - openedUserDetail: null, - userDetailLoading: false, - userMessageDraft: "", - userExtendDays: 30, - userExtendHwidDevices: true, - userActionBusy: false, - userDeleteOpen: false, - userBanConfirmOpen: false, - userMessageConfirmOpen: false, - userReferralsOpen: false, - userReferralsLoading: false, - userReferrals: [], - userReferralsTotal: 0, - userReferralsPage: 0, - userReferralsInviter: null, - userDetailTab: "profile", - premiumUnlimitedDraft: false, - premiumBonusGbDraft: "", - regularUnlimitedDraft: false, - regularBonusGbDraft: "", - hwidUnlimitedDraft: false, - hwidDeviceLimitDraft: "", - grantTrafficGbDraft: "", - grantTrafficKindDraft: "regular", - userLogs: [], - userLogsTotal: 0, - userLogsPage: 0, - userLogsLoading: false, - userLogsLoaded: false, - userLogsUserId: null, - }; - } - - function _openingUserModalState(user, userId) { - return { - ..._closedUserModalState(), - openedUser: user, - userDetailLoading: true, - userDetailTab: "subscription", - userLogsUserId: userId, - }; - } - - function _isCurrentUserRequest(s, requestId, userId) { - return ( - requestId === _openUserRequestId && - Boolean(s.openedUser) && - s.openedUser.user_id === userId - ); - } function setActive(active) { _activeRef = active; @@ -148,15 +124,31 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId); if (!userId) return; - const requestId = ++_openUserRequestId; _setPathContext(opts.pathContext); - const openedUser = - typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }; state.update((s) => ({ ...s, - ..._openingUserModalState(openedUser, userId), - userActionBusy: s.userActionBusy, + openedUser: + typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }, + openedUserDetail: null, + userMessageDraft: "", + userMessageConfirmOpen: false, + userExtendDays: 30, + userExtendHwidDevices: true, + userDetailLoading: true, + userDetailTab: "subscription", + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, + userReferralsInviter: null, + userLogs: [], + userLogsTotal: 0, + userLogsPage: 0, + userLogsLoading: false, + userLogsLoaded: false, + userLogsUserId: userId, })); if (!opts.skipPush) _pushUserPath(userId); @@ -169,52 +161,54 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { const hasHwidLimit = sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined; const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null; - state.update((s) => { - if (!_isCurrentUserRequest(s, requestId, userId)) return s; - return { - ...s, - openedUserDetail: res, - openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, - premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override), - premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "", - regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), - regularBonusGbDraft: - regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", - hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, - hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", - grantTrafficGbDraft: "", - grantTrafficKindDraft: "regular", - }; - }); + state.update((s) => ({ + ...s, + openedUserDetail: res, + openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, + premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override), + premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "", + regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), + regularBonusGbDraft: + regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", + hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, + hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", + })); } else { - let shouldClearPath = false; - let shouldShowError = false; - state.update((s) => { - if (!_isCurrentUserRequest(s, requestId, userId)) return s; - shouldShowError = true; - shouldClearPath = true; - _pathContext = null; - return { ...s, ..._closedUserModalState() }; - }); - if (shouldShowError) onToast(res?.error || "load_failed"); - if (shouldClearPath && !opts.skipPush) _pushUserPath(null); + onToast(res?.error || "load_failed"); + state.update((s) => ({ ...s, openedUser: null })); + if (!opts.skipPush) _pushUserPath(null); + _pathContext = null; } } finally { - state.update((s) => { - if (!_isCurrentUserRequest(s, requestId, userId)) return s; - return { ...s, userDetailLoading: false }; - }); + state.update((s) => ({ ...s, userDetailLoading: false })); } } function closeUser(opts = {}) { let wasOpen = false; - _openUserRequestId += 1; state.update((s) => { wasOpen = Boolean(s.openedUser); return { ...s, - ..._closedUserModalState(), + openedUser: null, + openedUserDetail: null, + userDeleteOpen: false, + userBanConfirmOpen: false, + userMessageConfirmOpen: false, + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, + userReferralsInviter: null, + userLogs: [], + userLogsTotal: 0, + userLogsPage: 0, + userLogsLoading: false, + userLogsLoaded: false, + userLogsUserId: null, }; }); if (wasOpen && !opts.skipPush) _pushUserPath(null); From fcb8e51ec070c02a846426ada926b8d03000f205 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 20:16:43 +0300 Subject: [PATCH 13/14] fix(admin): clear user route on modal close --- frontend/src/admin/AdminPanel.svelte | 2 +- frontend/src/lib/webapp/routes.js | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index e290043..48ea266 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -425,7 +425,7 @@ function closeUserCard() { usersStore.closeUser({ skipPush: true }); if (active === "users" || active === "payments") { - onSectionChange(active); + onSectionChange(active, 0); } } diff --git a/frontend/src/lib/webapp/routes.js b/frontend/src/lib/webapp/routes.js index 2c69574..3bd1e98 100644 --- a/frontend/src/lib/webapp/routes.js +++ b/frontend/src/lib/webapp/routes.js @@ -120,9 +120,11 @@ export function syncSectionPath( if (normalized === "admin") { const adm = adminSection || adminSectionFromPath(window.location.pathname, routePrefix) || "stats"; - const uid = - adminUserId ?? - (adm === "users" ? adminUserIdFromPath(window.location.pathname, routePrefix) : null); + const clearAdminUser = adminUserId === 0 || adminUserId === false; + const uid = clearAdminUser + ? null + : (adminUserId ?? + (adm === "users" ? adminUserIdFromPath(window.location.pathname, routePrefix) : null)); const supportTicketId = adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname, routePrefix) @@ -130,7 +132,7 @@ export function syncSectionPath( const paymentId = adm === "payments" ? adminPaymentIdFromPath(window.location.pathname, routePrefix) : null; const paymentUserId = - adm === "payments" + adm === "payments" && !clearAdminUser ? adminPaymentsUserIdFromPath(window.location.pathname, routePrefix) : null; if (adm === "users" && uid) targetPath = `/admin/users/${uid}`; From 9adcbf103a0c0318837d4ddfe282488b48c9c619 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 20:33:04 +0300 Subject: [PATCH 14/14] fix(admin): stabilize user modal lifecycle --- frontend/src/admin/AdminPanel.svelte | 33 +++-- frontend/src/lib/admin/stores/usersStore.js | 148 ++++++++++++-------- 2 files changed, 115 insertions(+), 66 deletions(-) diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index 48ea266..55c80ac 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -223,6 +223,8 @@ } let sidebarOpen = false; let isCompact = false; + let dismissedUserRouteKey = ""; + let lastUserRouteKey = ""; let adminLanguageMenuOpen = false; let adminLanguageClickGuard = false; let adminLanguageClickGuardArmed = false; @@ -379,6 +381,7 @@ const uid = Number(userId); // Synthetic email-only users use negative user_id; still a valid admin target. if (!Number.isFinite(uid) || uid === 0) return; + dismissedUserRouteKey = ""; const next = normalizeSection("payments"); sidebarOpen = false; if (active !== next) { @@ -395,6 +398,7 @@ function openLogsUserCard(userId) { const uid = Number(userId); if (!Number.isFinite(uid) || uid === 0) return; + dismissedUserRouteKey = ""; const next = normalizeSection("logs"); sidebarOpen = false; if (active !== next) { @@ -410,19 +414,20 @@ function openUserCard(userId) { const uid = Number(userId); if (!Number.isFinite(uid) || uid === 0) return; - const next = normalizeSection("users"); + dismissedUserRouteKey = ""; sidebarOpen = false; - usersStore.setActive(next); - if (active !== next) { - active = next; - paymentsStore.closePayment({ skipPush: true }); - supportStore.closeTicketView({ skipPush: true }); - onSectionChange(next, uid); - } - usersStore.openUser(uid); + usersStore.setActive(active); + usersStore.openUser(uid, { skipPush: true, pathContext: active }); + } + + function userRouteKey(section = active) { + if (section === "users" && initialUserId) return `users:${initialUserId}`; + if (section === "payments" && initialPaymentUserId) return `payments:${initialPaymentUserId}`; + return ""; } function closeUserCard() { + dismissedUserRouteKey = userRouteKey(); usersStore.closeUser({ skipPush: true }); if (active === "users" || active === "payments") { onSectionChange(active, 0); @@ -540,9 +545,18 @@ $: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 }; $: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 }; + $: { + const currentUserRouteKey = userRouteKey(); + if (currentUserRouteKey !== lastUserRouteKey) { + if (currentUserRouteKey !== dismissedUserRouteKey) dismissedUserRouteKey = ""; + lastUserRouteKey = currentUserRouteKey; + } + } + $: if ( active === "users" && initialUserId && + dismissedUserRouteKey !== `users:${initialUserId}` && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId) ) { usersStore.openUser(initialUserId, { skipPush: true }); @@ -559,6 +573,7 @@ $: if ( active === "payments" && initialPaymentUserId && + dismissedUserRouteKey !== `payments:${initialPaymentUserId}` && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialPaymentUserId) ) { usersStore.openUser(initialPaymentUserId, { skipPush: true, pathContext: "payments" }); diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 716e1e8..6ba9e49 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -54,6 +54,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { let _activeRef = "stats"; // fallback if active isn't tracked let _pathContext = null; + let _openUserRequestId = 0; + + function _closedUserModalState() { + return { + openedUser: null, + openedUserDetail: null, + userDetailLoading: false, + userMessageDraft: "", + userExtendDays: 30, + userExtendHwidDevices: true, + userDeleteOpen: false, + userBanConfirmOpen: false, + userMessageConfirmOpen: false, + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, + userReferralsInviter: null, + userDetailTab: "profile", + premiumUnlimitedDraft: false, + premiumBonusGbDraft: "", + regularUnlimitedDraft: false, + regularBonusGbDraft: "", + hwidUnlimitedDraft: false, + hwidDeviceLimitDraft: "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", + userLogs: [], + userLogsTotal: 0, + userLogsPage: 0, + userLogsLoading: false, + userLogsLoaded: false, + userLogsUserId: null, + }; + } + + function _openingUserModalState(user, userId) { + return { + ..._closedUserModalState(), + openedUser: user, + userDetailLoading: true, + userDetailTab: "subscription", + userLogsUserId: userId, + }; + } + + function _isCurrentUserRequest(s, requestId, userId) { + return ( + requestId === _openUserRequestId && Boolean(s.openedUser) && s.openedUser.user_id === userId + ); + } function setActive(active) { _activeRef = active; @@ -124,31 +176,15 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId); if (!userId) return; + const requestId = ++_openUserRequestId; _setPathContext(opts.pathContext); + const openedUser = + typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }; state.update((s) => ({ ...s, - openedUser: - typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }, - openedUserDetail: null, - userMessageDraft: "", - userMessageConfirmOpen: false, - userExtendDays: 30, - userExtendHwidDevices: true, - userDetailLoading: true, - userDetailTab: "subscription", - userReferralsOpen: false, - userReferralsLoading: false, - userReferrals: [], - userReferralsTotal: 0, - userReferralsPage: 0, - userReferralsInviter: null, - userLogs: [], - userLogsTotal: 0, - userLogsPage: 0, - userLogsLoading: false, - userLogsLoaded: false, - userLogsUserId: userId, + ..._openingUserModalState(openedUser, userId), + userActionBusy: s.userActionBusy, })); if (!opts.skipPush) _pushUserPath(userId); @@ -161,54 +197,52 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { const hasHwidLimit = sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined; const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null; - state.update((s) => ({ - ...s, - openedUserDetail: res, - openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, - premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override), - premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "", - regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), - regularBonusGbDraft: - regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", - hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, - hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", - grantTrafficGbDraft: "", - grantTrafficKindDraft: "regular", - })); + state.update((s) => { + if (!_isCurrentUserRequest(s, requestId, userId)) return s; + return { + ...s, + openedUserDetail: res, + openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, + premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override), + premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "", + regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override), + regularBonusGbDraft: + regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "", + hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0, + hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", + grantTrafficGbDraft: "", + grantTrafficKindDraft: "regular", + }; + }); } else { - onToast(res?.error || "load_failed"); - state.update((s) => ({ ...s, openedUser: null })); - if (!opts.skipPush) _pushUserPath(null); - _pathContext = null; + let shouldClearPath = false; + let shouldShowError = false; + state.update((s) => { + if (!_isCurrentUserRequest(s, requestId, userId)) return s; + shouldShowError = true; + shouldClearPath = true; + _pathContext = null; + return { ...s, ..._closedUserModalState() }; + }); + if (shouldShowError) onToast(res?.error || "load_failed"); + if (shouldClearPath && !opts.skipPush) _pushUserPath(null); } } finally { - state.update((s) => ({ ...s, userDetailLoading: false })); + state.update((s) => { + if (!_isCurrentUserRequest(s, requestId, userId)) return s; + return { ...s, userDetailLoading: false }; + }); } } function closeUser(opts = {}) { let wasOpen = false; + _openUserRequestId += 1; state.update((s) => { wasOpen = Boolean(s.openedUser); return { ...s, - openedUser: null, - openedUserDetail: null, - userDeleteOpen: false, - userBanConfirmOpen: false, - userMessageConfirmOpen: false, - userReferralsOpen: false, - userReferralsLoading: false, - userReferrals: [], - userReferralsTotal: 0, - userReferralsPage: 0, - userReferralsInviter: null, - userLogs: [], - userLogsTotal: 0, - userLogsPage: 0, - userLogsLoading: false, - userLogsLoaded: false, - userLogsUserId: null, + ..._closedUserModalState(), }; }); if (wasOpen && !opts.skipPush) _pushUserPath(null);