feat: tune webapp visual, use telegram widget for login

This commit is contained in:
3252a8
2026-04-23 14:06:21 +03:00
parent eab803652b
commit b9cb1fec06
14 changed files with 6265 additions and 1212 deletions
+272 -57
View File
@@ -4,16 +4,15 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from aiohttp import web
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.webapp_auth import (
consume_authorized_webapp_auth_token,
create_pending_webapp_auth_token,
create_webapp_session_token,
validate_telegram_login_widget_data,
validate_telegram_webapp_init_data,
verify_webapp_session_token,
)
@@ -31,6 +30,16 @@ from db.models import Payment, User
logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
TELEGRAM_WEB_APP_SDK_URL = "https://telegram.org/js/telegram-web-app.js"
TELEGRAM_WEB_APP_SDK_PATH = ASSET_DIR / "telegram-web-app.js"
TELEGRAM_WIDGET_SDK_URL = "https://telegram.org/js/telegram-widget.js?23"
TELEGRAM_WIDGET_SDK_PATH = ASSET_DIR / "telegram-widget.js"
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
def create_subscription_webapp_application(
@@ -57,6 +66,9 @@ def create_subscription_webapp_application(
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
setup_subscription_webapp_routes(app)
return app
@@ -64,9 +76,11 @@ def create_subscription_webapp_application(
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/health", health_route)
app.router.add_get("/telegram-web-app.js", telegram_web_app_asset_route)
app.router.add_get("/telegram-widget.js", telegram_widget_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_get("/api/auth/request-token", auth_request_token_route)
app.router.add_get("/api/auth/check-token/{token}", auth_check_token_route)
app.router.add_get("/api/me", me_route)
app.router.add_post("/api/payments", create_payment_route)
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
@@ -76,6 +90,171 @@ async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def css_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
async def telegram_web_app_asset_route(request: web.Request) -> web.Response:
if not TELEGRAM_WEB_APP_SDK_PATH.exists():
await refresh_telegram_web_app_sdk()
try:
response = await _serve_template_asset(
request,
"telegram-web-app.js",
"application/javascript",
)
except FileNotFoundError:
logger.exception(
"Telegram Web App SDK is unavailable at %s",
TELEGRAM_WEB_APP_SDK_PATH,
)
raise web.HTTPServiceUnavailable(text="telegram_web_app_sdk_unavailable")
response.headers["Cache-Control"] = "no-cache"
return response
async def telegram_widget_asset_route(request: web.Request) -> web.Response:
if not TELEGRAM_WIDGET_SDK_PATH.exists():
await refresh_telegram_login_widget_sdk()
try:
data = TELEGRAM_WIDGET_SDK_PATH.read_bytes()
except FileNotFoundError:
logger.exception(
"Telegram Login Widget SDK is unavailable at %s",
TELEGRAM_WIDGET_SDK_PATH,
)
raise web.HTTPServiceUnavailable(text="telegram_widget_sdk_unavailable")
data = _normalize_telegram_login_widget_sdk(data)
response = web.Response(body=data, content_type="application/javascript")
response.headers["Cache-Control"] = "no-cache"
return response
async def refresh_telegram_web_app_sdk() -> bool:
"""Best-effort refresh of the vendored Telegram Web App SDK."""
try:
timeout = ClientTimeout(total=30)
async with ClientSession(
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
) as session:
async with session.get(TELEGRAM_WEB_APP_SDK_URL) as response:
if response.status != 200:
logger.warning(
"Telegram Web App SDK refresh returned HTTP %s; keeping the bundled copy.",
response.status,
)
return False
data = await response.read()
except Exception as exc:
logger.warning("Failed to refresh Telegram Web App SDK: %s", exc)
return False
try:
TELEGRAM_WEB_APP_SDK_PATH.parent.mkdir(parents=True, exist_ok=True)
existing_data = (
TELEGRAM_WEB_APP_SDK_PATH.read_bytes()
if TELEGRAM_WEB_APP_SDK_PATH.exists()
else None
)
if existing_data == data:
logger.info("Telegram Web App SDK is already up to date.")
return True
temp_path = TELEGRAM_WEB_APP_SDK_PATH.with_name(
f"{TELEGRAM_WEB_APP_SDK_PATH.name}.tmp"
)
temp_path.write_bytes(data)
temp_path.replace(TELEGRAM_WEB_APP_SDK_PATH)
logger.info(
"Telegram Web App SDK updated at %s (%d bytes).",
TELEGRAM_WEB_APP_SDK_PATH,
len(data),
)
return True
except Exception as exc:
logger.warning("Failed to store Telegram Web App SDK locally: %s", exc)
return False
async def refresh_telegram_login_widget_sdk() -> bool:
"""Best-effort refresh of the vendored Telegram Login Widget SDK."""
try:
timeout = ClientTimeout(total=30)
async with ClientSession(
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
) as session:
async with session.get(TELEGRAM_WIDGET_SDK_URL) as response:
if response.status != 200:
logger.warning(
"Telegram Login Widget SDK refresh returned HTTP %s; keeping the bundled copy.",
response.status,
)
return False
data = await response.read()
except Exception as exc:
logger.warning("Failed to refresh Telegram Login Widget SDK: %s", exc)
return False
try:
data = _normalize_telegram_login_widget_sdk(data)
TELEGRAM_WIDGET_SDK_PATH.parent.mkdir(parents=True, exist_ok=True)
existing_data = (
TELEGRAM_WIDGET_SDK_PATH.read_bytes()
if TELEGRAM_WIDGET_SDK_PATH.exists()
else None
)
if existing_data == data:
logger.info("Telegram Login Widget SDK is already up to date.")
return True
temp_path = TELEGRAM_WIDGET_SDK_PATH.with_name(
f"{TELEGRAM_WIDGET_SDK_PATH.name}.tmp"
)
temp_path.write_bytes(data)
temp_path.replace(TELEGRAM_WIDGET_SDK_PATH)
logger.info(
"Telegram Login Widget SDK updated at %s (%d bytes).",
TELEGRAM_WIDGET_SDK_PATH,
len(data),
)
return True
except Exception as exc:
logger.warning("Failed to store Telegram Login Widget SDK locally: %s", exc)
return False
def _normalize_telegram_login_widget_sdk(data: bytes) -> bytes:
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
text = data.decode("utf-8")
normalized = text.replace(
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
_PATCHED_WIDGET_ORIGIN_SNIPPET,
1,
)
return normalized.encode("utf-8")
async def js_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(
request,
"subscription_webapp.js",
"application/javascript",
strip_dev_mock=True,
)
async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
@@ -87,25 +266,76 @@ async def index_route(request: web.Request) -> web.Response:
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"logoUrl": settings.WEBAPP_LOGO_URL or "",
"apiBase": "/api",
"telegramLoginBotUsername": request.app.get("bot_username") or "",
"supportUrl": settings.SUPPORT_LINK or "",
"privacyPolicyUrl": settings.PRIVACY_POLICY_URL or "",
"userAgreementUrl": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
}
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
html = html.replace(
"__WEBAPP_CONFIG__",
json.dumps(config, ensure_ascii=False, separators=(",", ":")),
WEBAPP_CONFIG_PLACEHOLDER,
(
"<script>window.__WEBAPP_CONFIG__="
+ json.dumps(config, ensure_ascii=False, separators=(",", ":"))
+ ";</script>"
),
)
return web.Response(text=html, content_type="text/html", charset="utf-8")
async def _serve_template_asset(
request: web.Request,
filename: str,
content_type: str,
*,
strip_dev_mock: bool = False,
) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
path = ASSET_DIR / filename
text = path.read_text(encoding="utf-8")
if strip_dev_mock:
text = _strip_marked_block(
text,
"/* WEBAPP_DEV_MOCK_START */",
"/* WEBAPP_DEV_MOCK_END */",
)
return web.Response(text=text, content_type=content_type, charset="utf-8")
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
start = html.find(start_marker)
if start == -1:
return html
end = html.find(end_marker, start)
if end == -1:
return html[:start]
return html[:start] + html[end + len(end_marker):]
async def auth_token_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
init_data = str(payload.get("init_data") or "")
telegram_user = validate_telegram_webapp_init_data(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
auth_data = payload.get("auth_data")
telegram_user = None
if init_data:
telegram_user = validate_telegram_webapp_init_data(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
elif auth_data is not None:
telegram_user = validate_telegram_login_widget_data(
auth_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
@@ -126,40 +356,6 @@ async def auth_token_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True, "token": token})
async def auth_request_token_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
token = create_pending_webapp_auth_token(settings)
bot: Bot = request.app["bot"]
bot_info = await bot.get_me()
auth_url = f"https://t.me/{bot_info.username}?start=webapp_auth_{token}"
return web.json_response({"ok": True, "token": token, "auth_url": auth_url})
async def auth_check_token_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
token = request.match_info.get("token", "")
user_id = consume_authorized_webapp_auth_token(settings, token)
if not user_id:
return web.json_response({"ok": True, "authorized": False})
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return web.json_response(
{"ok": True, "authorized": False, "error": "Access denied"}
)
session_token = create_webapp_session_token(settings, user_id)
return web.json_response(
{
"ok": True,
"authorized": True,
"token": session_token,
}
)
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
data = await _build_user_payload(request, user_id)
@@ -317,15 +513,16 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
except Exception:
await session.rollback()
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return {
"user": {
"id": user_id,
"username": db_user.username,
"first_name": db_user.first_name,
"language_code": db_user.language_code or settings.DEFAULT_LANGUAGE,
"language_code": lang,
},
"subscription": _serialize_subscription(active, local_sub),
"plans": _serialize_plans(settings),
"subscription": _serialize_subscription(active, local_sub, lang),
"plans": _serialize_plans(settings, lang),
"payment_methods": _serialize_payment_methods(settings, request.app),
"settings": {
"support_url": settings.SUPPORT_LINK,
@@ -337,12 +534,13 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
def _serialize_subscription(
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
) -> Dict[str, Any]:
if not active:
return {
"active": False,
"status": "INACTIVE",
"remaining_text": "Нет активной подписки",
"remaining_text": _format_remaining(0, lang),
"days_left": 0,
"config_link": None,
"connect_url": None,
@@ -365,7 +563,7 @@ def _serialize_subscription(
"end_date": end_date.isoformat() if end_date else None,
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A",
"days_left": seconds_left // 86400,
"remaining_text": _format_remaining(seconds_left),
"remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes")),
@@ -375,14 +573,14 @@ def _serialize_subscription(
}
def _serialize_plans(settings: Settings) -> List[Dict[str, Any]]:
def _serialize_plans(settings: Settings, lang: str) -> List[Dict[str, Any]]:
plans: List[Dict[str, Any]] = []
for months, price in sorted(settings.subscription_options.items()):
plan = {
"months": int(months),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_months_title(int(months)),
"title": _format_months_title(int(months), lang),
}
stars_price = settings.stars_subscription_options.get(months)
if stars_price is not None and int(stars_price) > 0:
@@ -843,12 +1041,25 @@ async def _create_stars_payment(
return _json_error(502, "payment_failed", "Failed to create invoice")
def _format_remaining(seconds: int) -> str:
def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower()
return value if value in {"ru", "en"} else "ru"
def _format_remaining(seconds: int, lang: str) -> str:
if seconds <= 0:
if lang == "en":
return "Subscription inactive"
return "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if lang == "en":
if days > 0:
return f"{days} d. {hours} h."
if hours > 0:
return f"{hours} h. {minutes} min."
return f"{max(1, minutes)} min."
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
@@ -873,7 +1084,11 @@ def _format_bytes(value: Optional[Any]) -> str:
return f"{size:.2f} {units[index]}"
def _format_months_title(months: int) -> str:
def _format_months_title(months: int, lang: str) -> str:
if lang == "en":
if months == 1:
return "1 month"
return f"{months} months"
if months == 1:
return "1 месяц"
if 2 <= months <= 4:
@@ -883,5 +1098,5 @@ def _format_months_title(months: int) -> str:
def _payment_description(months: int, lang: str) -> str:
if lang == "en":
return f"Subscription for {months} month(s)"
return f"Подписка на {_format_months_title(months)}"
return f"Subscription for {_format_months_title(months, lang)}"
return f"Подписка на {_format_months_title(months, lang)}"
@@ -0,0 +1,836 @@
:root {
color-scheme: dark;
--bg-primary: #05070a;
--bg-secondary: #0a0e17;
--bg-card: #0f1521;
--bg-card-hover: #161e2e;
--surface: #1e2940;
--border: #1e2940;
--border-strong: #2a3752;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #00fe7a;
--accent-hover: #5aff9f;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 8px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--app-safe-top-extra: 10px;
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
letter-spacing: 0;
}
body {
background: var(--bg-primary);
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, transparent), transparent 320px),
var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-sans);
line-height: 1.5;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
body.modal-open {
overflow: hidden;
}
button,
a {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:disabled {
cursor: progress;
opacity: 0.58;
}
.app {
width: min(100%, 560px);
margin: 0 auto;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 18px);
display: flex;
flex-direction: column;
gap: 14px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
}
.brand {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.brand-logo,
.icon-btn {
width: 38px;
height: 38px;
flex: 0 0 auto;
}
.brand-logo {
display: block;
object-fit: contain;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
}
.brand-logo--lg {
width: 84px;
height: 84px;
}
.brand-title {
max-width: 260px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 17px;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0.04em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.icon-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
color: var(--text-secondary);
transition: var(--transition);
}
.icon-btn:active,
.btn:active,
.plan:active,
.method:active {
transform: translateY(1px);
}
.icon,
.icon-btn svg,
.btn svg {
width: 16px;
height: 16px;
display: block;
fill: currentColor;
flex: 0 0 auto;
}
.panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)), var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38);
}
.subscription-panel {
padding: 16px;
display: grid;
gap: 16px;
}
.badge-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mono-pill,
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.mono-pill {
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 9%, transparent);
color: var(--accent);
}
.badge {
background: rgba(16, 185, 129, 0.1);
color: var(--success);
}
.badge.off {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
}
.status-title {
margin: 0;
font-size: 32px;
font-weight: 850;
line-height: 1.08;
overflow-wrap: anywhere;
}
.gradient-text {
color: var(--text-primary);
background: linear-gradient(135deg, var(--text-primary), color-mix(in srgb, var(--accent) 68%, #ffffff));
-webkit-background-clip: text;
background-clip: text;
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.gradient-text {
color: transparent;
}
}
@supports (-webkit-text-fill-color: transparent) {
.gradient-text {
-webkit-text-fill-color: transparent;
}
}
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
body {
background: var(--bg-primary);
}
.mono-pill,
.btn.primary,
.step.active,
.plan.active,
.method.active {
border-color: var(--accent);
}
.mono-pill,
.step.active,
.plan.active,
.method.active {
background: rgba(0, 254, 122, 0.08);
}
.btn.primary {
background: var(--accent);
}
.gradient-text {
background: none;
color: var(--text-primary);
-webkit-text-fill-color: currentColor;
}
}
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
.gradient-text {
color: var(--text-primary);
-webkit-text-fill-color: currentColor;
}
}
.status-subtitle {
margin: 4px 0 0;
color: var(--text-secondary);
font-size: 14px;
}
.metrics {
display: grid;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.metric {
display: grid;
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
gap: 10px;
min-height: 48px;
align-items: center;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
}
.metric + .metric {
border-top: 1px solid var(--border);
}
.metric-label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.metric-value {
color: var(--text-primary);
font-size: 14px;
font-weight: 750;
text-align: right;
overflow-wrap: anywhere;
}
.actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 48px;
gap: 8px;
}
.step-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.btn {
min-height: 46px;
border-radius: var(--radius-md);
padding: 11px 13px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-primary);
font-weight: 800;
text-align: center;
text-decoration: none;
transition: transform var(--transition), border-color var(--transition), background var(--transition), color var(--transition), box-shadow var(--transition);
}
.btn.primary {
border-color: color-mix(in srgb, var(--accent) 80%, var(--border));
background:
linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, #00c95f));
color: #04110a;
box-shadow: 0 10px 26px color-mix(in srgb, var(--accent) 20%, transparent);
}
.btn.ghost {
color: var(--text-secondary);
}
.btn.full {
width: 100%;
}
.btn.icon-only {
width: 48px;
min-width: 48px;
padding: 0;
}
.logout-btn {
min-height: 38px;
padding: 8px 11px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1;
}
.extend-row {
margin-top: 2px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.extend-btn {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 5%, transparent);
color: var(--accent);
box-shadow: none;
}
.support-link {
margin-top: 2px;
}
.legal-links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px 16px;
margin-top: 4px;
}
#legal-links-login {
width: min(100%, 560px);
}
.legal-link {
flex: 0 1 auto;
display: inline;
padding: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 650;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
text-underline-offset: 3px;
transition: color var(--transition), opacity var(--transition);
}
.legal-link:hover:not(:disabled) {
color: var(--accent);
opacity: 0.98;
}
.modal {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
align-items: center;
justify-items: center;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
}
.modal.show {
opacity: 1;
pointer-events: auto;
}
.modal-backdrop {
position: absolute;
inset: 0;
padding: 0;
border: 0;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(7px);
}
.modal-card {
position: relative;
width: min(100%, 560px);
max-height: min(86vh, 760px);
overflow: auto;
transform: translateY(18px) scale(0.98);
transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.modal.show .modal-card {
transform: translateY(0) scale(1);
}
.payment-flow {
padding: 14px;
display: grid;
gap: 14px;
}
.flow-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.flow-title {
font-size: 18px;
font-weight: 850;
line-height: 1.2;
}
.flow-caption {
margin-top: 3px;
color: var(--text-secondary);
font-size: 13px;
}
.stepper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.step {
min-height: 58px;
padding: 9px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
color: var(--text-muted);
}
.step-num {
display: block;
color: inherit;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 800;
line-height: 1.1;
}
.step-name {
display: block;
margin-top: 5px;
color: inherit;
font-size: 12px;
font-weight: 800;
line-height: 1.15;
}
.step.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
color: var(--accent);
}
.step.done {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
color: var(--text-secondary);
}
.step-pane {
display: grid;
gap: 10px;
}
.section-label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 800;
}
.plans,
.methods {
display: grid;
gap: 8px;
}
.plan,
.method {
width: 100%;
min-height: 64px;
padding: 13px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
transition: transform var(--transition), border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.plan.active,
.method.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 58%, transparent);
}
.plan-name,
.method-name,
.plan-price {
display: block;
font-size: 15px;
font-weight: 850;
line-height: 1.2;
overflow-wrap: anywhere;
}
.plan-meta,
.method-meta {
display: block;
margin-top: 4px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.plan-price {
flex: 0 0 auto;
color: var(--accent);
font-family: var(--font-mono);
text-align: right;
}
.notice,
.empty {
padding: 13px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.result-box {
display: grid;
gap: 10px;
}
.loader {
min-height: 78vh;
display: grid;
place-items: center;
color: var(--text-muted);
font-family: var(--font-mono);
font-weight: 800;
}
.login {
min-height: 100vh;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 18px);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
}
.login.show {
display: flex;
}
.login-card {
width: min(100%, 560px);
padding: 18px;
display: grid;
gap: 13px;
text-align: center;
}
.login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
min-width: 0;
}
.login-brand-title {
max-width: 260px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 24px;
font-weight: 800;
line-height: 1.1;
letter-spacing: 0.04em;
text-align: left;
overflow-wrap: anywhere;
}
.login-text {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.telegram-login-widget {
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
}
.login-status {
min-height: 20px;
margin: 0;
}
.login-status.error {
color: var(--danger);
}
.toast {
position: fixed;
left: 50%;
bottom: max(var(--app-safe-bottom), 18px);
z-index: 40;
width: max-content;
max-width: calc(100vw - 28px);
padding: 11px 13px;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border));
border-radius: var(--radius-md);
background: rgba(15, 21, 33, 0.96);
color: var(--text-primary);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.42);
font-size: 13px;
font-weight: 750;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 8px);
transition: opacity var(--transition), transform var(--transition);
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.hidden {
display: none !important;
}
@media (hover: hover) and (pointer: fine) {
.btn:hover:not(:disabled),
.icon-btn:hover:not(:disabled),
.plan:hover:not(:disabled),
.method:hover:not(:disabled) {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: rgba(255, 255, 255, 0.055);
}
.btn.primary:hover:not(:disabled) {
background:
linear-gradient(135deg, var(--accent-hover), color-mix(in srgb, var(--accent) 78%, #00c95f));
box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 24%, transparent);
}
.extend-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
}
}
@media (max-width: 380px) {
.app {
padding-left: 10px;
padding-right: 10px;
}
.brand-title {
max-width: 164px;
}
.status-title {
font-size: 28px;
}
.metric {
grid-template-columns: 1fr;
}
.metric-value {
text-align: left;
}
.step-actions {
grid-template-columns: 1fr;
}
.stepper {
gap: 6px;
}
.step {
min-height: 54px;
padding: 8px 7px;
}
.logout-btn span {
display: none;
}
.brand-logo--lg {
width: 68px;
height: 68px;
}
.login-brand {
gap: 10px;
}
.login-brand-title {
max-width: 200px;
font-size: 21px;
}
}
@media (max-width: 560px) {
.modal {
align-items: stretch;
justify-items: stretch;
top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
bottom: var(--app-safe-bottom);
padding: 0;
}
.modal-card {
width: 100vw;
max-height: none;
height: 100%;
border-radius: 0;
transform: translateY(12px);
}
.modal.show .modal-card {
transform: translateY(0);
}
.payment-flow {
min-height: 100%;
border-radius: 0;
}
.legal-link {
flex: 1 1 100%;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,888 @@
/* WEBAPP_DEV_MOCK_START */
window.__WEBAPP_DEV_MOCK__ = {
config: {
title: 'Моя подписка',
primaryColor: '#00fe7a',
logoUrl: '',
apiBase: '/api',
supportUrl: 'https://t.me/support',
privacyPolicyUrl: 'https://example.com/privacy',
userAgreementUrl: 'https://example.com/agreement',
currency: 'RUB',
language: 'ru'
},
data: {
ok: true,
user: {
id: 100200300,
username: 'preview',
first_name: 'Preview',
language_code: 'ru'
},
subscription: {
active: true,
status: 'ACTIVE',
remaining_text: '27 д. 5 ч.',
end_date_text: '19.05.2026 22:40',
days_left: 27,
config_link: 'https://sub.example.com/sub/preview-token',
connect_url: 'https://sub.example.com/connect/preview-token',
traffic_used: '18.42 GB',
traffic_limit: '500.00 GB',
auto_renew_enabled: false,
provider: null
},
plans: [
{months: 1, price: 190, currency: 'RUB', title: '1 месяц'},
{months: 3, price: 550, currency: 'RUB', title: '3 месяца'},
{months: 6, price: 1100, currency: 'RUB', title: '6 месяцев'},
{months: 12, price: 2000, currency: 'RUB', title: '12 месяцев'}
],
payment_methods: [
{id: 'cryptopay', name: 'CryptoPay'},
{id: 'platega', name: 'Platega'},
{id: 'freekassa', name: 'FreeKassa / СБП'}
],
settings: {
support_url: 'https://t.me/support',
traffic_mode: false
}
}
};
/* WEBAPP_DEV_MOCK_END */
const MOCK = (() => {
const mock = window.__WEBAPP_DEV_MOCK__;
const host = window.location.hostname;
const isLocal = window.location.protocol === 'file:' || host === 'localhost' || host === '127.0.0.1' || host === '';
return mock && isLocal ? mock : null;
})();
const CFG = window.__WEBAPP_CONFIG__ || (MOCK && MOCK.config) || {};
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js';
const state = {
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
data: null,
selectedPlan: null,
selectedMethod: null,
payment: null,
paymentFlowOpen: false,
paymentStep: 'plan',
creatingPayment: false,
authInProgress: false,
toastTimer: null
};
const I18N = {
ru: {
page_title: 'Моя подписка',
loading: 'Загрузка...',
refresh: 'Обновить',
logout: 'Выйти',
subscription: 'Подписка',
subscription_subtitle: 'Ваша ссылка подключения и срок доступа',
ends_at: 'Окончание',
traffic: 'Трафик',
connect: 'Подключиться',
copy_link: 'Скопировать ссылку',
extend_subscription: 'Продлить подписку/Добавить дни',
payment_title: 'Оплата подписки',
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
close_payment: 'Закрыть оплату',
payment_steps: 'Шаги оплаты',
step_plan: 'Срок',
step_method: 'Метод',
step_result: 'Платеж',
select_period: 'Выберите срок',
select_method: 'Выберите способ оплаты',
payment_status: 'Статус платежа',
choose_payment_method: 'Выбрать способ оплаты',
back: 'Назад',
create_payment: 'Создать платеж',
open_payment: 'Открыть оплату',
check_payment: 'Проверить оплату',
choose_other_method: 'Выбрать другой способ',
support: 'Поддержка',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Проверяю вход...',
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
telegram_auth_unavailable: 'Telegram Login Widget недоступен. Проверьте username бота и доступ к telegram.org.',
telegram_auth_access_denied: 'Доступ запрещен.',
active: 'Активна',
inactive: 'Не активна',
no_active_subscription: 'Нет активной подписки',
not_available: 'N/A',
no_plans: 'Тарифы не настроены.',
access_period: 'Срок доступа',
choose_plan_first: 'Сначала выберите срок подписки.',
no_methods: 'Нет доступных способов оплаты.',
payment_not_created: 'Платеж еще не создан.',
invoice_sent: 'Счет отправлен в чат с ботом. После оплаты вернитесь сюда и проверьте статус.',
payment_created: 'Платеж создан. Откройте оплату, завершите ее у провайдера и затем проверьте статус.',
updating: 'Обновляю данные',
choose_plan_toast: 'Выберите срок подписки',
choose_method_toast: 'Выберите способ оплаты',
create_payment_toast: 'Создайте платеж',
payment_confirmed: 'Оплата подтверждена',
payment_pending: 'Платеж пока не подтвержден',
link_copied: 'Ссылка скопирована',
no_link: 'Ссылка пока недоступна',
payment_error: 'Ошибка оплаты',
privacy_policy: 'Политика конфиденциальности',
user_agreement: 'Пользовательское соглашение'
},
en: {
page_title: 'My subscription',
loading: 'Loading...',
refresh: 'Refresh',
logout: 'Log out',
subscription: 'Subscription',
subscription_subtitle: 'Your connection link and access time',
ends_at: 'Ends at',
traffic: 'Traffic',
connect: 'Connect',
copy_link: 'Copy link',
extend_subscription: 'Renew subscription/Add days',
payment_title: 'Subscription payment',
payment_caption: 'Choose a period, payment method, and create a payment.',
close_payment: 'Close payment',
payment_steps: 'Payment steps',
step_plan: 'Period',
step_method: 'Method',
step_result: 'Payment',
select_period: 'Select period',
select_method: 'Select payment method',
payment_status: 'Payment status',
choose_payment_method: 'Choose payment method',
back: 'Back',
create_payment: 'Create payment',
open_payment: 'Open payment',
check_payment: 'Check payment',
choose_other_method: 'Choose another method',
support: 'Support',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Verifying login...',
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
telegram_auth_unavailable: 'Telegram Login Widget is unavailable. Check the bot username and access to telegram.org.',
telegram_auth_access_denied: 'Access denied.',
active: 'Active',
inactive: 'Inactive',
no_active_subscription: 'No active subscription',
not_available: 'N/A',
no_plans: 'Plans are not configured.',
access_period: 'Access period',
choose_plan_first: 'Choose a subscription period first.',
no_methods: 'No payment methods available.',
payment_not_created: 'Payment has not been created yet.',
invoice_sent: 'The invoice was sent to the bot chat. Pay it, then return here and check the status.',
payment_created: 'Payment was created. Open it, complete payment with the provider, then check the status.',
updating: 'Refreshing data',
choose_plan_toast: 'Choose a subscription period',
choose_method_toast: 'Choose a payment method',
create_payment_toast: 'Create a payment',
payment_confirmed: 'Payment confirmed',
payment_pending: 'Payment is not confirmed yet',
link_copied: 'Link copied',
no_link: 'Link is not available yet',
payment_error: 'Payment error',
privacy_policy: 'Privacy policy',
user_agreement: 'User agreement'
}
};
const accent = CFG.primaryColor || '#00fe7a';
document.documentElement.style.setProperty('--accent', accent);
setBrand();
applyI18n();
applyLegalLinks();
if (CFG.supportUrl) {
const support = document.getElementById('support-link');
support.href = CFG.supportUrl;
support.classList.remove('hidden');
}
if (tg) {
try {
tg.ready();
tg.expand();
const canSetColors = typeof tg.isVersionAtLeast === 'function' && tg.isVersionAtLeast('6.1');
if (canSetColors) {
tg.setHeaderColor('#05070a');
tg.setBackgroundColor('#05070a');
}
} catch (e) { }
}
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.paymentFlowOpen) {
closePaymentFlow();
}
});
boot();
async function boot() {
showLoader();
if (MOCK) {
await loadData();
return;
}
const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData) {
const authenticated = await finalizeTelegramAuth(widgetAuthData);
if (authenticated) {
return;
}
clearToken();
startExternalAuth({resetStatus: false});
return;
}
if (tg && tg.initData) {
try {
const authenticated = await finalizeTelegramAuth(tg.initData, 'init_data');
if (authenticated) {
return;
}
} catch (e) { }
}
if (state.token) {
try {
await loadData();
return;
} catch (e) {
clearToken();
}
}
await startExternalAuth();
}
function readTelegramLoginWidgetAuthData() {
const query = new URLSearchParams(window.location.search);
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
const authData = {};
let hasAuthValue = false;
keys.forEach(key => {
if (!query.has(key)) return;
authData[key] = query.get(key) || '';
hasAuthValue = true;
});
if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) {
return null;
}
return authData;
}
function clearTelegramLoginWidgetQuery() {
const url = new URL(window.location.href);
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
keys.forEach(key => url.searchParams.delete(key));
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
}
async function finalizeTelegramAuth(authData, source = 'auth_data') {
if (state.authInProgress) return false;
state.authInProgress = true;
setAuthStatus(t('telegram_auth_verifying'));
try {
const payload = source === 'init_data'
? {init_data: authData}
: {auth_data: authData};
const response = await fetch(CFG.apiBase + '/auth/token', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok && data.ok && data.token) {
setToken(data.token);
clearTelegramLoginWidgetQuery();
try {
setAuthStatus('');
await loadData();
return true;
} catch (e) {
clearToken();
setAuthStatus(t('telegram_auth_failed'), true);
return false;
}
}
const errorKey = data.error === 'banned' || data.error === 'access_denied'
? 'telegram_auth_access_denied'
: 'telegram_auth_failed';
setAuthStatus(t(errorKey), true);
} catch (e) {
setAuthStatus(t('telegram_auth_failed'), true);
} finally {
state.authInProgress = false;
}
clearTelegramLoginWidgetQuery();
return false;
}
async function handleTelegramLoginWidgetAuth(user) {
if (!user) {
setAuthStatus(t('telegram_auth_failed'), true);
return;
}
await finalizeTelegramAuth(user, 'auth_data');
}
function renderTelegramLoginWidget() {
const container = document.getElementById('telegram-login-widget');
if (!container) return;
container.innerHTML = '';
const botUsername = String(CFG.telegramLoginBotUsername || '').trim();
if (!botUsername) {
setAuthStatus(t('telegram_auth_unavailable'), true);
return;
}
if (typeof window.onTelegramAuth !== 'function') {
window.onTelegramAuth = async function(user) {
await handleTelegramLoginWidgetAuth(user);
};
}
const script = document.createElement('script');
script.async = true;
script.src = TELEGRAM_LOGIN_WIDGET_URL;
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-userpic', 'false');
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'onTelegramAuth(user)');
script.onerror = () => setAuthStatus(t('telegram_auth_unavailable'), true);
container.appendChild(script);
}
function startExternalAuth(options = {}) {
const resetStatus = options.resetStatus !== false;
showLogin();
if (resetStatus) {
setAuthStatus('');
}
renderTelegramLoginWidget();
}
async function loadData() {
const previousMonths = state.selectedPlan && state.selectedPlan.months;
const data = await api('/me');
if (!data.ok) throw new Error(data.error || 'load failed');
state.data = data;
setBrand();
applyI18n();
applyLegalLinks();
const plans = data.plans || [];
state.selectedPlan = plans.find(plan => plan.months === previousMonths) || plans[0] || null;
state.selectedMethod = null;
state.payment = null;
state.paymentStep = 'plan';
render();
showApp();
}
async function reloadData() {
showToast(t('updating'));
await loadData();
}
function render() {
renderSubscription(state.data.subscription);
renderPaymentFlow();
}
function renderSubscription(sub) {
const badge = document.getElementById('status-badge');
badge.textContent = sub.active ? t('active') : t('inactive');
badge.classList.toggle('off', !sub.active);
document.getElementById('remaining').textContent = sub.remaining_text || t('no_active_subscription');
document.getElementById('end-date').textContent = sub.end_date_text || t('not_available');
document.getElementById('traffic').textContent = (sub.traffic_used || t('not_available')) + ' / ' + (sub.traffic_limit || t('not_available'));
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
}
function togglePaymentFlow() {
if (state.paymentFlowOpen) {
closePaymentFlow();
return;
}
state.paymentFlowOpen = true;
state.paymentStep = 'plan';
state.payment = null;
state.selectedMethod = null;
renderPaymentFlow();
}
function closePaymentFlow() {
state.paymentFlowOpen = false;
state.paymentStep = 'plan';
state.payment = null;
state.selectedMethod = null;
renderPaymentFlow();
}
function renderPaymentFlow() {
const modal = document.getElementById('payment-modal');
if (!state.paymentFlowOpen) {
modal.classList.remove('show');
document.body.classList.remove('modal-open');
window.setTimeout(() => {
if (!state.paymentFlowOpen) modal.classList.add('hidden');
}, 180);
return;
}
modal.classList.remove('hidden');
document.body.classList.add('modal-open');
window.requestAnimationFrame(() => modal.classList.add('show'));
applyI18n(modal);
renderStepState();
renderPlans(state.data.plans || []);
renderMethods();
renderPaymentResult();
}
function renderStepState() {
const order = ['plan', 'method', 'result'];
order.forEach((step, index) => {
const node = document.getElementById('step-' + step);
const currentIndex = order.indexOf(state.paymentStep);
node.classList.toggle('active', state.paymentStep === step);
node.classList.toggle('done', currentIndex > index);
});
document.getElementById('plan-step').classList.toggle('hidden', state.paymentStep !== 'plan');
document.getElementById('method-step').classList.toggle('hidden', state.paymentStep !== 'method');
document.getElementById('result-step').classList.toggle('hidden', state.paymentStep !== 'result');
}
function renderPlans(plans) {
const wrap = document.getElementById('plans');
const nextBtn = document.getElementById('to-methods-btn');
if (!plans.length) {
wrap.innerHTML = '<div class="empty">' + escapeHtml(t('no_plans')) + '</div>';
nextBtn.disabled = true;
return;
}
nextBtn.disabled = !state.selectedPlan;
wrap.innerHTML = plans.map(plan => {
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : '';
return `
<button class="plan ${isActive ? 'active' : ''}" type="button" onclick="selectPlan(${plan.months})">
<span>
<span class="plan-name">${escapeHtml(plan.title)}</span>
<span class="plan-meta">${escapeHtml(t('access_period'))}</span>
</span>
<span class="plan-price">${escapeHtml(formatMoney(plan.price, plan.currency) + stars)}</span>
</button>
`;
}).join('');
}
function renderMethods() {
const methods = document.getElementById('methods');
const createBtn = document.getElementById('create-payment-btn');
if (!state.selectedPlan) {
methods.innerHTML = '<div class="empty">' + escapeHtml(t('choose_plan_first')) + '</div>';
createBtn.disabled = true;
return;
}
const available = getAvailableMethods();
if (state.selectedMethod && !available.some(method => method.id === state.selectedMethod)) {
state.selectedMethod = null;
}
createBtn.disabled = !state.selectedMethod || state.creatingPayment;
if (!available.length) {
methods.innerHTML = '<div class="empty">' + escapeHtml(t('no_methods')) + '</div>';
createBtn.disabled = true;
return;
}
methods.innerHTML = available.map(method => {
const isActive = state.selectedMethod === method.id;
const amount = method.id === 'stars'
? state.selectedPlan.stars_price + ' Stars'
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
return `
<button class="method ${isActive ? 'active' : ''}" type="button" onclick="selectMethod('${escapeAttr(method.id)}')">
<span>
<span class="method-name">${escapeHtml(method.name)}</span>
<span class="method-meta">${escapeHtml(state.selectedPlan.title)}</span>
</span>
<span class="plan-price">${escapeHtml(amount)}</span>
</button>
`;
}).join('');
}
function renderPaymentResult() {
const payment = state.payment;
const msg = document.getElementById('payment-message');
const openBtn = document.getElementById('payment-open-btn');
const checkBtn = document.getElementById('payment-check-btn');
if (!payment) {
msg.textContent = t('payment_not_created');
openBtn.classList.add('hidden');
checkBtn.classList.add('hidden');
return;
}
if (payment.action === 'invoice_sent') {
msg.textContent = t('invoice_sent');
openBtn.classList.add('hidden');
} else {
msg.textContent = t('payment_created');
openBtn.classList.toggle('hidden', !payment.payment_url);
}
checkBtn.classList.toggle('hidden', !payment.payment_id);
}
function selectPlan(months) {
state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months) || null;
state.selectedMethod = null;
state.payment = null;
renderPaymentFlow();
}
function selectMethod(method) {
state.selectedMethod = method;
state.payment = null;
renderMethods();
renderPaymentResult();
}
function goToPaymentStep(step) {
if (step === 'method' && !state.selectedPlan) {
showToast(t('choose_plan_toast'));
return;
}
if (step === 'result' && !state.payment) {
showToast(t('create_payment_toast'));
return;
}
state.paymentStep = step;
renderPaymentFlow();
}
async function createPaymentFromSelection() {
if (!state.selectedPlan) {
showToast(t('choose_plan_toast'));
return;
}
if (!state.selectedMethod) {
showToast(t('choose_method_toast'));
return;
}
state.creatingPayment = true;
renderMethods();
try {
const data = await api('/payments', {
method: 'POST',
body: JSON.stringify({months: state.selectedPlan.months, method: state.selectedMethod})
});
if (!data.ok) throw new Error(data.message || t('payment_error'));
state.payment = data;
state.paymentStep = 'result';
renderPaymentFlow();
} catch (e) {
showToast(e.message || t('payment_error'));
} finally {
state.creatingPayment = false;
renderPaymentFlow();
}
}
function getAvailableMethods() {
if (!state.data || !state.selectedPlan) return [];
return (state.data.payment_methods || []).filter(method => {
if (method.id === 'stars') return Number(state.selectedPlan.stars_price || 0) > 0;
return true;
});
}
function openPaymentUrl() {
const payment = state.payment;
if (!payment || !payment.payment_url) return;
if (payment.action === 'open_invoice' && tg && tg.openInvoice) {
tg.openInvoice(payment.payment_url, function(status) {
if (status === 'paid') loadData();
});
return;
}
if (tg && tg.openLink) {
tg.openLink(payment.payment_url);
} else {
window.open(payment.payment_url, '_blank', 'noopener');
}
}
async function checkPayment() {
if (!state.payment || !state.payment.payment_id) return;
const data = await api('/payments/' + state.payment.payment_id);
if (data.paid) {
showToast(t('payment_confirmed'));
state.paymentFlowOpen = false;
await loadData();
} else {
showToast(t('payment_pending'));
}
}
function openConnectLink() {
const sub = state.data && state.data.subscription;
const url = sub && (sub.connect_url || sub.config_link);
if (!url) {
showToast(t('no_link'));
return;
}
if (tg && tg.openLink) tg.openLink(url);
else window.open(url, '_blank', 'noopener');
}
async function copyConfigLink() {
const sub = state.data && state.data.subscription;
const link = sub && sub.config_link;
if (!link) {
showToast(t('no_link'));
return;
}
try {
await navigator.clipboard.writeText(link);
showToast(t('link_copied'));
} catch (e) {
const area = document.createElement('textarea');
area.value = link;
document.body.appendChild(area);
area.select();
document.execCommand('copy');
area.remove();
showToast(t('link_copied'));
}
}
async function api(path, options = {}) {
if (MOCK) {
return mockApi(path, options);
}
const headers = Object.assign({'Authorization': 'Bearer ' + state.token}, options.headers || {});
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers}));
if (response.status === 401) {
clearToken();
await startExternalAuth();
throw new Error('Unauthorized');
}
return response.json();
}
async function mockApi(path, options = {}) {
await new Promise(resolve => window.setTimeout(resolve, 120));
if (path === '/me') {
return JSON.parse(JSON.stringify(MOCK.data));
}
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
return {
ok: true,
action: 'open_link',
payment_url: 'https://example.com/payment-preview',
payment_id: 10001
};
}
if (path.startsWith('/payments/')) {
return {
ok: true,
payment_id: 10001,
status: 'pending',
paid: false
};
}
return {ok: false, error: 'not_found'};
}
function setToken(token) {
state.token = token;
localStorage.setItem('rw_webapp_token', token);
}
function clearToken() {
state.token = '';
localStorage.removeItem('rw_webapp_token');
}
function logout() {
clearToken();
closePaymentFlow();
startExternalAuth();
}
function showLoader() {
document.getElementById('loader').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
document.getElementById('login').classList.remove('show');
}
function showApp() {
document.getElementById('loader').classList.add('hidden');
document.getElementById('login').classList.remove('show');
document.getElementById('app').classList.remove('hidden');
}
function showLogin() {
document.getElementById('loader').classList.add('hidden');
document.getElementById('app').classList.add('hidden');
document.getElementById('login').classList.add('show');
}
function setAuthStatus(message, isError = false) {
const status = document.getElementById('auth-status');
if (!status) return;
if (!message) {
status.textContent = '';
status.classList.add('hidden');
status.classList.remove('error');
return;
}
status.textContent = message;
status.classList.remove('hidden');
status.classList.toggle('error', Boolean(isError));
}
function showToast(message) {
if (tg && tg.showAlert) {
tg.showAlert(message);
return;
}
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.remove('hidden');
window.requestAnimationFrame(() => toast.classList.add('show'));
if (state.toastTimer) window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
toast.classList.remove('show');
window.setTimeout(() => toast.classList.add('hidden'), 180);
}, 1800);
}
function setBrand() {
const title = CFG.title || t('page_title');
const logoUrl = CFG.logoUrl || '';
document.title = title;
document.querySelectorAll('[data-brand-title]').forEach(node => {
node.textContent = title;
});
document.querySelectorAll('[data-brand-logo]').forEach(logo => {
if (!logoUrl) {
logo.removeAttribute('src');
logo.classList.add('hidden');
return;
}
logo.onload = () => {
logo.classList.remove('hidden');
};
logo.onerror = () => {
logo.removeAttribute('src');
logo.classList.add('hidden');
};
logo.src = logoUrl;
if (logo.complete && logo.naturalWidth > 0) {
logo.classList.remove('hidden');
} else {
logo.classList.add('hidden');
}
});
}
function applyI18n(root = document) {
document.documentElement.lang = getLanguage();
root.querySelectorAll('[data-i18n]').forEach(node => {
node.textContent = t(node.dataset.i18n);
});
root.querySelectorAll('[data-title-i18n]').forEach(node => {
const value = t(node.dataset.titleI18n);
node.setAttribute('title', value);
node.setAttribute('aria-label', value);
});
root.querySelectorAll('[data-aria-i18n]').forEach(node => {
node.setAttribute('aria-label', t(node.dataset.ariaI18n));
});
}
function applyLegalLinks(root = document) {
const urls = {
privacyPolicyUrl: CFG.privacyPolicyUrl || '',
userAgreementUrl: CFG.userAgreementUrl || ''
};
root.querySelectorAll('[data-legal-key]').forEach(node => {
const key = node.dataset.legalKey;
const url = urls[key] || '';
node.href = url || '#';
node.classList.toggle('hidden', !url);
node.setAttribute('aria-hidden', url ? 'false' : 'true');
});
root.querySelectorAll('.legal-links').forEach(container => {
const visible = Array.from(container.querySelectorAll('[data-legal-key]'))
.some(node => !node.classList.contains('hidden'));
container.classList.toggle('hidden', !visible);
});
}
function getLanguage() {
const raw = (
state.data && state.data.user && state.data.user.language_code
? state.data.user.language_code
: (CFG.language || document.documentElement.lang || 'ru')
).toLowerCase();
const short = raw.split('-')[0];
return I18N[short] ? short : 'ru';
}
function t(key, params = {}) {
const table = I18N[getLanguage()] || I18N.ru;
const fallback = I18N.ru[key] || key;
const template = table[key] || fallback;
return template.replace(/\{(\w+)\}/g, (_, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : ''
));
}
function formatMoney(value, currency) {
const numeric = Number(value || 0);
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
return formatted + ' ' + (currency || CFG.currency || 'RUB');
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll('`', '&#096;');
}
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
(function(window) {
(function(window){
window.__parseFunction = function(__func, __attrs) {
__attrs = __attrs || [];
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
return window.execScript ? window.execScript(__func) : eval(__func);
}
}(window));
(function(window){
function addEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.addEventListener) {
el.addEventListener(events[i], handler);
} else {
el.attachEvent('on' + events[i], handler);
}
}
}
function removeEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.removeEventListener) {
el.removeEventListener(events[i], handler);
} else {
el.detachEvent('on' + events[i], handler);
}
}
}
function getCssProperty(el, prop) {
if (window.getComputedStyle) {
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
} else if (el.currentStyle) {
return el.currentStyle[prop] || null;
}
return null;
}
function geById(el_or_id) {
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
return document.getElementById(el_or_id);
} else if (el_or_id instanceof HTMLElement) {
return el_or_id;
}
return null;
}
var getWidgetsOrigin = function(default_origin, dev_origin) {
var link = document.createElement('A'), origin;
link.href = document.currentScript && document.currentScript.src || default_origin;
origin = link.origin || link.protocol + '//' + link.hostname;
if (origin == 'https://telegram.org') {
origin = default_origin;
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
origin = dev_origin;
} else {
origin = default_origin;
}
return origin;
};
var getPageCanonical = function() {
var a = document.createElement('A'), link, href;
if (document.querySelector) {
link = document.querySelector('link[rel="canonical"]');
if (link && (href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
} else {
var links = document.getElementsByTagName('LINK');
for (var i = 0; i < links.length; i++) {
if ((link = links[i]) &&
(link.getAttribute('rel') == 'canonical') &&
(href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
}
}
return false;
};
function haveTgAuthResult() {
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
try {
locationHash = location.hash.toString();
if (match = locationHash.match(re)) {
location.hash = locationHash.replace(re, '');
var data = match[1] || '';
data = data.replace(/-/g, '+').replace(/_/g, '/');
var pad = data.length % 4;
if (pad > 1) {
data += new Array(5 - pad).join('=');
}
return JSON.parse(window.atob(data));
}
} catch (e) {}
return false;
}
function getXHR() {
if (navigator.appName == "Microsoft Internet Explorer"){
return new ActiveXObject("Microsoft.XMLHTTP");
} else {
return new XMLHttpRequest();
}
}
if (!window.Telegram) {
window.Telegram = {};
}
if (!window.Telegram.__WidgetUuid) {
window.Telegram.__WidgetUuid = 0;
}
if (!window.Telegram.__WidgetLastId) {
window.Telegram.__WidgetLastId = 0;
}
if (!window.Telegram.__WidgetCallbacks) {
window.Telegram.__WidgetCallbacks = {};
}
function postMessageToIframe(iframe, event, data, callback) {
if (!iframe._ready) {
if (!iframe._readyQueue) iframe._readyQueue = [];
iframe._readyQueue.push([event, data, callback]);
return;
}
try {
data = data || {};
data.event = event;
if (callback) {
data._cb = ++window.Telegram.__WidgetLastId;
window.Telegram.__WidgetCallbacks[data._cb] = {
iframe: iframe,
callback: callback
};
}
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
} catch(e) {}
}
function initWidget(widgetEl) {
var widgetId, widgetElId, widgetsOrigin, existsEl,
src, styles = {}, allowedAttrs = [],
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
if (!widgetEl.tagName ||
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
widgetEl.classList.contains('telegram-post'))) {
return null;
}
if (widgetEl._iframe) {
return widgetEl._iframe;
}
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
var comment = widgetEl.getAttribute('data-comment') || '';
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
src = widgetsOrigin + '/' + widgetId + '?embed=1';
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = '';
styles.minWidth = '320px';
}
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
var websitePageUrl = widgetEl.getAttribute('data-page-url');
if (!websitePageUrl) {
websitePageUrl = getPageCanonical();
}
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = widgetEl.getAttribute('data-height') || 0;
styles.minWidth = '320px';
if (defHeight > 0) {
scrollable = true;
}
}
else if (widgetEl.hasAttribute('data-telegram-login')) {
widgetId = widgetEl.getAttribute('data-telegram-login');
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
defWidth = 186;
defHeight = 28;
if (widgetEl.hasAttribute('data-size')) {
var size = widgetEl.getAttribute('data-size');
if (size == 'small') defWidth = 148, defHeight = 20;
else if (size == 'large') defWidth = 238, defHeight = 40;
}
if (widgetEl.hasAttribute('data-onauth')) {
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
}
else if (widgetEl.hasAttribute('data-auth-url')) {
var a = document.createElement('A');
a.href = widgetEl.getAttribute('data-auth-url');
onAuthUser = function(user) {
var authUrl = a.href;
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
var params = [];
for (var key in user) {
params.push(key + '=' + encodeURIComponent(user[key]));
}
authUrl += params.join('&');
location.href = authUrl;
};
}
if (widgetEl.hasAttribute('data-onunauth')) {
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
}
var auth_result = haveTgAuthResult();
if (auth_result && onAuthUser) {
onAuthUser(auth_result);
}
}
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-share-' + window.btoa(widgetId);
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
defWidth = 60;
defHeight = 20;
if (widgetEl.getAttribute('data-size') == 'large') {
defWidth = 76;
defHeight = 28;
}
}
else {
return null;
}
existsEl = document.getElementById(widgetElId);
if (existsEl) {
return existsEl;
}
for (var i = 0; i < allowedAttrs.length; i++) {
var attr = allowedAttrs[i];
var novalue = attr.substr(-1) == '?';
if (novalue) {
attr = attr.slice(0, -1);
}
var data_attr = 'data-' + attr.replace(/_/g, '-');
if (widgetEl.hasAttribute(data_attr)) {
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
src += '&' + attr + '=' + attr_value;
}
}
function getCurCoords(iframe) {
var docEl = document.documentElement;
var frect = iframe.getBoundingClientRect();
return {
frameTop: frect.top,
frameBottom: frect.bottom,
frameLeft: frect.left,
frameRight: frect.right,
frameWidth: frect.width,
frameHeight: frect.height,
scrollTop: window.pageYOffset,
scrollLeft: window.pageXOffset,
clientWidth: docEl.clientWidth,
clientHeight: docEl.clientHeight
};
}
function visibilityHandler() {
if (isVisible(iframe, 50)) {
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
}
}
function focusHandler() {
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
}
function postMessageHandler(event) {
if (event.source !== iframe.contentWindow ||
event.origin != widgetsOrigin) {
return;
}
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (data.event == 'resize') {
if (data.height) {
iframe.style.height = data.height + 'px';
}
if (data.width) {
iframe.style.width = data.width + 'px';
}
}
else if (data.event == 'ready') {
iframe._ready = true;
focusHandler();
for (var i = 0; i < iframe._readyQueue.length; i++) {
var queue_item = iframe._readyQueue[i];
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
}
iframe._readyQueue = [];
}
else if (data.event == 'visible_off') {
removeEvent(window, 'scroll', visibilityHandler);
removeEvent(window, 'resize', visibilityHandler);
}
else if (data.event == 'get_coords') {
postMessageToIframe(iframe, 'callback', {
_cb: data._cb,
value: getCurCoords(iframe)
});
}
else if (data.event == 'scroll_to') {
try {
window.scrollTo(data.x || 0, data.y || 0);
} catch(e) {}
}
else if (data.event == 'auth_user') {
if (data.init) {
onInitAuthUser && onInitAuthUser(data.auth_data);
} else {
onAuthUser && onAuthUser(data.auth_data);
}
}
else if (data.event == 'unauthorized') {
onUnauth && onUnauth();
}
else if (data.event == 'callback') {
var cb_data = null;
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
if (cb_data.iframe === iframe) {
cb_data.callback(data.value);
delete window.Telegram.__WidgetCallbacks[data._cb];
}
} else {
console.warn('Callback #' + data._cb + ' not found');
}
}
}
var iframe = document.createElement('iframe');
iframe.id = widgetElId;
iframe.src = src;
iframe.width = defWidth;
iframe.height = defHeight;
iframe.setAttribute('frameborder', '0');
if (!scrollable) {
iframe.setAttribute('scrolling', 'no');
iframe.style.overflow = 'hidden';
}
iframe.style.colorScheme = 'light dark';
iframe.style.border = 'none';
for (var prop in styles) {
iframe.style[prop] = styles[prop];
}
if (widgetEl.parentNode) {
widgetEl.parentNode.insertBefore(iframe, widgetEl);
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
widgetEl.parentNode.removeChild(widgetEl);
}
}
iframe._ready = false;
iframe._readyQueue = [];
widgetEl._iframe = iframe;
addEvent(iframe, 'load', function() {
removeEvent(iframe, 'load', visibilityHandler);
addEvent(window, 'scroll', visibilityHandler);
addEvent(window, 'resize', visibilityHandler);
visibilityHandler();
});
addEvent(window, 'focus blur', focusHandler);
addEvent(window, 'message', postMessageHandler);
return iframe;
}
function isVisible(el, padding) {
var node = el, val;
var visibility = getCssProperty(node, 'visibility');
if (visibility == 'hidden') return false;
while (node) {
if (node === document.documentElement) break;
var display = getCssProperty(node, 'display');
if (display == 'none') return false;
var opacity = getCssProperty(node, 'opacity');
if (opacity !== null && opacity < 0.1) return false;
node = node.parentNode;
}
if (el.getBoundingClientRect) {
padding = +padding || 0;
var rect = el.getBoundingClientRect();
var html = document.documentElement;
if (rect.bottom < padding ||
rect.right < padding ||
rect.top > (window.innerHeight || html.clientHeight) - padding ||
rect.left > (window.innerWidth || html.clientWidth) - padding) {
return false;
}
}
return true;
}
function getAllWidgets() {
var widgets = [];
if (document.querySelectorAll) {
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
} else {
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
}
return widgets;
}
function getWidgetInfo(el_or_id, callback) {
var e = null, iframe = null;
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe && callback) {
postMessageToIframe(iframe, 'get_info', {}, callback);
}
}
}
function setWidgetOptions(options, el_or_id) {
var e = null, iframe = null;
if (typeof el_or_id === 'undefined') {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
if (iframe = widgets[i]._iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
} else {
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
}
}
if (!document.currentScript ||
!initWidget(document.currentScript)) {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
initWidget(widgets[i]);
}
}
var TelegramLogin = {
popups: {},
options: null,
auth_callback: null,
_init: function(options, auth_callback) {
TelegramLogin.options = options;
TelegramLogin.auth_callback = auth_callback;
var auth_result = haveTgAuthResult();
if (auth_result && auth_callback) {
auth_callback(auth_result);
}
},
_open: function(callback) {
TelegramLogin._auth(TelegramLogin.options, function(authData) {
if (TelegramLogin.auth_callback) {
TelegramLogin.auth_callback(authData);
}
if (callback) {
callback(authData);
}
});
},
_auth: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var width = 550;
var height = 470;
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
var onMessage = function (event) {
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (!TelegramLogin.popups[bot_id]) return;
if (event.source !== TelegramLogin.popups[bot_id].window) return;
if (data.event == 'auth_result') {
onAuthDone(data.result);
}
};
var onAuthDone = function (authData) {
if (!TelegramLogin.popups[bot_id]) return;
if (TelegramLogin.popups[bot_id].authFinished) return;
callback && callback(authData);
TelegramLogin.popups[bot_id].authFinished = true;
removeEvent(window, 'message', onMessage);
};
var checkClose = function(bot_id) {
if (!TelegramLogin.popups[bot_id]) return;
if (!TelegramLogin.popups[bot_id].window ||
TelegramLogin.popups[bot_id].window.closed) {
return TelegramLogin.getAuthData(options, function(origin, authData) {
onAuthDone(authData);
});
}
setTimeout(checkClose, 100, bot_id);
}
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
TelegramLogin.popups[bot_id] = {
window: popup,
authFinished: false
};
if (popup) {
addEvent(window, 'message', onMessage);
popup.focus();
checkClose(bot_id);
}
},
getAuthData: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var xhr = getXHR();
var url = Telegram.Login.widgetsOrigin + '/auth/get';
xhr.open('POST', url);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
try {
var result = JSON.parse(xhr.responseText);
} catch(e) {
var result = {};
}
if (result.user) {
callback(result.origin, result.user);
} else {
callback(result.origin, false);
}
} else {
callback('*', false);
}
}
};
xhr.onerror = function() {
callback('*', false);
};
xhr.withCredentials = true;
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
}
};
window.Telegram.getWidgetInfo = getWidgetInfo;
window.Telegram.setWidgetOptions = setWidgetOptions;
window.Telegram.Login = {
init: TelegramLogin._init,
open: TelegramLogin._open,
auth: TelegramLogin._auth,
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
};
}(window));
})(window);
+26 -1
View File
@@ -1,5 +1,7 @@
import asyncio
import logging
from contextlib import suppress
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
@@ -7,6 +9,8 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
def _inject_shared_instances(
app: web.Application,
@@ -111,8 +115,13 @@ async def build_and_start_web_app(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
telegram_web_app_sdk_refresh_task = None
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import create_subscription_webapp_application
from bot.app.web.subscription_webapp import (
create_subscription_webapp_application,
refresh_telegram_login_widget_sdk,
refresh_telegram_web_app_sdk,
)
subscription_app = create_subscription_webapp_application(
dp,
@@ -135,9 +144,25 @@ async def build_and_start_web_app(
settings.WEBAPP_SERVER_PORT,
)
async def _refresh_telegram_web_assets_forever() -> None:
while True:
await refresh_telegram_web_app_sdk()
await refresh_telegram_login_widget_sdk()
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
telegram_web_app_sdk_refresh_task = asyncio.create_task(
_refresh_telegram_web_assets_forever(),
name="TelegramWebAssetsRefreshTask",
)
try:
await asyncio.Event().wait()
finally:
if telegram_web_app_sdk_refresh_task is not None:
telegram_web_app_sdk_refresh_task.cancel()
with suppress(asyncio.CancelledError):
await telegram_web_app_sdk_refresh_task
for runner in reversed(runners):
try:
await runner.cleanup()
+51 -41
View File
@@ -3,9 +3,7 @@ import hashlib
import hmac
import json
import logging
import secrets
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl
@@ -14,15 +12,6 @@ from config.settings import Settings
logger = logging.getLogger(__name__)
@dataclass
class PendingWebAppAuth:
created_at: int
user_id: Optional[int] = None
_PENDING_AUTH_TOKENS: Dict[str, PendingWebAppAuth] = {}
def _urlsafe_b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
@@ -134,40 +123,61 @@ def validate_telegram_webapp_init_data(
return None
def _cleanup_pending_auth(settings: Settings) -> None:
now = int(time.time())
ttl = max(60, int(settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS))
expired = [
token
for token, value in _PENDING_AUTH_TOKENS.items()
if now - value.created_at > ttl
]
for token in expired:
_PENDING_AUTH_TOKENS.pop(token, None)
def validate_telegram_login_widget_data(
auth_data: Any,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Login Widget data and return the trusted user payload."""
try:
if isinstance(auth_data, str):
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
elif isinstance(auth_data, dict):
parsed_data = {
str(key): str(value)
for key, value in auth_data.items()
if value is not None
}
else:
return None
def create_pending_webapp_auth_token(settings: Settings) -> str:
_cleanup_pending_auth(settings)
token = secrets.token_urlsafe(24)
_PENDING_AUTH_TOKENS[token] = PendingWebAppAuth(created_at=int(time.time()))
return token
received_hash = str(parsed_data.pop("hash", "") or "")
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
calculated_hash = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("Telegram Login Widget hash mismatch.")
return None
def authorize_pending_webapp_auth_token(token: str, user_id: int) -> bool:
pending = _PENDING_AUTH_TOKENS.get(token)
if not pending:
return False
pending.user_id = int(user_id)
return True
auth_date_raw = parsed_data.get("auth_date")
if auth_date_raw:
auth_date = int(auth_date_raw)
now = int(time.time())
max_age = max(60, int(max_age_seconds))
if auth_date > now + 300 or now - auth_date > max_age:
logger.warning("Telegram Login Widget auth_date is stale.")
return None
user_id_raw = parsed_data.get("id")
if not user_id_raw:
return None
int(user_id_raw)
def consume_authorized_webapp_auth_token(
settings: Settings,
token: str,
) -> Optional[int]:
_cleanup_pending_auth(settings)
pending = _PENDING_AUTH_TOKENS.get(token)
if not pending or pending.user_id is None:
if not parsed_data.get("first_name"):
return None
return parsed_data
except Exception as exc:
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
return None
_PENDING_AUTH_TOKENS.pop(token, None)
return int(pending.user_id)