Merge origin/dev into feature/telegram-flood-hardening
This commit is contained in:
@@ -9,6 +9,7 @@ from bot.app.web.admin_api_impl import (
|
||||
backups as _backups,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
health as _health,
|
||||
logs as _logs,
|
||||
panel as _panel,
|
||||
payments as _payments,
|
||||
@@ -28,6 +29,7 @@ _MODULES = (
|
||||
_runtime,
|
||||
_auth,
|
||||
_common,
|
||||
_health,
|
||||
_stats,
|
||||
_users,
|
||||
_payments,
|
||||
|
||||
@@ -1,5 +1,165 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .common import _panel_user_connection_activity
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
|
||||
BROADCAST_TARGETS = {
|
||||
"all",
|
||||
"active",
|
||||
"inactive",
|
||||
"expired",
|
||||
"never",
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED,
|
||||
}
|
||||
PANEL_ACTIVITY_LOOKUP_CONCURRENCY = 10
|
||||
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
def _resolve_panel_service(request: web.Request) -> Any:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
return getattr(subscription_service, "panel_service", None)
|
||||
|
||||
|
||||
async def _active_subscription_panel_uuids_by_user(
|
||||
session: AsyncSession,
|
||||
) -> Dict[int, List[str]]:
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(Subscription.user_id, Subscription.panel_user_uuid)
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(
|
||||
User.is_banned == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.panel_user_uuid.is_not(None),
|
||||
Subscription.panel_user_uuid != "",
|
||||
)
|
||||
.order_by(Subscription.user_id.asc(), Subscription.end_date.desc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
grouped: Dict[int, List[str]] = defaultdict(list)
|
||||
seen: Dict[int, set[str]] = defaultdict(set)
|
||||
for user_id, panel_uuid in result.all():
|
||||
user_id_int = int(user_id)
|
||||
panel_uuid_str = str(panel_uuid or "").strip()
|
||||
if panel_uuid_str and panel_uuid_str not in seen[user_id_int]:
|
||||
grouped[user_id_int].append(panel_uuid_str)
|
||||
seen[user_id_int].add(panel_uuid_str)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
async def _panel_connection_status(panel_service: Any, panel_uuid: str) -> str:
|
||||
try:
|
||||
panel_user = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch panel user activity uuid=%s: %s", panel_uuid, exc)
|
||||
return "unknown"
|
||||
activity = _panel_user_connection_activity(panel_user)
|
||||
return str(activity.get("status") or "unknown")
|
||||
|
||||
|
||||
async def _user_ids_with_active_subscription_never_connected(
|
||||
session: AsyncSession,
|
||||
panel_service: Any,
|
||||
) -> List[int]:
|
||||
panel_uuids_by_user = await _active_subscription_panel_uuids_by_user(session)
|
||||
semaphore = asyncio.Semaphore(PANEL_ACTIVITY_LOOKUP_CONCURRENCY)
|
||||
|
||||
async def lookup(panel_uuid: str) -> str:
|
||||
async with semaphore:
|
||||
return await _panel_connection_status(panel_service, panel_uuid)
|
||||
|
||||
panel_uuids = list(
|
||||
dict.fromkeys(
|
||||
panel_uuid
|
||||
for user_panel_uuids in panel_uuids_by_user.values()
|
||||
for panel_uuid in user_panel_uuids
|
||||
)
|
||||
)
|
||||
statuses_by_uuid = dict(
|
||||
zip(
|
||||
panel_uuids,
|
||||
await asyncio.gather(*(lookup(uuid) for uuid in panel_uuids)),
|
||||
)
|
||||
)
|
||||
|
||||
user_ids: List[int] = []
|
||||
for user_id, panel_uuids in panel_uuids_by_user.items():
|
||||
statuses = [statuses_by_uuid.get(panel_uuid, "unknown") for panel_uuid in panel_uuids]
|
||||
if statuses and all(status == "never" for status in statuses):
|
||||
user_ids.append(user_id)
|
||||
return user_ids
|
||||
|
||||
|
||||
def _admin_broadcast_audience_counts_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(
|
||||
getattr(settings, "ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS", 30) or 0
|
||||
)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:broadcast_audience_counts",
|
||||
)
|
||||
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_broadcast_audience_counts(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: Any,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
cache = _admin_broadcast_audience_counts_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
)
|
||||
cache_key = "with-panel" if panel_service is not None else "without-panel"
|
||||
return await cache.get_or_load(
|
||||
cache_key,
|
||||
lambda: _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: Any,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
async with async_session_factory() as session:
|
||||
counts: Dict[str, Optional[int]] = {
|
||||
"all": await user_dal.count_all_active_users_for_broadcast(session),
|
||||
"active": await user_dal.count_users_with_active_subscription_for_broadcast(session),
|
||||
"inactive": await user_dal.count_users_without_active_subscription_for_broadcast(
|
||||
session
|
||||
),
|
||||
"expired": await user_dal.count_users_with_expired_subscription_for_broadcast(session),
|
||||
"never": await user_dal.count_users_without_any_subscription_for_broadcast(session),
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: None,
|
||||
}
|
||||
if panel_service is not None:
|
||||
counts[BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED] = len(
|
||||
await _user_ids_with_active_subscription_never_connected(
|
||||
session,
|
||||
panel_service,
|
||||
)
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
@@ -9,7 +169,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
target = str(payload.get("target") or "all").strip().lower()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if target not in {"all", "active", "inactive", "expired", "never"}:
|
||||
if target not in BROADCAST_TARGETS:
|
||||
target = "all"
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
@@ -18,7 +178,15 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
if target == "active":
|
||||
if target == BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED:
|
||||
panel_service = _resolve_panel_service(request)
|
||||
if panel_service is None:
|
||||
return _error(503, "panel_service_unavailable")
|
||||
user_ids = await _user_ids_with_active_subscription_never_connected(
|
||||
session,
|
||||
panel_service,
|
||||
)
|
||||
elif target == "active":
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
@@ -62,14 +230,13 @@ async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Res
|
||||
"""Return how many users each broadcast audience currently resolves to."""
|
||||
_require_admin_user_id(request)
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
counts = {
|
||||
"all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)),
|
||||
"active": len(await user_dal.get_user_ids_with_active_subscription(session)),
|
||||
"inactive": len(await user_dal.get_user_ids_without_active_subscription(session)),
|
||||
"expired": len(await user_dal.get_user_ids_with_expired_subscription(session)),
|
||||
"never": len(await user_dal.get_user_ids_without_any_subscription(session)),
|
||||
}
|
||||
panel_service = _resolve_panel_service(request)
|
||||
counts = await _load_broadcast_audience_counts(
|
||||
settings,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
)
|
||||
|
||||
return _ok({"counts": counts})
|
||||
|
||||
@@ -22,6 +22,175 @@ async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
_PANEL_LAST_CONNECTED_KEYS = (
|
||||
"onlineAt",
|
||||
"online_at",
|
||||
"lastSeenAt",
|
||||
"last_seen_at",
|
||||
"lastConnectedAt",
|
||||
"last_connected_at",
|
||||
"lastConnectionAt",
|
||||
"last_connection_at",
|
||||
)
|
||||
_PANEL_CONNECTION_MARKER_KEYS = (
|
||||
*_PANEL_LAST_CONNECTED_KEYS,
|
||||
"firstConnectedAt",
|
||||
"first_connected_at",
|
||||
"lastConnectedNodeUuid",
|
||||
"last_connected_node_uuid",
|
||||
)
|
||||
_PANEL_CONNECTION_MARKER_OBJECT_KEYS = ("lastConnectedNode", "last_connected_node")
|
||||
_PANEL_TRAFFIC_OBJECT_KEYS = ("userTraffic", "user_traffic", "traffic", "trafficStats")
|
||||
_PANEL_TRAFFIC_USED_KEYS = (
|
||||
"lifetimeUsedTrafficBytes",
|
||||
"lifetime_used_traffic_bytes",
|
||||
"usedTrafficBytes",
|
||||
"used_traffic_bytes",
|
||||
"trafficUsedBytes",
|
||||
"traffic_used_bytes",
|
||||
"downloadBytes",
|
||||
"download_bytes",
|
||||
"uploadBytes",
|
||||
"upload_bytes",
|
||||
)
|
||||
|
||||
|
||||
def _panel_user_payload(panel_user_data: Any) -> Dict[str, Any]:
|
||||
if not isinstance(panel_user_data, dict):
|
||||
return {}
|
||||
response = panel_user_data.get("response")
|
||||
if isinstance(response, dict) and not any(
|
||||
key in panel_user_data
|
||||
for key in ("uuid", "shortUuid", "subscriptionUrl", "userTraffic", "status")
|
||||
):
|
||||
return response
|
||||
return panel_user_data
|
||||
|
||||
|
||||
def _coerce_panel_datetime(value: Any) -> Optional[str]:
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (int, float)):
|
||||
if value <= 0:
|
||||
return None
|
||||
seconds = float(value) / 1000.0 if value > 10_000_000_000 else float(value)
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text.lower() in {"0", "null", "none", "never"}:
|
||||
return None
|
||||
if text.isdigit():
|
||||
return _coerce_panel_datetime(int(text))
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.isoformat()
|
||||
|
||||
|
||||
def _coerce_panel_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _panel_nested_dicts(panel_user: Dict[str, Any], keys: Tuple[str, ...]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for key in keys:
|
||||
value = panel_user.get(key)
|
||||
if isinstance(value, dict):
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
def _panel_user_connection_containers(panel_user: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
traffic_containers = _panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)
|
||||
marker_containers = _panel_nested_dicts(
|
||||
panel_user,
|
||||
_PANEL_CONNECTION_MARKER_OBJECT_KEYS,
|
||||
)
|
||||
for traffic_container in traffic_containers:
|
||||
marker_containers.extend(
|
||||
_panel_nested_dicts(traffic_container, _PANEL_CONNECTION_MARKER_OBJECT_KEYS)
|
||||
)
|
||||
return [panel_user, *traffic_containers, *marker_containers]
|
||||
|
||||
|
||||
def _panel_user_last_connected_at(panel_user_data: Any) -> Optional[str]:
|
||||
panel_user = _panel_user_payload(panel_user_data)
|
||||
if not panel_user:
|
||||
return None
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in _PANEL_LAST_CONNECTED_KEYS:
|
||||
connected_at = _coerce_panel_datetime(container.get(key))
|
||||
if connected_at:
|
||||
return connected_at
|
||||
return None
|
||||
|
||||
|
||||
def _panel_user_positive_traffic_bytes(panel_user: Dict[str, Any]) -> bool:
|
||||
containers = [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]
|
||||
for container in containers:
|
||||
for key in _PANEL_TRAFFIC_USED_KEYS:
|
||||
value = _coerce_panel_int(container.get(key))
|
||||
if value is not None and value > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_has_connection_marker(panel_user: Dict[str, Any]) -> bool:
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in _PANEL_CONNECTION_MARKER_KEYS:
|
||||
if key in container:
|
||||
return True
|
||||
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
|
||||
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
|
||||
if key in container:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_has_connected_marker_value(panel_user: Dict[str, Any]) -> bool:
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in (*_PANEL_LAST_CONNECTED_KEYS, "firstConnectedAt", "first_connected_at"):
|
||||
if _coerce_panel_datetime(container.get(key)):
|
||||
return True
|
||||
for key in ("lastConnectedNodeUuid", "last_connected_node_uuid"):
|
||||
if str(container.get(key) or "").strip():
|
||||
return True
|
||||
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
|
||||
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
|
||||
marker = container.get(key)
|
||||
if isinstance(marker, dict) and any(
|
||||
str(value or "").strip() for value in marker.values()
|
||||
):
|
||||
return True
|
||||
if marker and not isinstance(marker, dict):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_connection_activity(panel_user_data: Any) -> Dict[str, Any]:
|
||||
panel_user = _panel_user_payload(panel_user_data)
|
||||
last_connected_at = _panel_user_last_connected_at(panel_user)
|
||||
if not panel_user:
|
||||
return {"status": "unknown", "last_connected_at": None}
|
||||
if last_connected_at or _panel_user_positive_traffic_bytes(panel_user):
|
||||
return {"status": "connected", "last_connected_at": last_connected_at}
|
||||
if _panel_user_has_connected_marker_value(panel_user):
|
||||
return {"status": "connected", "last_connected_at": last_connected_at}
|
||||
if _panel_user_has_connection_marker(panel_user):
|
||||
return {"status": "never", "last_connected_at": None}
|
||||
return {"status": "unknown", "last_connected_at": None}
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> Dict[str, Any]:
|
||||
return {
|
||||
"user_id": int(user.user_id),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import _ok
|
||||
from bot.services.config_health_service import collect_config_alerts
|
||||
|
||||
|
||||
async def admin_health_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
refresh = str(request.query.get("refresh", "")).strip().lower() in {"1", "true", "yes"}
|
||||
alerts = await collect_config_alerts(request, refresh=refresh)
|
||||
return _ok(
|
||||
{
|
||||
"alerts": alerts,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
@@ -6,6 +6,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router = app.router
|
||||
router.add_get("/api/admin/me", admin_me_route)
|
||||
router.add_get("/api/admin/stats", admin_stats_route)
|
||||
router.add_get("/api/admin/health", admin_health_route)
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
|
||||
@@ -25,7 +25,6 @@ WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webap
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
|
||||
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
|
||||
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
@@ -171,14 +170,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
# Emoji logos were removed; purge any leftover animated-emoji cache files.
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
|
||||
@@ -5,6 +5,7 @@ from .common import (
|
||||
_build_admin_webapp_referral_link,
|
||||
_error,
|
||||
_ok,
|
||||
_panel_user_connection_activity,
|
||||
_premium_traffic_list_payload,
|
||||
_read_json,
|
||||
_serialize_payment,
|
||||
@@ -836,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
# imports into their VPN client. May be missing if the user has never
|
||||
# been provisioned on the panel.
|
||||
subscription_url: Optional[str] = None
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None)
|
||||
last_vpn_connected_at: Optional[str] = None
|
||||
vpn_connection_status = "unknown"
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None) or getattr(
|
||||
active_sub,
|
||||
"panel_user_uuid",
|
||||
None,
|
||||
)
|
||||
if panel_uuid:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
@@ -845,9 +852,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
if panel_data:
|
||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
||||
vpn_activity = _panel_user_connection_activity(panel_data)
|
||||
vpn_connection_status = str(vpn_activity.get("status") or "unknown")
|
||||
last_vpn_connected_at = vpn_activity.get("last_connected_at")
|
||||
except Exception as exc_panel: # pragma: no cover
|
||||
logger.warning(
|
||||
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
|
||||
"Failed to fetch panel details for user %s (uuid=%s): %s",
|
||||
target_id,
|
||||
panel_uuid,
|
||||
exc_panel,
|
||||
@@ -869,6 +879,8 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
"log_count": int(log_count or 0),
|
||||
"subscription_url": subscription_url,
|
||||
"last_vpn_connected_at": last_vpn_connected_at,
|
||||
"vpn_connection_status": vpn_connection_status,
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
|
||||
@@ -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"),
|
||||
@@ -811,10 +821,11 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"bool",
|
||||
"system",
|
||||
"Анонимная статистика установки",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||
"доменов. Помогает понять число активных установок и какие версии "
|
||||
"используются. Можно отключить здесь без перезапуска.",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, маркер образа "
|
||||
"official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных "
|
||||
"данных, токенов и доменов. Помогает понять число активных установок, какие "
|
||||
"версии используются и долю изменённых сборок. Можно отключить здесь без "
|
||||
"перезапуска.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon-precomposed.png"
|
||||
/>
|
||||
<title>/minishop</title>
|
||||
<title>Subscription</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||
<style>
|
||||
.app-boot-fallback {
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
.theme-key-ascii .admin-toolbar-card,
|
||||
.theme-key-ascii .admin-table-card,
|
||||
.theme-key-ascii .admin-panel-dash-card,
|
||||
.theme-key-ascii .admin-config-alerts,
|
||||
.theme-key-ascii .admin-select-trigger,
|
||||
.theme-key-ascii .admin-select-content,
|
||||
.theme-key-ascii .admin-cn-card[data-slot="card"],
|
||||
@@ -199,7 +200,8 @@
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
.theme-key-ascii .admin-revenue-period-btn,
|
||||
.theme-key-ascii .admin-mobile-toggle,
|
||||
.theme-key-ascii .admin-nav-item {
|
||||
.theme-key-ascii .admin-nav-item,
|
||||
.theme-key-ascii .admin-config-alert-link {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
@@ -216,7 +218,8 @@
|
||||
.theme-key-ascii .admin-nav-item:hover,
|
||||
.theme-key-ascii .admin-tabs-trigger:hover,
|
||||
.theme-key-ascii .admin-revenue-period-btn:hover,
|
||||
.theme-key-ascii .bottom-nav button:hover {
|
||||
.theme-key-ascii .bottom-nav button:hover,
|
||||
.theme-key-ascii .admin-config-alert-link:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
@@ -393,6 +396,58 @@
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ---------- Admin health config alerts ---------- */
|
||||
|
||||
.theme-key-ascii .admin-config-alerts {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts::before {
|
||||
content: "!";
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 7px;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts-error {
|
||||
border-color: #ff5555;
|
||||
color: #ffaaaa;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts-error::before {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-dot {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-dot::before {
|
||||
content: ">";
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-error .admin-config-alert-dot {
|
||||
background: transparent;
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-link {
|
||||
padding: 1px 7px;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-ascii .trial-offer-card,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 6,
|
||||
"assets_version": 7,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
|
||||
@@ -252,6 +252,27 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Admin health config alerts */
|
||||
|
||||
.theme-key-light .admin-config-alerts {
|
||||
border-color: color-mix(in srgb, var(--warning) 38%, var(--admin-border));
|
||||
background: color-mix(in srgb, var(--warning) 9%, #ffffff);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alerts-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 38%, var(--admin-border));
|
||||
background: color-mix(in srgb, var(--danger) 8%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alert-link {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alert-link:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Telegram notifications banner: keep the warm warning tint but swap the
|
||||
* dark-theme inset bevel for the soft drop shadow the other light cards use. */
|
||||
.theme-key-light .telegram-notifications-card {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 5,
|
||||
"assets_version": 6,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
|
||||
@@ -1267,6 +1267,59 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
/* Admin health config alerts */
|
||||
|
||||
.theme-key-windows95 .admin-config-alerts {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #ffffcc;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alerts-error {
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #f7d6d6;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-dot {
|
||||
border-radius: 0;
|
||||
background: #808000;
|
||||
box-shadow:
|
||||
1px 1px 0 #ffffff,
|
||||
-1px -1px 0 #404040;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-error .admin-config-alert-dot {
|
||||
background: #800000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link:hover {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link:active {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 input::placeholder,
|
||||
.theme-key-windows95 textarea::placeholder,
|
||||
.theme-key-windows95 .input::placeholder,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 13,
|
||||
"assets_version": 14,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
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 +22,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,
|
||||
@@ -48,6 +85,8 @@ async def build_and_start_web_app(
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
after_webhooks_started: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
):
|
||||
app = web.Application()
|
||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||
@@ -110,7 +149,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(
|
||||
@@ -123,6 +162,8 @@ async def build_and_start_web_app(
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
if after_webhooks_started is not None:
|
||||
await after_webhooks_started()
|
||||
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||
@@ -133,7 +174,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(
|
||||
|
||||
@@ -11,9 +11,20 @@ from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
def _email_auth_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "email_auth_configured", True))
|
||||
|
||||
|
||||
def _email_auth_not_configured_response() -> web.Response:
|
||||
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
payload = await _read_json(request)
|
||||
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
|
||||
if validation_error:
|
||||
@@ -40,6 +51,10 @@ async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
@@ -55,7 +70,6 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
email = email_payload.email
|
||||
code = str(email_payload.code or "")
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
merge_notice: Optional[Dict[str, Any]] = None
|
||||
source_panel_uuid: Optional[str] = None
|
||||
@@ -210,6 +224,9 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
@@ -232,6 +249,10 @@ async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings = request.app.get("settings")
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||
if validation_error:
|
||||
|
||||
@@ -18,6 +18,8 @@ _GZIP_BODY_CACHE: Dict[str, bytes] = {}
|
||||
_ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {}
|
||||
_I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {}
|
||||
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
|
||||
WEBAPP_HTML_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
WEBAPP_LEGACY_ASSET_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
|
||||
|
||||
async def health_route(request: web.Request) -> web.Response:
|
||||
@@ -48,7 +50,7 @@ async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Respo
|
||||
allow_precompressed=bool(asset_hash),
|
||||
)
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
|
||||
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -382,11 +384,15 @@ async def webapp_current_favicon_route(request: web.Request) -> web.Response:
|
||||
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
|
||||
digest = _webapp_generated_favicon_digest(favicon_url)
|
||||
if digest:
|
||||
return _webapp_favicon_file_response(digest, target_filename)
|
||||
response = _webapp_favicon_file_response(digest, target_filename)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename)
|
||||
if redirect_url:
|
||||
raise web.HTTPFound(location=redirect_url)
|
||||
redirect = web.HTTPFound(location=redirect_url)
|
||||
redirect.headers["Cache-Control"] = "no-cache"
|
||||
raise redirect
|
||||
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
@@ -897,7 +903,7 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
|
||||
strip_dev_mock=not asset_hash,
|
||||
)
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
|
||||
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -1136,9 +1142,11 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
bootstrap = _build_webapp_bootstrap_payload(request)
|
||||
config = bootstrap["config"]
|
||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||
css_asset_name = _resolve_webapp_css_asset_name()
|
||||
js_asset_name = _resolve_webapp_js_asset_name()
|
||||
html = html.replace(
|
||||
'href="/subscription_webapp.css"',
|
||||
f'href="/{_resolve_webapp_css_asset_name()}"',
|
||||
f'href="/{css_asset_name}"',
|
||||
1,
|
||||
)
|
||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||
@@ -1165,7 +1173,7 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
html = html.replace(
|
||||
WEBAPP_JS_PLACEHOLDER,
|
||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||
f'<script src="/{js_asset_name}" defer></script>',
|
||||
)
|
||||
brand_asset_url = cached["logo_url"]
|
||||
if brand_asset_url:
|
||||
@@ -1178,7 +1186,9 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
1,
|
||||
)
|
||||
response = web.Response(text=html, content_type="text/html", charset="utf-8")
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["Cache-Control"] = WEBAPP_HTML_CACHE_CONTROL
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
|
||||
@@ -1444,10 +1454,15 @@ def _resolve_webapp_js_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_js_asset_name() -> str:
|
||||
# The admin bundle is lazy-loaded from the already running Mini App. In
|
||||
# deployments where nginx serves static files in front of aiohttp, stale
|
||||
# hashed admin filenames can 404 even though the runtime build asset exists.
|
||||
return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
|
||||
# The admin bundle is lazy-loaded from the already running Mini App. It now
|
||||
# ships content-hashed alongside the main bundle (same build, deterministic
|
||||
# hashes, served immutable), so iOS WebViews fetch fresh admin assets on every
|
||||
# deploy. The App.svelte loader falls back to the bare runtime build name if a
|
||||
# hashed asset ever 404s.
|
||||
return _resolve_hashed_js_asset_name(
|
||||
kind="admin-js",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
||||
@@ -1466,7 +1481,7 @@ def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
||||
if minified_assets:
|
||||
minified_assets.sort(reverse=True)
|
||||
return _set_cached_asset_name(kind, minified_assets[0][1])
|
||||
return _set_cached_asset_name(kind, f"{base_name}.js")
|
||||
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.js"))
|
||||
|
||||
|
||||
def _resolve_webapp_css_asset_name() -> str:
|
||||
@@ -1477,9 +1492,11 @@ def _resolve_webapp_css_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_css_asset_name() -> str:
|
||||
# Keep the lazy-loaded admin stylesheet on the stable build filename for
|
||||
# the same reason as the JS bundle above.
|
||||
return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
|
||||
# Content-hashed and immutable, same rationale as the admin JS bundle above.
|
||||
return _resolve_hashed_css_asset_name(
|
||||
kind="admin-css",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
||||
@@ -1498,7 +1515,19 @@ def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
||||
if hashed_assets:
|
||||
hashed_assets.sort(reverse=True)
|
||||
return _set_cached_asset_name(kind, hashed_assets[0][1])
|
||||
return _set_cached_asset_name(kind, f"{base_name}.css")
|
||||
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.css"))
|
||||
|
||||
|
||||
def _stable_asset_name_with_version(filename: str) -> str:
|
||||
path = ASSET_DIR / filename
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return filename
|
||||
|
||||
raw_version = f"{filename}:{int(stat.st_mtime_ns)}:{int(stat.st_size)}"
|
||||
version = hashlib.sha256(raw_version.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{filename}?v={version}"
|
||||
|
||||
|
||||
def _get_cached_asset_name(kind: str) -> Optional[str]:
|
||||
|
||||
@@ -1497,6 +1497,10 @@ async def _grant_referral_welcome_bonus_if_eligible(
|
||||
return None
|
||||
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
default_tariff_key = None
|
||||
tariffs_config = getattr(settings, "tariffs_config", None)
|
||||
if tariffs_config:
|
||||
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
|
||||
try:
|
||||
if await subscription_service.has_active_subscription(session, int(user.user_id)):
|
||||
return None
|
||||
@@ -1508,6 +1512,7 @@ async def _grant_referral_welcome_bonus_if_eligible(
|
||||
int(user.user_id),
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
tariff_key=default_tariff_key,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1068,28 +1068,44 @@ async def _create_subscription_payment(
|
||||
"payment_amount_below_minimum",
|
||||
"Payment amount is below the provider minimum",
|
||||
)
|
||||
return await provider_spec.create_webapp_payment(
|
||||
WebAppPaymentContext(
|
||||
request=request,
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
method=method,
|
||||
months=months,
|
||||
price=price,
|
||||
stars_price=stars_price,
|
||||
currency=payment_currency,
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
|
||||
)
|
||||
payment_context = WebAppPaymentContext(
|
||||
request=request,
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
method=method,
|
||||
months=months,
|
||||
price=price,
|
||||
stars_price=stars_price,
|
||||
currency=payment_currency,
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
|
||||
)
|
||||
if provider_spec.reuse_webapp_payment:
|
||||
from bot.payment_providers.shared import reusable_webapp_payment_response
|
||||
|
||||
try:
|
||||
reusable_response = await reusable_webapp_payment_response(
|
||||
payment_context,
|
||||
provider_spec,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to verify reusable payment: user_id=%s provider=%s",
|
||||
user_id,
|
||||
provider_spec.provider_key,
|
||||
)
|
||||
reusable_response = None
|
||||
if reusable_response is not None:
|
||||
return reusable_response
|
||||
return await provider_spec.create_webapp_payment(payment_context)
|
||||
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
Reference in New Issue
Block a user