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
# 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' }}
@@ -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"),
+41 -2
View File
@@ -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(
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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(
@@ -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,11 +54,14 @@ async def post_json_request(
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
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:
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:
@@ -55,8 +83,18 @@ async def post_json_request(
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,
attempt + 1,
_TRANSPORT_ATTEMPTS,
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:
+1 -1
View File
@@ -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.")
+5
View File
@@ -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")
+1
View File
@@ -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_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. |
| `PAYMENT_<METHOD>_WEBAPP_ICON` | Lucide-иконка кнопки в Web App. |
| `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU` / `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` | Текст кнопки в Telegram. |
+31 -8
View File
@@ -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}
/>
@@ -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}
+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 _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,7 +197,9 @@ 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) => ({
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,
@@ -174,41 +212,37 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
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);
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);
+6 -4
View File
@@ -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}`;
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
@@ -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:
+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.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={