diff --git a/.github/workflows/_docker-build-push.yml b/.github/workflows/_docker-build-push.yml index 30929e9..0be2ec5 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 the selected registries under the 3252a8/ namespace. +# them to the selected registries under the repository owner's namespace. # # Called by: # - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev @@ -88,13 +88,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: | - name=3252a8/${{ matrix.image }},enable=${{ inputs.publish_dockerhub }} - name=ghcr.io/3252a8/${{ matrix.image }},enable=true + name=${{ steps.image_namespace.outputs.owner }}/${{ matrix.image }},enable=${{ inputs.publish_dockerhub }} + name=ghcr.io/${{ steps.image_namespace.outputs.owner }}/${{ matrix.image }},enable=true tags: | type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }} type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }} 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/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/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 9be3bd0..6ee8d17 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 b618fa0..4ee14db 100644 --- a/backend/bot/payment_providers/paykilla.py +++ b/backend/bot/payment_providers/paykilla.py @@ -546,7 +546,7 @@ class PaykillaService(HttpClientMixin): self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {} self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, []) - 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 e4f15fc..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=20) + 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/shared/http_client.py b/backend/bot/payment_providers/shared/http_client.py index c5dbd6d..73787e2 100644 --- a/backend/bot/payment_providers/shared/http_client.py +++ b/backend/bot/payment_providers/shared/http_client.py @@ -1,12 +1,14 @@ from __future__ import annotations +import asyncio import json import logging from typing import Any, Callable, Dict, Mapping, Optional, Tuple -from aiohttp import ClientSession, ClientTimeout +from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig SuccessCheck = Callable[[int, Any], bool] +_TRANSPORT_ATTEMPTS = 2 def http_ok(status: int, _body: Any) -> bool: @@ -14,6 +16,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 +54,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]: @@ -76,6 +114,9 @@ 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. + + Provider API calls are traced so callers can retry transport failures only + when aiohttp has not sent request headers yet. """ _timeout: ClientTimeout @@ -87,7 +128,10 @@ class HttpClientMixin: async def _get_session(self) -> ClientSession: if self._session is None or self._session.closed: - self._session = ClientSession(timeout=self._timeout) + self._session = ClientSession( + timeout=self._timeout, + trace_configs=[_payment_trace_config()], + ) return self._session async def close(self) -> None: 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 19acc12..a8f56bc 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -331,6 +331,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 605f75a..09aed29 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -210,6 +210,7 @@ proxy/Docker gateway и может отклонить валидный webhook. | `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/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index df27457..d252ccc 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,16 +414,24 @@ function openUserCard(userId) { const uid = Number(userId); if (!Number.isFinite(uid) || uid === 0) return; - const next = normalizeSection("users"); + dismissedUserRouteKey = ""; sidebarOpen = false; - if (active !== next) { - active = next; - paymentsStore.closePayment({ skipPush: true }); - supportStore.closeTicketView({ skipPush: true }); - onSectionChange(next, 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); } - usersStore.setActive(next); - usersStore.openUser(uid); } function resolvedAvatarUrl(user) { @@ -534,9 +546,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 }); @@ -553,6 +574,7 @@ $: if ( active === "payments" && initialPaymentUserId && + dismissedUserRouteKey !== `payments:${initialPaymentUserId}` && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialPaymentUserId) ) { usersStore.openUser(initialPaymentUserId, { skipPush: true, pathContext: "payments" }); @@ -893,4 +915,5 @@ {trafficPercentValue} {trafficLeftLabel} {trafficOfLabel} + onClose={closeUserCard} /> diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index 09983f8..998a3a0 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 = ""; @@ -198,7 +199,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 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); 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}`; diff --git a/frontend/src/lib/webapp/settingsManifest.generated.json b/frontend/src/lib/webapp/settingsManifest.generated.json index eb1ff71..05be4fb 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 38dfe30..7d9179f 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.", @@ -1680,6 +1681,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 a16664e..8c35f1e 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.", @@ -1680,6 +1681,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: diff --git a/tests/test_payment_http_client.py b/tests/test_payment_http_client.py new file mode 100644 index 0000000..67e6b9a --- /dev/null +++ b/tests/test_payment_http_client.py @@ -0,0 +1,30 @@ +import unittest + +from bot.payment_providers.shared.http_client import ( + HttpClientMixin, + _should_retry_transport_error, +) + + +class _DummyHttpClient(HttpClientMixin): + def __init__(self): + self._init_http_client(total_timeout=20) + + +class PaymentHttpClientTests(unittest.IsolatedAsyncioTestCase): + async def test_http_client_tracks_sent_headers_for_safe_retries(self): + client = _DummyHttpClient() + try: + session = await client._get_session() + self.assertFalse(session.connector.force_close) + self.assertTrue(session.trace_configs) + 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}) + ) diff --git a/tests/test_security.py b/tests/test_security.py index e471d1d..ae1e75d 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -12,6 +12,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, @@ -95,6 +96,38 @@ 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.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.6", + headers={"X-Forwarded-For": "203.0.113.10, 172.19.0.7"}, + 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.6", + ) + async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self): request = SimpleNamespace( app={