Merge GitHub dev into GitLab dev

This commit is contained in:
BADtochka
2026-06-09 13:21:26 +03:00
22 changed files with 369 additions and 109 deletions
+9 -3
View File
@@ -2,7 +2,7 @@ name: Docker build & push (reusable)
# Reusable workflow that builds the three image targets defined in # Reusable workflow that builds the three image targets defined in
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes # 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: # Called by:
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev # - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
@@ -88,13 +88,19 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Resolve image namespace
id: image_namespace
run: |
owner="${{ github.repository_owner }}"
echo "owner=${owner,,}" >> "$GITHUB_OUTPUT"
- name: Docker metadata - name: Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: |
name=3252a8/${{ matrix.image }},enable=${{ inputs.publish_dockerhub }} name=${{ steps.image_namespace.outputs.owner }}/${{ matrix.image }},enable=${{ inputs.publish_dockerhub }}
name=ghcr.io/3252a8/${{ matrix.image }},enable=true name=ghcr.io/${{ steps.image_namespace.outputs.owner }}/${{ matrix.image }},enable=true
tags: | tags: |
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }} type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }} type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
@@ -348,6 +348,16 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Английская версия текста на этапе оплаты.", "Английская версия текста на этапе оплаты.",
subsection="checkout", subsection="checkout",
), ),
SettingField(
"PAYMENT_REQUEST_TIMEOUT_SECONDS",
"float",
"payments",
"Таймаут запроса к провайдеру",
"Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.",
optional=False,
min=1,
subsection="checkout",
),
# ─── Payment providers (toggles) ─────────────────────────────── # ─── Payment providers (toggles) ───────────────────────────────
# Common # Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"), SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
+41 -2
View File
@@ -1,13 +1,16 @@
import asyncio import asyncio
import functools
import hmac import hmac
import logging import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web from aiohttp import web
from aiohttp.web_log import AccessLogger, KeyMethod
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from bot.payment_providers import iter_provider_specs, iter_service_keys 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 from config.settings import Settings
@@ -18,6 +21,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
return hmac.compare_digest(telegram_secret_token, self.secret_token) 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( def _inject_shared_instances(
app: web.Application, app: web.Application,
dp: Dispatcher, dp: Dispatcher,
@@ -110,7 +146,7 @@ async def build_and_start_web_app(
runners = [] runners = []
webhooks_runner = web.AppRunner(app) webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger)
await webhooks_runner.setup() await webhooks_runner.setup()
runners.append(webhooks_runner) runners.append(webhooks_runner)
site = web.TCPSite( site = web.TCPSite(
@@ -133,7 +169,10 @@ async def build_and_start_web_app(
settings, settings,
async_session_factory, async_session_factory,
) )
subscription_runner = web.AppRunner(subscription_app) subscription_runner = web.AppRunner(
subscription_app,
access_log_class=TrustedProxyAccessLogger,
)
await subscription_runner.setup() await subscription_runner.setup()
runners.append(subscription_runner) runners.append(subscription_runner)
subscription_site = web.TCPSite( subscription_site = web.TCPSite(
+1 -1
View File
@@ -153,7 +153,7 @@ class FreeKassaService(HttpClientMixin):
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper() self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
self.api_base_url: str = "https://api.fk.life/v1" 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._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000) self._last_nonce = int(time.time() * 1000)
+1 -1
View File
@@ -243,7 +243,7 @@ class HeleketService(HttpClientMixin):
self.referral_service = referral_service self.referral_service = referral_service
self._default_return_url = default_return_url 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: if not self.configured:
logging.warning( logging.warning(
"HeleketService initialized but not fully configured. Payments disabled." "HeleketService initialized but not fully configured. Payments disabled."
+1 -1
View File
@@ -546,7 +546,7 @@ class PaykillaService(HttpClientMixin):
self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {} self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {}
self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, []) 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: if not self.configured:
logging.warning( logging.warning(
"PaykillaService initialized but not fully configured. Payments disabled." "PaykillaService initialized but not fully configured. Payments disabled."
+1 -1
View File
@@ -157,7 +157,7 @@ class PlategaService(HttpClientMixin):
self.referral_service = referral_service self.referral_service = referral_service
self._default_return_url = default_return_url 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: if not self.configured:
logging.warning( logging.warning(
"PlategaService initialized but not fully configured. Payments disabled." "PlategaService initialized but not fully configured. Payments disabled."
+1 -1
View File
@@ -136,7 +136,7 @@ class SeverPayService(HttpClientMixin):
self.referral_service = referral_service self.referral_service = referral_service
self._default_return_url = default_return_url 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: if not self.configured:
logging.warning( logging.warning(
@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from typing import Any, Callable, Dict, Mapping, Optional, Tuple 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] SuccessCheck = Callable[[int, Any], bool]
_TRANSPORT_ATTEMPTS = 2
def http_ok(status: int, _body: Any) -> bool: def http_ok(status: int, _body: Any) -> bool:
@@ -14,6 +16,29 @@ def http_ok(status: int, _body: Any) -> bool:
return status == 200 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( async def post_json_request(
session: ClientSession, session: ClientSession,
url: str, url: str,
@@ -29,11 +54,14 @@ async def post_json_request(
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
can decide what to do (typically: mark the payment as ``failed_creation``). can decide what to do (typically: mark the payment as ``failed_creation``).
""" """
for attempt in range(1, _TRANSPORT_ATTEMPTS + 1):
trace_ctx: dict[str, Any] = {"headers_sent": False}
try: try:
async with session.post( async with session.post(
url, url,
json=body, json=body,
headers=dict(headers) if headers else None, headers=dict(headers) if headers else None,
trace_request_ctx=trace_ctx,
) as response: ) as response:
response_text = await response.text() response_text = await response.text()
try: try:
@@ -55,8 +83,18 @@ async def post_json_request(
return False, {"status": response.status, "message": response_data} return False, {"status": response.status, "message": response_data}
return True, response_data return True, response_data
except Exception as exc: 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,
attempt + 1,
_TRANSPORT_ATTEMPTS,
exc,
)
continue
logging.exception("%s: request failed.", log_prefix) logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)} return False, {"message": str(exc)}
return False, {"message": "request_failed"}
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]: 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 Each subclass calls ``self._init_http_client(total_timeout=...)`` from
``__init__`` and inherits ``_get_session`` / ``close``. The session is ``__init__`` and inherits ``_get_session`` / ``close``. The session is
created on first use and recreated transparently if it was closed. 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 _timeout: ClientTimeout
@@ -87,7 +128,10 @@ class HttpClientMixin:
async def _get_session(self) -> ClientSession: async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed: 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 return self._session
async def close(self) -> None: async def close(self) -> None:
+1 -1
View File
@@ -206,7 +206,7 @@ class WataService(HttpClientMixin):
self._default_return_url = default_return_url self._default_return_url = default_return_url
self._cached_public_key_pem = None # populated by webhook on first verify 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: if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.") logging.warning("WataService initialized but not fully configured. Payments disabled.")
+5
View File
@@ -331,6 +331,11 @@ class Settings(BaseSettings):
default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN, default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN,
description="English subscription description shown before purchase/renewal options.", 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_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
+1
View File
@@ -210,6 +210,7 @@ proxy/Docker gateway и может отклонить валидный webhook.
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. | | `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. |
| `PAYMENT_REQUEST_TIMEOUT_SECONDS` | Общий таймаут одного API-запроса к платёжному провайдеру, в секундах. По умолчанию `20`. |
| `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. | | `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. |
| `PAYMENT_<METHOD>_WEBAPP_ICON` | Lucide-иконка кнопки в Web App. | | `PAYMENT_<METHOD>_WEBAPP_ICON` | Lucide-иконка кнопки в Web App. |
| `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU` / `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` | Текст кнопки в Telegram. | | `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU` / `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` | Текст кнопки в Telegram. |
+31 -8
View File
@@ -223,6 +223,8 @@
} }
let sidebarOpen = false; let sidebarOpen = false;
let isCompact = false; let isCompact = false;
let dismissedUserRouteKey = "";
let lastUserRouteKey = "";
let adminLanguageMenuOpen = false; let adminLanguageMenuOpen = false;
let adminLanguageClickGuard = false; let adminLanguageClickGuard = false;
let adminLanguageClickGuardArmed = false; let adminLanguageClickGuardArmed = false;
@@ -379,6 +381,7 @@
const uid = Number(userId); const uid = Number(userId);
// Synthetic email-only users use negative user_id; still a valid admin target. // Synthetic email-only users use negative user_id; still a valid admin target.
if (!Number.isFinite(uid) || uid === 0) return; if (!Number.isFinite(uid) || uid === 0) return;
dismissedUserRouteKey = "";
const next = normalizeSection("payments"); const next = normalizeSection("payments");
sidebarOpen = false; sidebarOpen = false;
if (active !== next) { if (active !== next) {
@@ -395,6 +398,7 @@
function openLogsUserCard(userId) { function openLogsUserCard(userId) {
const uid = Number(userId); const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return; if (!Number.isFinite(uid) || uid === 0) return;
dismissedUserRouteKey = "";
const next = normalizeSection("logs"); const next = normalizeSection("logs");
sidebarOpen = false; sidebarOpen = false;
if (active !== next) { if (active !== next) {
@@ -410,16 +414,24 @@
function openUserCard(userId) { function openUserCard(userId) {
const uid = Number(userId); const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return; if (!Number.isFinite(uid) || uid === 0) return;
const next = normalizeSection("users"); dismissedUserRouteKey = "";
sidebarOpen = false; sidebarOpen = false;
if (active !== next) { usersStore.setActive(active);
active = next; usersStore.openUser(uid, { skipPush: true, pathContext: active });
paymentsStore.closePayment({ skipPush: true }); }
supportStore.closeTicketView({ skipPush: true });
onSectionChange(next, uid); 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) { function resolvedAvatarUrl(user) {
@@ -534,9 +546,18 @@
$: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 }; $: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 };
$: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 }; $: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 };
$: {
const currentUserRouteKey = userRouteKey();
if (currentUserRouteKey !== lastUserRouteKey) {
if (currentUserRouteKey !== dismissedUserRouteKey) dismissedUserRouteKey = "";
lastUserRouteKey = currentUserRouteKey;
}
}
$: if ( $: if (
active === "users" && active === "users" &&
initialUserId && initialUserId &&
dismissedUserRouteKey !== `users:${initialUserId}` &&
(!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId) (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)
) { ) {
usersStore.openUser(initialUserId, { skipPush: true }); usersStore.openUser(initialUserId, { skipPush: true });
@@ -553,6 +574,7 @@
$: if ( $: if (
active === "payments" && active === "payments" &&
initialPaymentUserId && initialPaymentUserId &&
dismissedUserRouteKey !== `payments:${initialPaymentUserId}` &&
(!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialPaymentUserId) (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialPaymentUserId)
) { ) {
usersStore.openUser(initialPaymentUserId, { skipPush: true, pathContext: "payments" }); usersStore.openUser(initialPaymentUserId, { skipPush: true, pathContext: "payments" });
@@ -893,4 +915,5 @@
{trafficPercentValue} {trafficPercentValue}
{trafficLeftLabel} {trafficLeftLabel}
{trafficOfLabel} {trafficOfLabel}
onClose={closeUserCard}
/> />
@@ -42,6 +42,7 @@
export let userTelegramProfileLink = () => ""; export let userTelegramProfileLink = () => "";
export let userTelegramProfileLinkKind = () => ""; export let userTelegramProfileLinkKind = () => "";
export let openTelegramProfileLink = () => false; export let openTelegramProfileLink = () => false;
export let onClose = () => usersStore.closeUser();
let avatarPreviewOpen = false; let avatarPreviewOpen = false;
let avatarPreviewUrl = ""; let avatarPreviewUrl = "";
@@ -198,7 +199,7 @@
: ""} : ""}
description={openedUser?.username ? "@" + openedUser.username : ""} description={openedUser?.username ? "@" + openedUser.username : ""}
closeLabel={at("close", {}, "Закрыть")} closeLabel={at("close", {}, "Закрыть")}
onclose={usersStore.closeUser} onclose={onClose}
class="admin-dialog admin-user-dialog" class="admin-dialog admin-user-dialog"
> >
{#if openedUser} {#if openedUser}
+78 -44
View File
@@ -54,6 +54,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
let _activeRef = "stats"; // fallback if active isn't tracked let _activeRef = "stats"; // fallback if active isn't tracked
let _pathContext = null; 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) { function setActive(active) {
_activeRef = active; _activeRef = active;
@@ -124,31 +176,15 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
const userId = const userId =
typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId); typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
if (!userId) return; if (!userId) return;
const requestId = ++_openUserRequestId;
_setPathContext(opts.pathContext); _setPathContext(opts.pathContext);
const openedUser =
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId };
state.update((s) => ({ state.update((s) => ({
...s, ...s,
openedUser: ..._openingUserModalState(openedUser, userId),
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }, userActionBusy: s.userActionBusy,
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); if (!opts.skipPush) _pushUserPath(userId);
@@ -161,7 +197,9 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
const hasHwidLimit = const hasHwidLimit =
sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined; sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined;
const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null; const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null;
state.update((s) => ({ state.update((s) => {
if (!_isCurrentUserRequest(s, requestId, userId)) return s;
return {
...s, ...s,
openedUserDetail: res, openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser, openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
@@ -174,41 +212,37 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "", hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "",
grantTrafficGbDraft: "", grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular", grantTrafficKindDraft: "regular",
})); };
});
} else { } else {
onToast(res?.error || "load_failed"); let shouldClearPath = false;
state.update((s) => ({ ...s, openedUser: null })); let shouldShowError = false;
if (!opts.skipPush) _pushUserPath(null); state.update((s) => {
if (!_isCurrentUserRequest(s, requestId, userId)) return s;
shouldShowError = true;
shouldClearPath = true;
_pathContext = null; _pathContext = null;
return { ...s, ..._closedUserModalState() };
});
if (shouldShowError) onToast(res?.error || "load_failed");
if (shouldClearPath && !opts.skipPush) _pushUserPath(null);
} }
} finally { } finally {
state.update((s) => ({ ...s, userDetailLoading: false })); state.update((s) => {
if (!_isCurrentUserRequest(s, requestId, userId)) return s;
return { ...s, userDetailLoading: false };
});
} }
} }
function closeUser(opts = {}) { function closeUser(opts = {}) {
let wasOpen = false; let wasOpen = false;
_openUserRequestId += 1;
state.update((s) => { state.update((s) => {
wasOpen = Boolean(s.openedUser); wasOpen = Boolean(s.openedUser);
return { return {
...s, ...s,
openedUser: null, ..._closedUserModalState(),
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); if (wasOpen && !opts.skipPush) _pushUserPath(null);
+6 -4
View File
@@ -120,9 +120,11 @@ export function syncSectionPath(
if (normalized === "admin") { if (normalized === "admin") {
const adm = const adm =
adminSection || adminSectionFromPath(window.location.pathname, routePrefix) || "stats"; adminSection || adminSectionFromPath(window.location.pathname, routePrefix) || "stats";
const uid = const clearAdminUser = adminUserId === 0 || adminUserId === false;
adminUserId ?? const uid = clearAdminUser
(adm === "users" ? adminUserIdFromPath(window.location.pathname, routePrefix) : null); ? null
: (adminUserId ??
(adm === "users" ? adminUserIdFromPath(window.location.pathname, routePrefix) : null));
const supportTicketId = const supportTicketId =
adm === "support" adm === "support"
? adminSupportTicketIdFromPath(window.location.pathname, routePrefix) ? adminSupportTicketIdFromPath(window.location.pathname, routePrefix)
@@ -130,7 +132,7 @@ export function syncSectionPath(
const paymentId = const paymentId =
adm === "payments" ? adminPaymentIdFromPath(window.location.pathname, routePrefix) : null; adm === "payments" ? adminPaymentIdFromPath(window.location.pathname, routePrefix) : null;
const paymentUserId = const paymentUserId =
adm === "payments" adm === "payments" && !clearAdminUser
? adminPaymentsUserIdFromPath(window.location.pathname, routePrefix) ? adminPaymentsUserIdFromPath(window.location.pathname, routePrefix)
: null; : null;
if (adm === "users" && uid) targetPath = `/admin/users/${uid}`; if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
@@ -603,6 +603,26 @@
"overridden": false, "overridden": false,
"updated_at": null "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", "key": "STARS_ENABLED",
"type": "bool", "type": "bool",
+3
View File
@@ -1170,6 +1170,7 @@
"admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_cryptopay": "CryptoPay",
"admin_settings_subsection_wata": "Wata", "admin_settings_subsection_wata": "Wata",
"admin_settings_subsection_heleket": "Heleket", "admin_settings_subsection_heleket": "Heleket",
"admin_settings_subsection_paykilla": "PayKilla",
"admin_settings_provider_webhook_url": "Webhook URL", "admin_settings_provider_webhook_url": "Webhook URL",
"admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.", "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.", "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_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_label": "Subscription description (EN)",
"admin_settings_field_subscription_purchase_description_en_description": "English text shown during checkout.", "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_stars_enabled_label": "Stars Enabled",
"admin_settings_field_yookassa_enabled_label": "YooKassa Enabled", "admin_settings_field_yookassa_enabled_label": "YooKassa Enabled",
"admin_settings_field_yookassa_shop_id_label": "YooKassa Shop ID", "admin_settings_field_yookassa_shop_id_label": "YooKassa Shop ID",
+3
View File
@@ -1170,6 +1170,7 @@
"admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_cryptopay": "CryptoPay",
"admin_settings_subsection_wata": "Wata", "admin_settings_subsection_wata": "Wata",
"admin_settings_subsection_heleket": "Heleket", "admin_settings_subsection_heleket": "Heleket",
"admin_settings_subsection_paykilla": "PayKilla",
"admin_settings_provider_webhook_url": "Webhook URL", "admin_settings_provider_webhook_url": "Webhook URL",
"admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.", "admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.",
"admin_settings_panel_webhook_url_hint": "Укажите этот адрес как WEBHOOK_URL в Remnawave Panel.", "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_ru_description": "Русская версия текста на этапе оплаты.",
"admin_settings_field_subscription_purchase_description_en_label": "Описание подписки (EN)", "admin_settings_field_subscription_purchase_description_en_label": "Описание подписки (EN)",
"admin_settings_field_subscription_purchase_description_en_description": "Английская версия текста на этапе оплаты.", "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_stars_enabled_label": "Telegram Stars",
"admin_settings_field_yookassa_enabled_label": "Включена", "admin_settings_field_yookassa_enabled_label": "Включена",
"admin_settings_field_yookassa_shop_id_label": "Shop ID", "admin_settings_field_yookassa_shop_id_label": "Shop ID",
@@ -24,6 +24,7 @@ SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = (
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED", "SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU", "SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN", "SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"PAYMENT_REQUEST_TIMEOUT_SECONDS",
) )
SUBSCRIPTION_GUIDE_SETTINGS = ( 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(): def test_subscription_purchase_description_settings_i18n_keys_exist():
manifest = _manifest_by_key() 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"): for language in ("ru", "en"):
messages = _locale(language) messages = _locale(language)
for setting_key in SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS: for setting_key in SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS:
+30
View File
@@ -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})
)
+33
View File
@@ -12,6 +12,7 @@ from aiohttp import web
from bot.app.web import admin_api, subscription_webapp 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.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 import account as account_routes
from bot.app.web.webapp_auth import ( from bot.app.web.webapp_auth import (
create_telegram_oauth_nonce, create_telegram_oauth_nonce,
@@ -95,6 +96,38 @@ class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
"198.51.100.7", "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): async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self):
request = SimpleNamespace( request = SimpleNamespace(
app={ app={