Merge GitHub dev into GitLab dev
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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})
|
||||||
|
)
|
||||||
@@ -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={
|
||||||
|
|||||||
Reference in New Issue
Block a user