diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 39c7589..20882e9 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -1,8 +1,11 @@ import asyncio +import hashlib +import hmac import ipaddress import json import logging import re +import secrets import socket import time from collections import deque @@ -14,6 +17,7 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from aiohttp import ClientSession, ClientTimeout, web from aiogram import Bot, Dispatcher from aiogram.types import LabeledPrice +from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker @@ -58,6 +62,44 @@ DEV_MOCK_END_MARKER = "" WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session" +WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf" +WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" +WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +WEBAPP_CSRF_EXEMPT_PATHS = { + "/api/auth/token", + "/api/auth/email/request", + "/api/auth/email/verify", + "/api/auth/logout", +} + + +class WebAppEmailPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + email: EmailStr + + @field_validator("email") + @classmethod + def _normalize_and_limit_email(cls, value: EmailStr) -> str: + normalized = normalize_email(str(value)) + if len(normalized) > 254: + raise ValueError("email_too_long") + return normalized + + +class WebAppEmailCodePayload(WebAppEmailPayload): + code: str = "" + + +class WebAppPaymentCreatePayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + method: str = "" + months: Any = None + description: Optional[constr(max_length=4096)] = None + comment: Optional[constr(max_length=4096)] = None + note: Optional[constr(max_length=4096)] = None _SHARED_HTTP_SESSION: Optional[ClientSession] = None _SHARED_HTTP_SESSION_LOCK = asyncio.Lock() @@ -69,7 +111,7 @@ def create_subscription_webapp_application( settings: Settings, async_session_factory: sessionmaker, ) -> web.Application: - app = web.Application(middlewares=[_security_headers_middleware]) + app = web.Application(middlewares=[_security_headers_middleware, _csrf_protection_middleware]) app["bot"] = bot app["dp"] = dp app["settings"] = settings @@ -123,6 +165,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/auth/token", auth_token_route) app.router.add_post("/api/auth/email/request", email_auth_request_route) app.router.add_post("/api/auth/email/verify", email_auth_verify_route) + app.router.add_post("/api/auth/logout", logout_route) app.router.add_get("/api/me", me_route) app.router.add_post("/api/account/email/request", account_email_request_route) app.router.add_post("/api/account/email/verify", account_email_verify_route) @@ -326,6 +369,32 @@ async def _security_headers_middleware(request: web.Request, handler): return response +@web.middleware +async def _csrf_protection_middleware(request: web.Request, handler): + settings: Settings = request.app["settings"] + header = request.headers.get("Authorization", "") + prefix = "Bearer " + if header.startswith(prefix): + if verify_webapp_session_token(settings, header[len(prefix):].strip()): + return await handler(request) + + if ( + request.method in WEBAPP_STATE_CHANGING_METHODS + and request.path not in WEBAPP_CSRF_EXEMPT_PATHS + and request.cookies.get(WEBAPP_SESSION_COOKIE_NAME) + ): + csrf_cookie = request.cookies.get(WEBAPP_CSRF_COOKIE_NAME, "") + csrf_header = request.headers.get(WEBAPP_CSRF_HEADER_NAME, "") + if ( + not csrf_cookie + or not csrf_header + or not hmac.compare_digest(csrf_header, csrf_cookie) + ): + return _json_error(403, "csrf_failed", "Invalid CSRF token") + + return await handler(request) + + def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: settings: Settings = request.app["settings"] cache = request.app["webapp_settings_cache"] @@ -700,13 +769,22 @@ async def auth_token_route(request: web.Request) -> web.Response: return _json_error(500, "auth_failed", "Auth failed") token = create_webapp_session_token(settings, int(authenticated_user_id)) - return web.json_response({"ok": True, "token": token}) + return _build_webapp_auth_response(settings, {"ok": True}, token=token) + + +async def logout_route(request: web.Request) -> web.Response: + response = web.json_response({"ok": True}) + _clear_webapp_auth_cookies(response) + return response async def email_auth_request_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) - email = normalize_email(str(payload.get("email") or "")) + email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload) + if validation_error: + return validation_error + email = email_payload.email lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE)) return await _request_email_code( request, @@ -720,8 +798,11 @@ async def email_auth_request_route(request: web.Request) -> web.Response: async def email_auth_verify_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) - email = normalize_email(str(payload.get("email") or "")) - code = str(payload.get("code") or "") + email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload) + if validation_error: + return validation_error + email = email_payload.email + code = str(email_payload.code or "") referral_param = str(payload.get("referral_code") or payload.get("start_param") or "") email_service: EmailAuthService = request.app["email_auth_service"] async_session_factory: sessionmaker = request.app["async_session_factory"] @@ -792,13 +873,14 @@ async def email_auth_verify_route(request: web.Request) -> web.Response: return _json_error(500, "auth_failed", "Auth failed") token = create_webapp_session_token(settings, int(db_user.user_id)) - return web.json_response( + return _build_webapp_auth_response( + settings, { "ok": True, - "token": token, "user_id": int(db_user.user_id), "telegram_id": _telegram_id_for_user(db_user), - } + }, + token=token, ) @@ -806,7 +888,10 @@ async def account_email_request_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] payload = await _read_json(request) - email = normalize_email(str(payload.get("email") or "")) + email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload) + if validation_error: + return validation_error + email = email_payload.email async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: @@ -837,8 +922,11 @@ async def account_email_verify_route(request: web.Request) -> web.Response: return rate_limit_response payload = await _read_json(request) - email = normalize_email(str(payload.get("email") or "")) - code = str(payload.get("code") or "") + email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload) + if validation_error: + return validation_error + 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"] @@ -952,11 +1040,11 @@ async def account_email_verify_route(request: web.Request) -> web.Response: return _json_error(500, "link_failed", "Link failed") token = create_webapp_session_token(settings, int(final_user_id)) - response_payload: Dict[str, Any] = {"ok": True, "token": token} + response_payload: Dict[str, Any] = {"ok": True} if merge_notice: response_payload["account_merge"] = merge_notice response_payload["user_id"] = final_user_id - return web.json_response(response_payload) + return _build_webapp_auth_response(settings, response_payload, token=token) async def account_telegram_link_route(request: web.Request) -> web.Response: @@ -1078,15 +1166,12 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: token = create_webapp_session_token(settings, int(final_user_id)) response_payload: Dict[str, Any] = { "ok": True, - "token": token, "user_id": int(final_user_id), "telegram_id": final_telegram_id, } if merge_notice: response_payload["account_merge"] = merge_notice - return web.json_response( - response_payload - ) + return _build_webapp_auth_response(settings, response_payload, token=token) async def me_route(request: web.Request) -> web.Response: @@ -1150,9 +1235,12 @@ async def create_payment_route(request: web.Request) -> web.Response: return rate_limit_response payload = await _read_json(request) - method = str(payload.get("method") or "").strip().lower() + payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload) + if validation_error: + return validation_error + method = str(payment_payload.method or "").strip().lower() try: - months = int(float(payload.get("months"))) + months = int(float(payment_payload.months)) except (TypeError, ValueError): return _json_error(400, "invalid_plan", "Invalid subscription period") @@ -1220,12 +1308,120 @@ def _json_error(status: int, code: str, message: str) -> web.Response: ) -def _require_user_id(request: web.Request) -> int: +def _validation_error_response(exc: ValidationError) -> web.Response: + for error in exc.errors(): + loc = error.get("loc") or () + field = str(loc[0]) if loc else "" + error_type = str(error.get("type") or "") + message = str(error.get("msg") or "") + message_lower = message.lower() + + if field == "email": + if ("too_long" in message_lower or "too long" in message_lower or error_type == "string_too_long"): + return _json_error(400, "email_too_long", "Email is too long") + return _json_error(400, "invalid_email", "Invalid email") + + if field in {"description", "comment", "note"} and error_type == "string_too_long": + return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long") + + if error_type == "string_too_long": + return _json_error(400, "text_too_long", "Text is too long") + + return _json_error(400, "invalid_request", "Invalid request") + + +def _validate_model_payload( + model_cls: type[BaseModel], + payload: Dict[str, Any], +) -> tuple[Optional[BaseModel], Optional[web.Response]]: + try: + return model_cls.model_validate(payload), None + except ValidationError as exc: + return None, _validation_error_response(exc) + + +def _set_webapp_auth_cookies( + response: web.StreamResponse, + settings: Settings, + session_token: str, + csrf_token: str, +) -> None: + max_age = max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)) + response.set_cookie( + WEBAPP_SESSION_COOKIE_NAME, + session_token, + httponly=True, + secure=True, + samesite="None", + path="/", + max_age=max_age, + ) + response.set_cookie( + WEBAPP_CSRF_COOKIE_NAME, + csrf_token, + httponly=False, + secure=True, + samesite="None", + path="/", + max_age=max_age, + ) + + +def _clear_webapp_auth_cookies(response: web.StreamResponse) -> None: + response.set_cookie( + WEBAPP_SESSION_COOKIE_NAME, + "", + httponly=True, + secure=True, + samesite="None", + path="/", + max_age=0, + ) + response.set_cookie( + WEBAPP_CSRF_COOKIE_NAME, + "", + httponly=False, + secure=True, + samesite="None", + path="/", + max_age=0, + ) + + +def _build_webapp_auth_response( + settings: Settings, + payload: Dict[str, Any], + *, + token: str, + csrf_token: Optional[str] = None, +) -> web.Response: + response_payload = dict(payload) + response_payload["ok"] = True + response_payload["token"] = token + csrf_value = csrf_token or secrets.token_hex(32) + response_payload["csrf_token"] = csrf_value + response = web.json_response(response_payload) + _set_webapp_auth_cookies(response, settings, token, csrf_value) + return response + + +def _extract_authenticated_user_id(request: web.Request) -> Optional[int]: settings: Settings = request.app["settings"] header = request.headers.get("Authorization", "") prefix = "Bearer " - token = header[len(prefix):].strip() if header.startswith(prefix) else "" - user_id = verify_webapp_session_token(settings, token) + if header.startswith(prefix): + user_id = verify_webapp_session_token(settings, header[len(prefix):].strip()) + if user_id: + return user_id + + cookie_token = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME, "") + if cookie_token: + return verify_webapp_session_token(settings, cookie_token) + return None + + +def _require_user_id(request: web.Request) -> int: + user_id = _extract_authenticated_user_id(request) if not user_id: raise web.HTTPUnauthorized( text=json.dumps({"ok": False, "error": "unauthorized"}), diff --git a/bot/app/web/templates/subscription_webapp.js b/bot/app/web/templates/subscription_webapp.js index 7482cd9..a26619a 100644 --- a/bot/app/web/templates/subscription_webapp.js +++ b/bot/app/web/templates/subscription_webapp.js @@ -93,6 +93,7 @@ const MOCK = (() => { const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js'; const state = { token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''), + csrfToken: MOCK ? '' : (readCookie('rw_webapp_csrf') || ''), data: null, selectedPlan: null, referralParam: readReferralParam(), @@ -514,7 +515,7 @@ const MOCK = (() => { } catch (e) { } } - if (state.token) { + if (state.token || readCookie('rw_webapp_csrf')) { try { await loadData(); return; @@ -584,7 +585,7 @@ const MOCK = (() => { }); const data = await response.json(); if (response.ok && data.ok && data.token) { - setToken(data.token); + setToken(data.token, data.csrf_token); clearTelegramLoginWidgetQuery(); try { setAuthStatus(''); @@ -905,7 +906,7 @@ const MOCK = (() => { referral_code: state.referralParam || '' }); if (!data.ok || !data.token) throw data; - setToken(data.token); + setToken(data.token, data.csrf_token); closeEmailLoginCodeModal(); await loadData(); } catch (e) { @@ -1746,7 +1747,7 @@ const MOCK = (() => { body: JSON.stringify({email, code}) }); if (!data.ok) throw data; - if (data.token) setToken(data.token); + if (data.token) setToken(data.token, data.csrf_token); mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null; state.accountMergeNotice = mergeNotice; showToast(mergeNotice ? t('account_merge_toast') : t('email_linked')); @@ -1824,7 +1825,7 @@ const MOCK = (() => { } async function finishTelegramLink(data, fallbackTelegramId) { - if (data.token) setToken(data.token); + if (data.token) setToken(data.token, data.csrf_token); markTelegramLinked(data.telegram_id || fallbackTelegramId); setTelegramLinkStatus(''); state.telegramLinkRendered = false; @@ -2106,7 +2107,15 @@ const MOCK = (() => { return mockApi(path, options); } - const headers = Object.assign({'Authorization': 'Bearer ' + state.token}, options.headers || {}); + const method = String(options.method || 'GET').toUpperCase(); + const headers = Object.assign({}, options.headers || {}); + if (state.token) { + headers.Authorization = 'Bearer ' + state.token; + } + const csrfToken = state.csrfToken || readCookie('rw_webapp_csrf') || ''; + if (csrfToken && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + headers['X-CSRF-Token'] = csrfToken; + } 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) { @@ -2141,17 +2150,20 @@ const MOCK = (() => { return {ok: true}; } if (path === '/auth/email/verify') { - return {ok: true, token: 'local-preview'}; + return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; } if (path === '/account/email/verify') { MOCK.data.user.email = 'preview@example.com'; MOCK.data.user.email_verified = true; - return {ok: true, token: 'local-preview'}; + return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; } if (path === '/account/telegram/link') { MOCK.data.user.telegram_linked = true; MOCK.data.user.telegram_id = 100200300; - return {ok: true, token: 'local-preview'}; + return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; + } + if (path === '/auth/logout') { + return {ok: true}; } if (path === '/promo/apply') { return {ok: true, end_date_text: '20.05.2026 12:00'}; @@ -2175,16 +2187,23 @@ const MOCK = (() => { return {ok: false, error: 'not_found'}; } - function setToken(token) { + function setToken(token, csrfToken = '') { state.token = token; - localStorage.setItem('rw_webapp_token', token); + state.csrfToken = csrfToken || readCookie('rw_webapp_csrf') || state.csrfToken || ''; } function clearToken() { state.token = ''; + state.csrfToken = ''; localStorage.removeItem('rw_webapp_token'); } + function readCookie(name) { + const prefix = name + '='; + const cookie = document.cookie.split('; ').find(part => part.startsWith(prefix)); + return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : ''; + } + function normalizeEmail(value) { return String(value || '').trim().toLowerCase(); } @@ -2210,6 +2229,7 @@ const MOCK = (() => { function logout() { toggleUserMenu(false); + void publicApi('/auth/logout', {}).catch(() => {}); clearToken(); closePaymentFlow(); closeEmailLoginCodeModal(); diff --git a/bot/app/web/templates/subscription_webapp.min.723dfe05.js b/bot/app/web/templates/subscription_webapp.min.723dfe05.js new file mode 100644 index 0000000..3c6b3bf --- /dev/null +++ b/bot/app/web/templates/subscription_webapp.min.723dfe05.js @@ -0,0 +1,79 @@ +const MOCK=(()=>{const e=window.__WEBAPP_DEV_MOCK__,n=window.location.hostname,a=window.location.protocol==="file:"||n==="localhost"||n==="127.0.0.1"||n==="";return e&&a?e:null})();function readJsonScript(e){const n=document.getElementById(e);if(!n||!n.textContent)return null;try{return JSON.parse(n.textContent)}catch(a){return console.warn("Failed to parse JSON config from #"+e,a),null}}const CFG=readJsonScript("webapp-config")||MOCK&&MOCK.config||{},tg=window.Telegram&&window.Telegram.WebApp?window.Telegram.WebApp:null,TELEGRAM_LOGIN_WIDGET_URL="./telegram-widget.js",state={token:MOCK?"local-preview":localStorage.getItem("rw_webapp_token")||"",csrfToken:MOCK?"":readCookie("rw_webapp_csrf")||"",data:null,selectedPlan:null,referralParam:readReferralParam(),promoApplying:!1,payment:null,paymentFlowOpen:!1,promoModalOpen:!1,referralModalOpen:!1,accountMergeModalOpen:!1,accountMergeNotice:null,creatingPayment:!1,authInProgress:!1,authMode:CFG.emailAuthEnabled===!1?"telegram":"email",emailLoginPending:!1,emailLoginEmail:"",emailLoginCodeModalOpen:!1,emailLoginVerifying:!1,emailLoginResending:!1,emailLinkPending:!1,emailLinkEmail:"",promoStatusTimer:null,promoStatusType:"",promoStatusText:"",telegramLinkRendered:!1,telegramLinkInProgress:!1,toastTimer:null,userMenuOpen:!1,userMenuCloseTimer:null,langOverride:(function(){try{return localStorage.getItem("rw_webapp_lang")||""}catch(e){return""}})(),langMenuOpen:!1,langMenuCloseTimer:null},LANG_OPTIONS=[{code:"ru",label:"Русский",short:"RU",flag:"🇷🇺"},{code:"en",label:"English",short:"EN",flag:"🇬🇧"}],TW={iconBtn:"icon-btn",btnBase:"btn",btnIconOnly:"w-12 min-w-12 p-0",panelHead:"panel-head",flowCaption:"section-caption",sectionTitle:"section-title",metricLabel:"metric-label",metricValue:"metric-value",referralLinkRow:"referral-link-row",referralLinkValue:"referral-link-value",bonusRow:"bonus-row",empty:"empty-state",planCard:"plan-card",planCardActive:"plan-card-active",planName:"plan-name",planMeta:"plan-meta",planPrice:"plan-price",paymentMethodCard:"payment-method-card",paymentMethodCardPlatega:"payment-method-card--platega",paymentMethodCardCryptopay:"payment-method-card--cryptopay",notice:"notice",stepNum:"step-num",stepName:"step-name"},FALLBACK_I18N={ru:{page_title:"Моя подписка",loading:"Загрузка...",refresh:"Обновить",logout:"Выйти",subscription:"Подписка",subscription_subtitle:"Ваша ссылка подключения и срок доступа",ends_at:"Окончание",traffic:"Трафик",connect:"Подключиться",copy_link:"Скопировать ссылку",extend_subscription:"Купить подписку / Добавить дни",payment_title:"Оплата подписки",payment_caption:"Выберите период и нажмите способ оплаты. Сервис оплаты откроется сразу.",close:"Закрыть",close_payment:"Закрыть оплату",payment_steps:"Шаги оплаты",step_plan:"Срок",step_method:"Метод",step_result:"Платеж",select_period:"Выберите период",select_method:"Выберите способ оплаты",payment_status:"Статус платежа",payment_amount_label:"Стоимость выбранного периода",choose_payment_method:"Способ оплаты",back:"Назад",create_payment:"Создать платеж",open_payment:"Открыть оплату",check_payment:"Проверить оплату",choose_other_method:"Выбрать другой способ",pay_with_platega_button:"Оплатить картой (СБП)",pay_with_cryptopay_button:"Оплатить криптой",support:"Поддержка",account_title:"Аккаунт",account_caption:"Способы входа",email_label:"Email",telegram_label:"Telegram",linked:"Привязан",not_linked:"Не привязан",email_login_tab:"Email",telegram_login_tab:"Telegram",email_placeholder:"mail@example.com",code_placeholder:"000000",send_code:"Отправить код",resend_code:"Отправить еще раз",confirm:"Подтвердить",login:"Войти",login_title:"Войдите или зарегистрируйтесь",login_continue:"Продолжить",email_code_title:"Подтвердите вход",email_code_caption:"Введите 6-значный код из письма.",email_code_aria:"Код подтверждения",email_auth_disabled:"Вход по email пока не настроен.",email_required:"Введите email",email_invalid:"Введите корректный email",email_code_sending:"Отправляю код...",email_code_sent:"Код отправлен на почту",email_code_send_failed:"Не удалось отправить код",email_code_invalid:"Неверный код",email_code_expired:"Код устарел",email_rate_limited:"Повторная отправка доступна через {seconds} сек.",email_linked:"Email привязан",telegram_linked:"Telegram привязан",account_merge_toast:"Аккаунты объединены",account_merge_title:"Аккаунты объединены",account_merge_caption:"Оплаченные периоды сложились, и теперь у вас один аккаунт.",account_merge_body:"Мы объединили ваш Telegram и email в один профиль. Оплаченные периоды сложились, ссылка на подписку осталась прежней, а более поздний аккаунт был удалён автоматически.",account_merge_primary_account_label:"Оставлен аккаунт",account_merge_removed_account_label:"Удалён аккаунт",account_merge_end_date_label:"Новая дата окончания",account_merge_conflict:"Этот аккаунт уже связан с другими данными.",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:"Не активна",subscription_active:"Подписка активна",subscription_inactive:"Подписка не активна",sub_remaining_template:"Действует еще {value}",sub_ends_template:"Закончится {date}",sub_ended_template:"Закончилась {date}",sub_no_expiry:"Бессрочно",traffic_unlimited:"∞ бесконечный трафик",link_panel_title:"Привяжите способ входа",link_panel_caption:"Чтобы не потерять доступ к кабинету",link_email_title:"Привяжите email",link_email_caption:"Получайте код для входа на почту",link_telegram_title:"Привяжите Telegram",link_telegram_caption:"Входите одним нажатием через Telegram",guest_user:"Гость",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:"Пользовательское соглашение",promo_title:"Промокод",promo_caption:"Введите код, чтобы начислить бонусные дни.",promo_placeholder:"PROMO2026",apply_promo:"Применить",promo_applied:"Промокод применен",promo_applied_ok:"Промокод применен, всё ок.",promo_empty:"Введите промокод",promo_invalid:"Промокод не найден или недействителен.",referral_title:"Пригласить друга",referral_caption:"Поделитесь ссылкой и получайте бонусы.",referral_code:"Ваш код",invited_friends:"Приглашено",purchased_friends:"Оплатили",copy_code:"Скопировать код",copy_webapp_link:"Скопировать ссылку Web App",copy_bot_link:"Скопировать ссылку бота",referral_copied:"Ссылка скопирована",code_copied:"Код скопирован",referral_site_label:"Пригласить через сайт",referral_telegram_label:"Пригласить через телеграм",referral_bonus_title:"Бонусы",referral_bonus_pair:"Вы: {inviter} дн. / друг: {friend} дн.",referral_no_bonuses:"Бонусы пока не настроены.",referral_bonus_explanation:"Бонусы начисляются один раз за каждого приглашенного пользователя, когда приглашенный пользователь покупает подписку."},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 and tap a payment method. The payment service will open right away.",close:"Close",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",payment_amount_label:"Selected period cost",choose_payment_method:"Payment method",back:"Back",create_payment:"Create payment",open_payment:"Open payment",check_payment:"Check payment",choose_other_method:"Choose another method",pay_with_platega_button:"Pay by card (SBP)",pay_with_cryptopay_button:"Pay with crypto",support:"Support",account_title:"Account",account_caption:"Sign-in methods",email_label:"Email",telegram_label:"Telegram",linked:"Linked",not_linked:"Not linked",email_login_tab:"Email",telegram_login_tab:"Telegram",email_placeholder:"mail@example.com",code_placeholder:"000000",send_code:"Send code",resend_code:"Send again",confirm:"Confirm",login:"Log in",login_title:"Log in or sign up",login_continue:"Continue",email_code_title:"Confirm login",email_code_caption:"Enter the 6-digit code from the email.",email_code_aria:"Verification code",email_auth_disabled:"Email sign-in is not configured yet.",email_required:"Enter your email",email_invalid:"Enter a valid email address",email_code_sending:"Sending code...",email_code_sent:"Code sent to email",email_code_send_failed:"Could not send code",email_code_invalid:"Invalid code",email_code_expired:"Code expired",email_rate_limited:"Try again in {seconds} sec.",email_linked:"Email linked",telegram_linked:"Telegram linked",account_merge_toast:"Accounts merged",account_merge_title:"Accounts merged",account_merge_caption:"Your paid periods were combined and you now have one account.",account_merge_body:"We merged your Telegram and email accounts into one profile. Your paid periods were combined, your subscription link stayed the same, and the later account was removed automatically.",account_merge_primary_account_label:"Kept account",account_merge_removed_account_label:"Removed account",account_merge_end_date_label:"New end date",account_merge_conflict:"This account is already linked to different data.",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",subscription_active:"Subscription is active",subscription_inactive:"Subscription is inactive",sub_remaining_template:"Valid for {value} more",sub_ends_template:"Expires {date}",sub_ended_template:"Expired {date}",sub_no_expiry:"No expiration",traffic_unlimited:"∞ unlimited traffic",link_panel_title:"Link a sign-in method",link_panel_caption:"So you never lose access",link_email_title:"Link email",link_email_caption:"Receive sign-in codes by email",link_telegram_title:"Link Telegram",link_telegram_caption:"Sign in with one tap via Telegram",guest_user:"Guest",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",promo_title:"Promo code",promo_caption:"Enter a code to add bonus days.",promo_placeholder:"PROMO2026",apply_promo:"Apply",promo_applied:"Promo code applied",promo_applied_ok:"Promo code applied, all good.",promo_empty:"Enter promo code",promo_invalid:"Promo code was not found or is not valid.",referral_title:"Invite friend",referral_caption:"Share your link and receive bonuses.",referral_code:"Your code",invited_friends:"Invited",purchased_friends:"Purchased",copy_code:"Copy code",copy_webapp_link:"Copy Web App link",copy_bot_link:"Copy bot link",referral_copied:"Link copied",code_copied:"Code copied",referral_site_label:"Invite via site",referral_telegram_label:"Invite via Telegram",referral_bonus_title:"Bonuses",referral_bonus_pair:"You: {inviter} d. / friend: {friend} d.",referral_no_bonuses:"Bonuses are not configured yet.",referral_bonus_explanation:"Bonuses are awarded once for each invited user when they purchase a subscription."}},I18N=readJsonScript("i18n")||MOCK&&MOCK.i18n||{},accent=CFG.primaryColor||"#00fe7a";if(document.documentElement.style.setProperty("--accent",accent),setBrand(),applyI18n(),applyLegalLinks(),CFG.supportUrl){const e=document.getElementById("support-link");e.href=CFG.supportUrl,e.classList.remove("hidden")}if(tg)try{tg.ready(),tg.expand(),typeof tg.isVersionAtLeast=="function"&&tg.isVersionAtLeast("6.1")&&(tg.setHeaderColor("#05070a"),tg.setBackgroundColor("#05070a"))}catch(e){}bindEmailLoginInput(),bindEmailCodeInput(),bindPromoInput(),document.addEventListener("keydown",e=>{handleDocumentKeyForUserMenu(e),e.key==="Escape"&&(state.emailLoginCodeModalOpen?closeEmailLoginCodeModal():state.accountMergeModalOpen?closeAccountMergeModal():state.promoModalOpen?closePromoModal():state.referralModalOpen?closeReferralModal():state.paymentFlowOpen&&closePaymentFlow())}),document.addEventListener("click",handleDocumentActionClick),document.addEventListener("click",handleDocumentClickForUserMenu),document.addEventListener("click",handleDocumentClickForLangMenu),renderLangMenu(),boot();async function boot(){if(showLoader(),MOCK){await loadData();return}const e=readTelegramLoginWidgetAuthData();if(e){if(await finalizeTelegramAuth(e))return;clearToken(),startExternalAuth({resetStatus:!1});return}if(tg&&tg.initData)try{if(await finalizeTelegramAuth(tg.initData,"init_data"))return}catch(n){}if(state.token||readCookie("rw_webapp_csrf"))try{await loadData();return}catch(n){clearToken()}await startExternalAuth()}function readTelegramLoginWidgetAuthData(){const e=new URLSearchParams(window.location.search),n=["id","first_name","last_name","username","photo_url","auth_date","hash"],a={};let i=!1;return n.forEach(o=>{e.has(o)&&(a[o]=e.get(o)||"",i=!0)}),!i||!a.id||!a.auth_date||!a.hash?null:a}function readReferralParam(){const e=new URLSearchParams(window.location.search),n=e.get("ref")||e.get("start")||e.get("start_param")||"",a=tg&&tg.initDataUnsafe&&tg.initDataUnsafe.start_param?tg.initDataUnsafe.start_param:"",i=String(a||n||"").trim();return i?(localStorage.setItem("rw_webapp_referral",i),i):localStorage.getItem("rw_webapp_referral")||""}function clearTelegramLoginWidgetQuery(){const e=new URL(window.location.href);["id","first_name","last_name","username","photo_url","auth_date","hash"].forEach(a=>e.searchParams.delete(a)),window.history&&window.history.replaceState&&window.history.replaceState({},document.title,e.pathname+e.search+e.hash)}async function finalizeTelegramAuth(e,n="auth_data"){if(state.authInProgress)return!1;state.authInProgress=!0,setAuthStatus(t("telegram_auth_verifying"));try{const a=n==="init_data"?{init_data:e}:{auth_data:e};state.referralParam&&(a.referral_code=state.referralParam);const i=await fetch(CFG.apiBase+"/auth/token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),o=await i.json();if(i.ok&&o.ok&&o.token){setToken(o.token,o.csrf_token),clearTelegramLoginWidgetQuery();try{return setAuthStatus(""),await loadData(),!0}catch(l){return clearToken(),setAuthStatus(t("telegram_auth_failed"),!0),!1}}const r=o.error==="banned"||o.error==="access_denied"?"telegram_auth_access_denied":"telegram_auth_failed";setAuthStatus(t(r),!0)}catch(a){setAuthStatus(t("telegram_auth_failed"),!0)}finally{state.authInProgress=!1}return clearTelegramLoginWidgetQuery(),!1}async function handleTelegramLoginWidgetAuth(e){if(!e){setAuthStatus(t("telegram_auth_failed"),!0);return}await finalizeTelegramAuth(e,"auth_data")}function setAuthMode(e){state.authMode=e==="telegram"?"telegram":"email",renderAuthMode()}function renderAuthMode(){const e=document.getElementById("email-auth-tab"),n=document.getElementById("telegram-auth-tab"),a=document.getElementById("email-login-pane"),i=document.getElementById("telegram-login-pane"),o=document.querySelector(".login-auth-body"),r=CFG.emailAuthEnabled!==!1;!r&&state.authMode==="email"&&(state.authMode="telegram"),e.classList.toggle("active",state.authMode==="email"),n.classList.toggle("active",state.authMode==="telegram"),a.classList.toggle("hidden",state.authMode!=="email"),i.classList.toggle("hidden",state.authMode!=="telegram"),o&&o.classList.toggle("login-auth-body--telegram",state.authMode==="telegram"),e.disabled=!r,state.authMode==="telegram"?renderTelegramLoginWidget():r||setAuthStatus(t("email_auth_disabled"),!0)}function renderTelegramLoginWidget(){const e=document.getElementById("telegram-login-widget");if(!e)return;e.innerHTML="";const n=String(CFG.telegramLoginBotUsername||"").trim();if(!n){setAuthStatus(t("telegram_auth_unavailable"),!0);return}typeof window.onTelegramAuth!="function"&&(window.onTelegramAuth=async function(i){await handleTelegramLoginWidgetAuth(i)});const a=document.createElement("script");a.async=!0,a.src=TELEGRAM_LOGIN_WIDGET_URL,a.setAttribute("data-telegram-login",n),a.setAttribute("data-size","large"),a.setAttribute("data-userpic","true"),a.setAttribute("data-request-access","write"),a.setAttribute("data-onauth","onTelegramAuth(user)"),a.onerror=()=>setAuthStatus(t("telegram_auth_unavailable"),!0),e.appendChild(a)}function bindEmailLoginInput(){const e=document.getElementById("email-login-input");e&&(e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),requestEmailLoginCode())}),e.addEventListener("input",()=>{e.getAttribute("aria-invalid")==="true"&&(e.removeAttribute("aria-invalid"),setAuthStatus(""))}))}function bindEmailCodeInput(){const e=document.getElementById("email-login-code-input");e&&(e.addEventListener("input",()=>{e.value=sanitizeCode(e.value),updateEmailLoginCodeSlots()}),e.addEventListener("focus",updateEmailLoginCodeSlots),e.addEventListener("blur",updateEmailLoginCodeSlots),e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),sanitizeCode(e.value).length===6&&!state.emailLoginVerifying&&verifyEmailLoginCode())}))}function sanitizeCode(e){return String(e||"").replace(/\D/g,"").slice(0,6)}function isValidEmail(e){const n=normalizeEmail(e);return!!(n&&n.length<=254&&/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))}function readValidEmailLoginInput(){const e=document.getElementById("email-login-input"),n=normalizeEmail(e&&e.value);return!n||!isValidEmail(n)?(e&&(e.setAttribute("aria-invalid","true"),e.focus()),setAuthStatus(t(n?"email_invalid":"email_required"),!0),""):(e&&(e.value=n,e.removeAttribute("aria-invalid")),n)}function openEmailLoginCodeModal(e,n,a=!1){state.emailLoginEmail=e,state.emailLoginCodeModalOpen=!0;const i=document.getElementById("email-login-code-input");i&&(i.value=""),renderEmailLoginCodeModal(),setEmailCodeStatus(n||t("email_code_sent"),a),window.setTimeout(()=>{const o=document.getElementById("email-login-code-input");state.emailLoginCodeModalOpen&&o&&o.focus()},80)}function closeEmailLoginCodeModal(){state.emailLoginCodeModalOpen=!1;const e=document.getElementById("email-login-code-input");e&&(e.value=""),setEmailCodeStatus(""),renderEmailLoginCodeModal()}function renderEmailLoginCodeModal(){const e=document.getElementById("email-code-modal");if(!e)return;if(!state.emailLoginCodeModalOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.emailLoginCodeModalOpen||e.classList.add("hidden")},180);return}const n=document.getElementById("email-code-address");n&&(n.textContent=state.emailLoginEmail||"..."),e.classList.remove("hidden"),syncModalLock(),applyI18n(e),updateEmailLoginCodeSlots(),window.requestAnimationFrame(()=>e.classList.add("show"))}function updateEmailLoginCodeSlots(){const e=document.getElementById("email-login-code-input"),n=sanitizeCode(e&&e.value);e&&e.value!==n&&(e.value=n);for(let o=0;o<6;o+=1){const r=document.getElementById("email-code-slot-"+o);if(!r)continue;const l=n[o]||"";r.textContent=l,r.classList.toggle("filled",!!l),r.classList.toggle("active",state.emailLoginCodeModalOpen&&document.activeElement===e&&o===Math.min(n.length,5))}const a=document.getElementById("email-login-verify-btn");a&&(a.disabled=n.length!==6||state.emailLoginVerifying||state.emailLoginPending);const i=document.getElementById("email-login-resend-btn");i&&(i.disabled=state.emailLoginPending||state.emailLoginResending)}async function requestEmailLoginCode(){if(state.emailLoginPending)return;if(CFG.emailAuthEnabled===!1){setAuthStatus(t("email_auth_disabled"),!0);return}const e=readValidEmailLoginInput();if(e){state.emailLoginPending=!0,setButtonBusy("email-login-send-btn",!0),openEmailLoginCodeModal(e,t("email_code_sending")),setAuthStatus("");try{const n=await publicApi("/auth/email/request",{email:e,language:getLanguage()});if(!n.ok)throw n;setEmailCodeStatus(t("email_code_sent"))}catch(n){const a=emailErrorMessage(n,"email_code_send_failed");if(n&&n.error==="invalid_email"){closeEmailLoginCodeModal();const i=document.getElementById("email-login-input");i&&i.setAttribute("aria-invalid","true"),setAuthStatus(a,!0)}else setEmailCodeStatus(a,!0)}finally{state.emailLoginPending=!1,setButtonBusy("email-login-send-btn",!1),updateEmailLoginCodeSlots()}}}async function resendEmailLoginCode(){if(state.emailLoginPending||state.emailLoginResending)return;const e=state.emailLoginEmail||normalizeEmail(document.getElementById("email-login-input").value);if(!e||!isValidEmail(e)){setEmailCodeStatus(t(e?"email_invalid":"email_required"),!0);return}state.emailLoginResending=!0,updateEmailLoginCodeSlots(),setEmailCodeStatus(t("email_code_sending"));try{const n=await publicApi("/auth/email/request",{email:e,language:getLanguage()});if(!n.ok)throw n;state.emailLoginEmail=e;const a=document.getElementById("email-login-code-input");a&&(a.value=""),setEmailCodeStatus(t("email_code_sent"))}catch(n){setEmailCodeStatus(emailErrorMessage(n,"email_code_send_failed"),!0)}finally{state.emailLoginResending=!1,updateEmailLoginCodeSlots()}}async function verifyEmailLoginCode(){const e=state.emailLoginEmail||normalizeEmail(document.getElementById("email-login-input").value),n=document.getElementById("email-login-code-input"),a=sanitizeCode(n&&n.value);if(!e||a.length!==6){setEmailCodeStatus(t("email_code_invalid"),!0),updateEmailLoginCodeSlots();return}state.emailLoginVerifying=!0,updateEmailLoginCodeSlots(),setEmailCodeStatus(t("telegram_auth_verifying"));try{const i=await publicApi("/auth/email/verify",{email:e,code:a,referral_code:state.referralParam||""});if(!i.ok||!i.token)throw i;setToken(i.token,i.csrf_token),closeEmailLoginCodeModal(),await loadData()}catch(i){clearToken(),setEmailCodeStatus(emailErrorMessage(i,"email_code_invalid"),!0)}finally{state.emailLoginVerifying=!1,updateEmailLoginCodeSlots()}}function startExternalAuth(e={}){const n=e.resetStatus!==!1;showLogin(),n&&setAuthStatus(""),renderAuthMode()}async function loadData(){const e=await api("/me");if(!e.ok)throw new Error(e.error||"load failed");state.data=e,setBrand(),applyI18n(),applyLegalLinks();const n=e.plans||[];state.selectedPlan=getDefaultPaymentPlan(n),state.payment=null,state.creatingPayment=!1,state.telegramLinkRendered=!1,render(),showApp()}function render(){renderSubscription(state.data.subscription),renderUserMenu(state.data.user||{}),renderAccount(state.data.user||{}),renderReferral(state.data.referral||{}),renderAccountMergeModal(),renderPaymentFlow()}function renderSubscription(e){const n=document.getElementById("sub-status"),a=document.getElementById("sub-status-text"),i=document.getElementById("remaining"),o=document.getElementById("end-date-caption");if(n.classList.toggle("active",!!e.active),a.textContent=e.active?t("subscription_active"):t("subscription_inactive"),e.active){const r=formatCompactRemaining(e.days_left,getLanguage())||e.remaining_text||"";i.textContent=r}else i.textContent=t("no_active_subscription");if(e.end_date_text){const r=e.active?"sub_ends_template":"sub_ended_template";o.textContent=t(r,{date:e.end_date_text}),o.classList.remove("hidden")}else e.active?(o.textContent=t("sub_no_expiry"),o.classList.remove("hidden")):(o.textContent="",o.classList.add("hidden"));renderTraffic(e),document.getElementById("connect-actions").classList.toggle("hidden",!e.connect_url&&!e.config_link)}function renderTraffic(e){const n=document.getElementById("traffic-bar"),a=document.getElementById("traffic-bar-fill"),i=document.getElementById("traffic");if(!n||!a||!i)return;const o=Number(e.traffic_limit_bytes),r=Number(e.traffic_used_bytes),l=Number.isFinite(o)&&o>0,s=Number.isFinite(r)&&r>=0,d=e.traffic_used||t("not_available"),c=e.traffic_limit||t("not_available");if(n.classList.remove("over","unlimited"),!l){n.classList.add("unlimited"),a.style.width="100%",i.textContent=t("traffic_unlimited"),n.setAttribute("aria-valuenow","0");return}const u=s?r/o:0,m=Math.max(0,Math.min(100,Math.round(u*100)));a.style.width=m+"%",i.textContent=d+" / "+c,n.setAttribute("aria-valuenow",String(m)),s&&r>=o&&n.classList.add("over")}function formatCompactRemaining(e,n){const a=Math.max(0,Math.floor(Number(e)||0));if(a<=0)return null;if(a>=365){const i=Math.round(a/365*2)/2;return formatDecimal(i,n)+" "+pluralizeUnit(i,"year",n)}if(a>=30){const i=Math.round(a/30*2)/2;return formatDecimal(i,n)+" "+pluralizeUnit(i,"month",n)}return formatDecimal(a,n)+" "+pluralizeUnit(a,"day",n)}function formatDecimal(e,n){if(Number.isInteger(e))return String(e);const a=e.toFixed(1);return n==="ru"?a.replace(".",","):a}function pluralizeUnit(e,n,a){const i={year:["year","years"],month:["month","months"],day:["day","days"]},o={year:["год","года","лет"],month:["месяц","месяца","месяцев"],day:["день","дня","дней"]};if(a==="en")return e===1?i[n][0]:i[n][1];const r=o[n];if(!Number.isInteger(e))return r[1];const l=Math.abs(e),s=l%10,d=l%100;return s===1&&d!==11?r[0]:s>=2&&s<=4&&(d<12||d>14)?r[1]:r[2]}function renderUserMenu(e){const n=!!(e.email&&e.email_verified),a=!!e.telegram_linked,i=getUserDisplayName(e),o=getUserSecondaryText(e,n,a),r=getUserAvatarSrc(e),l=buildIdenticon(getUserAvatarSeed(e,n)),s=document.getElementById("user-chip-name"),d=document.getElementById("user-chip-avatar"),c=document.getElementById("user-dropdown-avatar"),u=document.getElementById("user-dropdown-name"),m=document.getElementById("user-dropdown-sub"),p=document.getElementById("user-dropdown-email-status"),f=document.getElementById("user-dropdown-telegram-status");s.textContent=i,setAvatarWithFallback(d,r,l,i),setAvatarWithFallback(c,r,l,i),u.textContent=i,m.textContent=o,m.classList.toggle("hidden",!o),p.textContent=n?e.email:t("not_linked"),p.classList.toggle("linked",n),p.classList.toggle("unlinked",!n),f.textContent=a?e.telegram_id?String(e.telegram_id):t("linked"):t("not_linked"),f.classList.toggle("linked",a),f.classList.toggle("unlinked",!a)}function renderAccount(e){const n=!!(e.email&&e.email_verified),a=!!e.telegram_linked,i=!!(state.data.settings&&state.data.settings.email_auth_enabled),o=!n&&i,r=!a,l=document.getElementById("email-link-box"),s=document.getElementById("telegram-link-box"),d=document.getElementById("link-panel");l.classList.toggle("hidden",!o),s.classList.toggle("hidden",!r),d.classList.toggle("hidden",!o&&!r),a?state.telegramLinkRendered=!1:renderTelegramLinkWidget()}function getTelegramInitUser(){return!tg||!tg.initDataUnsafe||!tg.initDataUnsafe.user?null:tg.initDataUnsafe.user}function getUserAvatarSeed(e,n){const a=getTelegramInitUser();return n&&e&&e.email?e.email.trim().toLowerCase():String(e&&e.id||e&&e.telegram_id||a&&a.id||"guest")}function getUserAvatarSrc(e){const n=getTelegramInitUser();return n&&n.photo_url?n.photo_url:e&&e.telegram_photo_url?e.telegram_photo_url:""}function getUserDisplayName(e){const n=getTelegramInitUser();return n&&n.first_name?n.first_name:e&&e.first_name?e.first_name:e&&e.username?"@"+e.username:n&&n.username?"@"+n.username:e&&e.email?e.email:t("guest_user")}function getUserSecondaryText(e,n,a){return n&&e.email?e.email:a&&e.telegram_id?"#"+e.telegram_id:""}function setAvatarWithFallback(e,n,a,i){if(e){if(e.alt=i||"",!n){e.onerror=null,e.src=a;return}e.onerror=()=>{e.onerror=null,e.src=a},e.src=n}}function buildIdenticon(e){const n=String(e||"guest");let a=2166136261;for(let c=0;c>>0;const i=a%360,o="hsl("+i+", 60%, 18%)",r="hsl("+(i+180)%360+", 78%, 62%)";let l="",s=a;for(let c=0;c<5;c++)for(let u=0;u<3;u++){const m=s&1;s=s>>>1^(s&1?46080:0),m&&(l+='',u!==2&&(l+=''))}const d=''+l+"";return"data:image/svg+xml;utf8,"+encodeURIComponent(d)}function toggleUserMenu(e){const n=typeof e=="boolean"?e:!state.userMenuOpen;state.userMenuOpen=n;const a=document.getElementById("user-chip"),i=document.getElementById("user-dropdown");if(!(!a||!i)){if(state.userMenuCloseTimer&&(window.clearTimeout(state.userMenuCloseTimer),state.userMenuCloseTimer=null),a.setAttribute("aria-expanded",n?"true":"false"),n){i.classList.remove("hidden"),i.classList.remove("show"),window.requestAnimationFrame(()=>{state.userMenuOpen&&i.classList.add("show")});return}i.classList.remove("show"),state.userMenuCloseTimer=window.setTimeout(()=>{state.userMenuOpen||i.classList.add("hidden"),state.userMenuCloseTimer=null},180)}}function handleDocumentClickForUserMenu(e){if(!state.userMenuOpen)return;const n=document.querySelector(".user-menu");n&&n.contains(e.target)||toggleUserMenu(!1)}function handleDocumentKeyForUserMenu(e){e.key==="Escape"&&state.userMenuOpen&&toggleUserMenu(!1),e.key==="Escape"&&state.langMenuOpen&&toggleLangMenu(!1)}function renderLangMenu(){const e=getLanguage(),n=document.getElementById("lang-chip-label"),a=document.getElementById("lang-chip-flag"),i=LANG_OPTIONS.find(r=>r.code===e)||LANG_OPTIONS[0];n&&(n.textContent=i.short),a&&(a.textContent=i.flag);const o=document.getElementById("lang-dropdown");o&&(o.innerHTML=LANG_OPTIONS.map(r=>'").join(""),o.querySelectorAll("[data-lang]").forEach(r=>{r.addEventListener("click",()=>setLanguage(r.dataset.lang))}))}function toggleLangMenu(e){const n=typeof e=="boolean"?e:!state.langMenuOpen;state.langMenuOpen=n;const a=document.getElementById("lang-chip"),i=document.getElementById("lang-dropdown");if(!(!a||!i)){if(state.langMenuCloseTimer&&(window.clearTimeout(state.langMenuCloseTimer),state.langMenuCloseTimer=null),a.setAttribute("aria-expanded",n?"true":"false"),n){i.classList.remove("hidden"),i.classList.remove("show"),window.requestAnimationFrame(()=>{state.langMenuOpen&&i.classList.add("show")});return}i.classList.remove("show"),state.langMenuCloseTimer=window.setTimeout(()=>{state.langMenuOpen||i.classList.add("hidden"),state.langMenuCloseTimer=null},180)}}function handleDocumentClickForLangMenu(e){if(!state.langMenuOpen)return;const n=document.querySelector(".lang-menu");n&&n.contains(e.target)||toggleLangMenu(!1)}function handleDocumentActionClick(e){const n=e.target instanceof Element?e.target.closest("[data-action]"):null;if(!n||!document.contains(n)||n.disabled)return;const a=String(n.dataset.action||"");if(a){switch(a){case"toggle-user-menu":toggleUserMenu();break;case"toggle-lang-menu":toggleLangMenu();break;case"logout":logout();break;case"open-connect-link":openConnectLink();break;case"toggle-payment-flow":togglePaymentFlow();break;case"open-referral-modal":openReferralModal();break;case"open-promo-modal":openPromoModal();break;case"close-payment-flow":closePaymentFlow();break;case"close-promo-modal":closePromoModal();break;case"close-referral-modal":closeReferralModal();break;case"close-account-merge-modal":closeAccountMergeModal();break;case"close-email-login-code-modal":closeEmailLoginCodeModal();break;case"request-email-link-code":requestEmailLinkCode();break;case"verify-email-link-code":verifyEmailLinkCode();break;case"request-email-login-code":requestEmailLoginCode();break;case"verify-email-login-code":verifyEmailLoginCode();break;case"resend-email-login-code":resendEmailLoginCode();break;case"set-auth-mode":setAuthMode(n.dataset.mode||"email");break;case"apply-promo-code":applyPromoCode();break;case"select-plan":selectPlan(n.dataset.months||"");break;case"create-payment":createAndOpenPayment(n.dataset.method||"");break;case"copy-referral-link":copyReferralLink(n.dataset.kind||"webapp");break;case"copy-config-link":copyConfigLink();break;case"check-payment":checkPayment();break;default:return}e.preventDefault()}}function openPromoModal(){state.promoModalOpen=!0,clearPromoStatus(),renderPromoModal(),window.setTimeout(()=>{const e=document.getElementById("promo-code-input");e&&e.focus()},80)}function closePromoModal(){state.promoModalOpen=!1,clearPromoStatus(),renderPromoModal()}function renderPromoModal(){const e=document.getElementById("promo-modal");if(e){if(!state.promoModalOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.promoModalOpen||e.classList.add("hidden")},180);return}e.classList.remove("hidden"),syncModalLock(),renderPromoStatus(),applyI18n(e),window.requestAnimationFrame(()=>e.classList.add("show"))}}function bindPromoInput(){const e=document.getElementById("promo-code-input");e&&(e.addEventListener("input",()=>{e.getAttribute("aria-invalid")==="true"&&e.removeAttribute("aria-invalid"),state.promoStatusText&&clearPromoStatus()}),e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),applyPromoCode())}))}function setPromoStatus(e,n="error"){state.promoStatusType=n==="success"?"success":"error",state.promoStatusText=normalizeStatusText(e||""),renderPromoStatus()}function clearPromoStatus(){state.promoStatusType="",state.promoStatusText="",renderPromoStatus()}function renderPromoStatus(){const e=document.getElementById("promo-status");if(e){if(!state.promoStatusText){e.textContent="",e.classList.add("hidden"),e.classList.remove("error","success");return}e.textContent=state.promoStatusText,e.classList.remove("hidden"),e.classList.toggle("error",state.promoStatusType==="error"),e.classList.toggle("success",state.promoStatusType==="success")}}function normalizeStatusText(e){const n=String(e||""),a=document.createElement("div");return a.innerHTML=n,(a.textContent||a.innerText||"").trim()}function openReferralModal(){state.referralModalOpen=!0,renderReferral(state.data&&state.data.referral||{})}function closeReferralModal(){state.referralModalOpen=!1,renderReferral(state.data&&state.data.referral||{})}function closeToolModals(){state.promoModalOpen=!1,state.referralModalOpen=!1,state.accountMergeModalOpen=!1,clearPromoStatus(),renderPromoModal(),renderReferral(state.data&&state.data.referral||{}),renderAccountMergeModal()}function openAccountMergeModal(e=null){e&&(state.accountMergeNotice=e),state.accountMergeModalOpen=!0,renderAccountMergeModal()}function closeAccountMergeModal(){state.accountMergeModalOpen=!1,renderAccountMergeModal()}function renderAccountMergeModal(){const e=document.getElementById("account-merge-modal"),n=document.getElementById("account-merge-panel");if(!e||!n)return;if(!state.accountMergeModalOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.accountMergeModalOpen||e.classList.add("hidden")},180);return}e.classList.remove("hidden"),syncModalLock();const a=state.accountMergeNotice||{},i=a.primary_user_id!=null?String(a.primary_user_id):t("not_available"),o=a.removed_user_id!=null?String(a.removed_user_id):t("not_available"),r=a.final_end_date_text||t("not_available");n.innerHTML=` +
+
+
${escapeHtml(t("account_merge_title"))}
+
${escapeHtml(t("account_merge_caption"))}
+
+ +
+ +
+
${escapeHtml(t("account_merge_body"))}
+
+ +
+
+
${escapeHtml(t("account_merge_primary_account_label"))}
+
#${escapeHtml(i)}
+
+
+
${escapeHtml(t("account_merge_removed_account_label"))}
+
#${escapeHtml(o)}
+
+
+
${escapeHtml(t("account_merge_end_date_label"))}
+
${escapeHtml(r)}
+
+
+ `,applyI18n(e),window.requestAnimationFrame(()=>e.classList.add("show"))}function renderReferral(e){const n=document.getElementById("referral-modal"),a=document.getElementById("referral-panel");if(!n||!a)return;if(!state.referralModalOpen){n.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.referralModalOpen||n.classList.add("hidden")},180);return}if(n.classList.remove("hidden"),syncModalLock(),!state.referralModalOpen)return;const i=(e.bonus_details||[]).map(o=>` +
+ ${escapeHtml(o.title||"")} + ${escapeHtml(t("referral_bonus_pair",{inviter:o.inviter_days||0,friend:o.friend_days||0}))} +
+ `).join("");a.innerHTML=` +
+
+
${escapeHtml(t("referral_title"))}
+
${escapeHtml(t("referral_caption"))}
+
+ +
+ +
+
+ + +
+ +
+
+ +
${escapeHtml(t("referral_bonus_explanation"))}
+
+
${i||'
'+escapeHtml(t("referral_no_bonuses"))+"
"}
+
+
+ `,applyI18n(n),window.requestAnimationFrame(()=>n.classList.add("show"))}function renderReferralLinkRow(e,n,a){return` +
+
+
${escapeHtml(n)}
+
${escapeHtml(a||t("not_available"))}
+
+ +
+ `}async function applyPromoCode(){if(state.promoApplying)return;const e=document.getElementById("promo-code-input"),n=String(e&&e.value||"").trim();if(!n){setPromoStatus(t("promo_empty"),"error"),e&&e.setAttribute("aria-invalid","true"),e&&e.focus();return}state.promoApplying=!0,setButtonBusy("promo-apply-btn",!0),clearPromoStatus(),e&&e.removeAttribute("aria-invalid");try{const a=await api("/promo/apply",{method:"POST",body:JSON.stringify({code:n})});if(!a.ok)throw a;e&&(e.value=""),setPromoStatus(t("promo_applied_ok"),"success"),await loadData()}catch(a){const i=a&&a.error,o=a&&(a.message||a.detail);e&&e.setAttribute("aria-invalid","true"),setPromoStatus(i==="empty_code"?t("promo_empty"):o||t(i==="promo_apply_failed"?"promo_invalid":"payment_error"),"error")}finally{state.promoApplying=!1,setButtonBusy("promo-apply-btn",!1)}}async function copyReferralLink(e){const n=state.data&&state.data.referral,a=n&&(e==="bot"?n.bot_link:n.webapp_link);if(!a){showToast(t("no_link"));return}await copyText(a),showToast(t("referral_copied"))}async function copyReferralCode(){const e=state.data&&state.data.referral,n=e&&e.code;if(!n){showToast(t("no_link"));return}await copyText(n),showToast(t("code_copied"))}async function requestEmailLinkCode(){const e=document.getElementById("email-link-input"),n=normalizeEmail(e.value);if(!n){showToast(t("email_code_send_failed"));return}state.emailLinkPending=!0,setButtonBusy("email-link-send-btn",!0);try{const a=await api("/account/email/request",{method:"POST",body:JSON.stringify({email:n})});if(!a.ok)throw a;if(a.already_linked){showToast(t("email_linked")),await loadData();return}state.emailLinkEmail=n,document.getElementById("email-link-code-row").classList.remove("hidden"),showToast(t("email_code_sent"))}catch(a){showToast(emailErrorMessage(a,"email_code_send_failed"))}finally{state.emailLinkPending=!1,setButtonBusy("email-link-send-btn",!1)}}async function verifyEmailLinkCode(){const e=state.emailLinkEmail||normalizeEmail(document.getElementById("email-link-input").value),n=document.getElementById("email-link-code-input").value;setButtonBusy("email-link-verify-btn",!0);let a=null;try{const i=await api("/account/email/verify",{method:"POST",body:JSON.stringify({email:e,code:n})});if(!i.ok)throw i;i.token&&setToken(i.token,i.csrf_token),a=i.account_merge&&i.account_merge.merged?i.account_merge:null,state.accountMergeNotice=a,showToast(t(a?"account_merge_toast":"email_linked")),state.emailLinkEmail="";try{await loadData(),a&&openAccountMergeModal(a)}catch(o){console.warn("Email account linked, but data refresh failed",o),a&&openAccountMergeModal(a)}}catch(i){showToast(emailErrorMessage(i,"email_code_invalid"))}finally{setButtonBusy("email-link-verify-btn",!1)}}function renderTelegramLinkWidget(){const e=document.getElementById("telegram-link-widget");if(!e||state.telegramLinkRendered)return;e.innerHTML="";const n=String(CFG.telegramLoginBotUsername||"").trim();if(!n){setTelegramLinkStatus(t("telegram_auth_unavailable"),!0);return}window.onTelegramLinkAuth=async function(i){await linkTelegramAccount(i)};const a=document.createElement("script");a.async=!0,a.src=TELEGRAM_LOGIN_WIDGET_URL,a.setAttribute("data-telegram-login",n),a.setAttribute("data-size","large"),a.setAttribute("data-userpic","true"),a.setAttribute("data-request-access","write"),a.setAttribute("data-onauth","onTelegramLinkAuth(user)"),a.onerror=()=>setTelegramLinkStatus(t("telegram_auth_unavailable"),!0),e.appendChild(a),state.telegramLinkRendered=!0}async function linkTelegramAccount(e){if(!(!e||state.telegramLinkInProgress)){state.telegramLinkInProgress=!0,setTelegramLinkStatus(t("telegram_auth_verifying"));try{const n=await api("/account/telegram/link",{method:"POST",body:JSON.stringify({auth_data:e})});if(!n.ok)throw n;await finishTelegramLink(n,e.id)}catch(n){if(await refreshLinkedTelegramAfterError()){setTelegramLinkStatus("");try{showToast(t("telegram_linked"))}catch(a){console.warn("Telegram link success toast failed",a)}return}setTelegramLinkStatus(emailErrorMessage(n,"telegram_auth_failed"),!0)}finally{state.telegramLinkInProgress=!1}}}async function finishTelegramLink(e,n){e.token&&setToken(e.token,e.csrf_token),markTelegramLinked(e.telegram_id||n),setTelegramLinkStatus(""),state.telegramLinkRendered=!1;const a=e.account_merge&&e.account_merge.merged?e.account_merge:null;state.accountMergeNotice=a;try{showToast(t(a?"account_merge_toast":"telegram_linked"))}catch(i){console.warn("Telegram link success toast failed",i)}try{await loadData(),a&&openAccountMergeModal(a)}catch(i){console.warn("Telegram account linked, but data refresh failed",i),a&&openAccountMergeModal(a)}}function markTelegramLinked(e){!state.data||!state.data.user||(state.data.user.telegram_linked=!0,e&&(state.data.user.telegram_id=Number(e)),renderAccount(state.data.user))}async function refreshLinkedTelegramAfterError(){try{return await loadData(),!!(state.data&&state.data.user&&state.data.user.telegram_linked)}catch(e){return console.warn("Telegram link status refresh failed",e),!1}}function togglePaymentFlow(){if(state.paymentFlowOpen){closePaymentFlow();return}state.paymentFlowOpen=!0,state.creatingPayment=!1,state.payment=null,state.selectedPlan=getDefaultPaymentPlan(state.data&&state.data.plans||[]),renderPaymentFlow()}function closePaymentFlow(){state.paymentFlowOpen=!1,state.creatingPayment=!1,state.payment=null,renderPaymentFlow()}function renderPaymentFlow(){const e=document.getElementById("payment-modal");if(e){if(!state.paymentFlowOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.paymentFlowOpen||e.classList.add("hidden")},180);return}e.classList.remove("hidden"),syncModalLock(),window.requestAnimationFrame(()=>e.classList.add("show")),renderPlans(state.data&&state.data.plans||[]),renderPaymentSummary(),renderMethods(),applyI18n(e)}}function syncModalLock(){document.body.classList.toggle("modal-open",!!(state.paymentFlowOpen||state.emailLoginCodeModalOpen||state.promoModalOpen||state.referralModalOpen||state.accountMergeModalOpen))}function getDefaultPaymentPlan(e=[]){return e.length&&(e.find(n=>Number(n.months)===3)||e[0])||null}function renderPlans(e){const n=document.getElementById("plans");if(n){if(!e.length){n.innerHTML='
'+escapeHtml(t("no_plans"))+"
";return}(!state.selectedPlan||!e.some(a=>Number(a.months)===Number(state.selectedPlan.months)))&&(state.selectedPlan=getDefaultPaymentPlan(e)),n.innerHTML=e.map(a=>{const i=Number(a.months),o=state.selectedPlan&&Number(state.selectedPlan.months)===i;return` + + `}).join("")}}function renderPaymentSummary(){const e=document.getElementById("selected-plan-price");if(e){if(!state.selectedPlan){e.textContent=t("no_plans");return}e.textContent=formatMoney(state.selectedPlan.price,state.selectedPlan.currency)}}function renderMethods(){const e=document.getElementById("payment-methods");if(!e)return;if(!state.selectedPlan){e.innerHTML='
'+escapeHtml(t("choose_plan_first"))+"
";return}const n=getAvailableMethods();if(!n.length){e.innerHTML='
'+escapeHtml(t("no_methods"))+"
";return}e.innerHTML=n.map(a=>{const i=a.id==="platega"?"pay_with_platega_button":"pay_with_cryptopay_button",o=a.id==="platega"?TW.paymentMethodCardPlatega:TW.paymentMethodCardCryptopay;return` + + `}).join("")}function selectPlan(e){state.creatingPayment||(state.selectedPlan=(state.data&&state.data.plans||[]).find(n=>Number(n.months)===Number(e))||null,state.payment=null,renderPaymentFlow())}async function createAndOpenPayment(e){if(state.creatingPayment)return;if(!state.selectedPlan){showToast(t("choose_plan_toast"));return}if(!getAvailableMethods().some(a=>a.id===e)){showToast(t("no_methods"));return}state.creatingPayment=!0,state.payment=null,renderPaymentFlow();try{const a=await api("/payments",{method:"POST",body:JSON.stringify({months:state.selectedPlan.months,method:e})});if(!a.ok||!a.payment_url)throw new Error(a.message||t("payment_error"));state.payment=a,openPaymentUrl(a.payment_url)&&closePaymentFlow()}catch(a){showToast(a.message||t("payment_error"))}finally{state.creatingPayment=!1,state.paymentFlowOpen&&renderPaymentFlow()}}function getAvailableMethods(){if(!state.data||!state.selectedPlan)return[];const e=new Map((state.data.payment_methods||[]).map(n=>[String(n.id||"").toLowerCase(),n]));return["platega","cryptopay"].map(n=>e.get(n)).filter(Boolean)}function openPaymentUrl(e){const n=String(e||state.payment&&state.payment.payment_url||"").trim();return n?tg&&tg.openLink?(tg.openLink(n),!0):(window.open(n,"_blank","noopener")||window.location.assign(n),!0):!1}async function checkPayment(){if(!state.payment||!state.payment.payment_id)return;(await api("/payments/"+state.payment.payment_id)).paid?(showToast(t("payment_confirmed")),state.paymentFlowOpen=!1,await loadData()):showToast(t("payment_pending"))}function openConnectLink(){const e=state.data&&state.data.subscription,n=e&&(e.connect_url||e.config_link);if(!n){showToast(t("no_link"));return}tg&&tg.openLink?tg.openLink(n):window.open(n,"_blank","noopener")}async function copyConfigLink(){const e=state.data&&state.data.subscription,n=e&&e.config_link;if(!n){showToast(t("no_link"));return}await copyText(n),showToast(t("link_copied"))}async function copyText(e){try{await navigator.clipboard.writeText(e)}catch(n){const a=document.createElement("textarea");a.value=e,document.body.appendChild(a),a.select(),document.execCommand("copy"),a.remove()}}async function api(e,n={}){if(MOCK)return mockApi(e,n);const a=String(n.method||"GET").toUpperCase(),i=Object.assign({},n.headers||{});state.token&&(i.Authorization="Bearer "+state.token);const o=state.csrfToken||readCookie("rw_webapp_csrf")||"";o&&["POST","PUT","PATCH","DELETE"].includes(a)&&(i["X-CSRF-Token"]=o),n.body&&!i["Content-Type"]&&(i["Content-Type"]="application/json");const r=await fetch(CFG.apiBase+e,Object.assign({},n,{headers:i}));if(r.status===401)throw clearToken(),await startExternalAuth(),new Error("Unauthorized");return r.json()}async function publicApi(e,n={}){return MOCK?mockApi(e,{method:"POST",body:JSON.stringify(n)}):(await fetch(CFG.apiBase+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json()}async function mockApi(e,n={}){return await new Promise(a=>window.setTimeout(a,120)),e==="/me"?JSON.parse(JSON.stringify(MOCK.data)):e==="/auth/email/request"||e==="/account/email/request"?{ok:!0}:e==="/auth/email/verify"?{ok:!0,token:"local-preview",csrf_token:"local-preview-csrf"}:e==="/account/email/verify"?(MOCK.data.user.email="preview@example.com",MOCK.data.user.email_verified=!0,{ok:!0,token:"local-preview",csrf_token:"local-preview-csrf"}):e==="/account/telegram/link"?(MOCK.data.user.telegram_linked=!0,MOCK.data.user.telegram_id=100200300,{ok:!0,token:"local-preview",csrf_token:"local-preview-csrf"}):e==="/auth/logout"?{ok:!0}:e==="/promo/apply"?{ok:!0,end_date_text:"20.05.2026 12:00"}:e==="/payments"&&String(n.method||"").toUpperCase()==="POST"?{ok:!0,action:"open_link",payment_url:"https://example.com/payment-preview",payment_id:10001}:e.startsWith("/payments/")?{ok:!0,payment_id:10001,status:"pending",paid:!1}:{ok:!1,error:"not_found"}}function setToken(e,n=""){state.token=e,state.csrfToken=n||readCookie("rw_webapp_csrf")||state.csrfToken||""}function clearToken(){state.token="",state.csrfToken="",localStorage.removeItem("rw_webapp_token")}function readCookie(e){const n=e+"=",a=document.cookie.split("; ").find(i=>i.startsWith(n));return a?decodeURIComponent(a.slice(n.length)):""}function normalizeEmail(e){return String(e||"").trim().toLowerCase()}function emailErrorMessage(e,n){const a=e&&e.error;return a==="rate_limited"?t("email_rate_limited",{seconds:e.retry_after||60}):t(a==="invalid_email"?"email_invalid":a==="expired_code"?"email_code_expired":a==="invalid_code"||a==="too_many_attempts"?"email_code_invalid":a==="email_auth_not_configured"?"email_auth_disabled":a==="account_merge_conflict"?"account_merge_conflict":a==="invalid_auth"?"telegram_auth_failed":n)}function setButtonBusy(e,n){const a=document.getElementById(e);a&&(a.disabled=!!n)}function logout(){toggleUserMenu(!1),publicApi("/auth/logout",{}).catch(()=>{}),clearToken(),closePaymentFlow(),closeEmailLoginCodeModal(),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("login").classList.add("hidden"),document.getElementById("app").classList.remove("hidden")}function showLogin(){document.getElementById("loader").classList.add("hidden"),document.getElementById("app").classList.add("hidden"),document.getElementById("login").classList.remove("hidden"),document.getElementById("login").classList.add("show")}function setAuthStatus(e,n=!1){const a=document.getElementById("auth-status");if(a){if(!e){a.textContent="",a.classList.add("hidden"),a.classList.remove("error");return}a.textContent=e,a.classList.remove("hidden"),a.classList.toggle("error",!!n)}}function setEmailCodeStatus(e,n=!1){const a=document.getElementById("email-code-status");if(a){if(!e){a.textContent="",a.classList.add("hidden"),a.classList.remove("error");return}a.textContent=e,a.classList.remove("hidden"),a.classList.toggle("error",!!n)}}function setTelegramLinkStatus(e,n=!1){const a=document.getElementById("telegram-link-status");if(a){if(!e){a.textContent="",a.classList.add("hidden"),a.classList.remove("error");return}a.textContent=e,a.classList.remove("hidden"),a.classList.toggle("error",!!n)}}function showToast(e){const n=document.getElementById("toast");if(n){if(n.textContent=e,n.classList.remove("hidden"),n.offsetWidth,n.classList.add("show"),tg&&tg.HapticFeedback)try{tg.HapticFeedback.notificationOccurred("success")}catch(a){console.warn("Haptic feedback failed",a)}state.toastTimer&&window.clearTimeout(state.toastTimer),state.toastTimer=window.setTimeout(()=>{n.classList.remove("show"),window.setTimeout(()=>{n.classList.contains("show")||n.classList.add("hidden")},250)},2200)}}function setFavicon(e){let n=document.getElementById("app-favicon");n||(n=document.createElement("link"),n.id="app-favicon",n.rel="icon",n.setAttribute("sizes","any"),document.head.appendChild(n)),n.href=e||"data:,"}function setBrandLogo(e,n){if(!e)return;const a=e.querySelector("[data-brand-logo]"),i=e.querySelector("[data-brand-logo-spinner]");if(!a)return;if(a.onload=null,a.onerror=null,a.removeAttribute("data-brand-logo-url"),!n){a.removeAttribute("src"),a.classList.add("hidden"),i&&i.classList.add("hidden"),e.classList.add("hidden");return}const o=n;e.classList.remove("hidden"),i&&i.classList.remove("hidden"),a.classList.add("hidden"),a.dataset.brandLogoUrl=o,a.onload=()=>{a.dataset.brandLogoUrl===o&&(a.classList.remove("hidden"),i&&i.classList.add("hidden"))},a.onerror=()=>{a.dataset.brandLogoUrl===o&&(a.removeAttribute("src"),a.classList.add("hidden"),i&&i.classList.add("hidden"),e.classList.add("hidden"))},a.src=n,a.complete&&a.naturalWidth>0&&(a.classList.remove("hidden"),i&&i.classList.add("hidden"))}function setBrand(){const e=CFG.title||document.title||t("page_title"),n=CFG.logoUrl||"";document.title=e,setFavicon(n),document.querySelectorAll("[data-brand-title]").forEach(a=>{a.textContent=e}),document.querySelectorAll("[data-brand-logo-shell]").forEach(a=>{setBrandLogo(a,n)})}function applyI18n(e=document){document.documentElement.lang=getLanguage(),e.querySelectorAll("[data-i18n]").forEach(n=>{n.textContent=t(n.dataset.i18n)}),e.querySelectorAll("[data-title-i18n]").forEach(n=>{const a=t(n.dataset.titleI18n);n.setAttribute("title",a),n.setAttribute("aria-label",a)}),e.querySelectorAll("[data-aria-i18n]").forEach(n=>{n.setAttribute("aria-label",t(n.dataset.ariaI18n))}),e.querySelectorAll("[data-placeholder-i18n]").forEach(n=>{n.setAttribute("placeholder",t(n.dataset.placeholderI18n))})}function applyLegalLinks(e=document){const n={privacyPolicyUrl:CFG.privacyPolicyUrl||"",userAgreementUrl:CFG.userAgreementUrl||""};e.querySelectorAll("[data-legal-key]").forEach(a=>{const i=a.dataset.legalKey,o=n[i]||"";a.href=o||"#",a.classList.toggle("hidden",!o),a.setAttribute("aria-hidden",o?"false":"true")}),e.querySelectorAll("[data-legal-links]").forEach(a=>{const i=Array.from(a.querySelectorAll("[data-legal-key]")).some(o=>!o.classList.contains("hidden"));a.classList.toggle("hidden",!i)})}function getLanguage(){const e=[];state.langOverride&&e.push(state.langOverride),state.data&&state.data.user&&state.data.user.language_code&&e.push(state.data.user.language_code);const n=tg&&tg.initDataUnsafe&&tg.initDataUnsafe.user;n&&n.language_code&&e.push(n.language_code),typeof navigator!="undefined"&&(Array.isArray(navigator.languages)&&navigator.languages.forEach(a=>a&&e.push(a)),navigator.language&&e.push(navigator.language)),CFG.language&&e.push(CFG.language),document.documentElement.lang&&e.push(document.documentElement.lang);for(const a of e){const i=String(a).toLowerCase().split("-")[0];if(I18N&&I18N[i])return i}return"ru"}function setLanguage(e){const n=String(e||"").toLowerCase().split("-")[0];if(!(I18N&&Object.keys(I18N).length&&!I18N[n])){state.langOverride=n;try{localStorage.setItem("rw_webapp_lang",n)}catch(a){}if(applyI18n(),state.data)try{render()}catch(a){}renderLangMenu(),toggleLangMenu(!1)}}function t(e,n={}){const a=I18N&&I18N[getLanguage()]||I18N&&I18N.ru||{},i=I18N&&I18N.ru&&I18N.ru[e]||e;return(a[e]||i).replace(/\{(\w+)\}/g,(r,l)=>Object.prototype.hasOwnProperty.call(n,l)?String(n[l]):"")}function formatMoney(e,n){const a=Number(e||0);return(Number.isInteger(a)?String(a):a.toFixed(2))+" "+(n||CFG.currency||"RUB")}function escapeHtml(e){return String(e==null?"":e).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function escapeAttr(e){return escapeHtml(e).replaceAll("`","`")} diff --git a/bot/app/web/templates/subscription_webapp.min.bc7b9bbe.js b/bot/app/web/templates/subscription_webapp.min.bc7b9bbe.js deleted file mode 100644 index 8706607..0000000 --- a/bot/app/web/templates/subscription_webapp.min.bc7b9bbe.js +++ /dev/null @@ -1,79 +0,0 @@ -const MOCK=(()=>{const e=window.__WEBAPP_DEV_MOCK__,n=window.location.hostname,a=window.location.protocol==="file:"||n==="localhost"||n==="127.0.0.1"||n==="";return e&&a?e:null})();function readJsonScript(e){const n=document.getElementById(e);if(!n||!n.textContent)return null;try{return JSON.parse(n.textContent)}catch(a){return console.warn("Failed to parse JSON config from #"+e,a),null}}const CFG=readJsonScript("webapp-config")||MOCK&&MOCK.config||{},tg=window.Telegram&&window.Telegram.WebApp?window.Telegram.WebApp:null,TELEGRAM_LOGIN_WIDGET_URL="./telegram-widget.js",state={token:MOCK?"local-preview":localStorage.getItem("rw_webapp_token")||"",data:null,selectedPlan:null,referralParam:readReferralParam(),promoApplying:!1,payment:null,paymentFlowOpen:!1,promoModalOpen:!1,referralModalOpen:!1,accountMergeModalOpen:!1,accountMergeNotice:null,creatingPayment:!1,authInProgress:!1,authMode:CFG.emailAuthEnabled===!1?"telegram":"email",emailLoginPending:!1,emailLoginEmail:"",emailLoginCodeModalOpen:!1,emailLoginVerifying:!1,emailLoginResending:!1,emailLinkPending:!1,emailLinkEmail:"",promoStatusTimer:null,promoStatusType:"",promoStatusText:"",telegramLinkRendered:!1,telegramLinkInProgress:!1,toastTimer:null,userMenuOpen:!1,userMenuCloseTimer:null,langOverride:(function(){try{return localStorage.getItem("rw_webapp_lang")||""}catch(e){return""}})(),langMenuOpen:!1,langMenuCloseTimer:null},LANG_OPTIONS=[{code:"ru",label:"Русский",short:"RU",flag:"🇷🇺"},{code:"en",label:"English",short:"EN",flag:"🇬🇧"}],TW={iconBtn:"icon-btn",btnBase:"btn",btnIconOnly:"w-12 min-w-12 p-0",panelHead:"panel-head",flowCaption:"section-caption",sectionTitle:"section-title",metricLabel:"metric-label",metricValue:"metric-value",referralLinkRow:"referral-link-row",referralLinkValue:"referral-link-value",bonusRow:"bonus-row",empty:"empty-state",planCard:"plan-card",planCardActive:"plan-card-active",planName:"plan-name",planMeta:"plan-meta",planPrice:"plan-price",paymentMethodCard:"payment-method-card",paymentMethodCardPlatega:"payment-method-card--platega",paymentMethodCardCryptopay:"payment-method-card--cryptopay",notice:"notice",stepNum:"step-num",stepName:"step-name"},FALLBACK_I18N={ru:{page_title:"Моя подписка",loading:"Загрузка...",refresh:"Обновить",logout:"Выйти",subscription:"Подписка",subscription_subtitle:"Ваша ссылка подключения и срок доступа",ends_at:"Окончание",traffic:"Трафик",connect:"Подключиться",copy_link:"Скопировать ссылку",extend_subscription:"Купить подписку / Добавить дни",payment_title:"Оплата подписки",payment_caption:"Выберите период и нажмите способ оплаты. Сервис оплаты откроется сразу.",close:"Закрыть",close_payment:"Закрыть оплату",payment_steps:"Шаги оплаты",step_plan:"Срок",step_method:"Метод",step_result:"Платеж",select_period:"Выберите период",select_method:"Выберите способ оплаты",payment_status:"Статус платежа",payment_amount_label:"Стоимость выбранного периода",choose_payment_method:"Способ оплаты",back:"Назад",create_payment:"Создать платеж",open_payment:"Открыть оплату",check_payment:"Проверить оплату",choose_other_method:"Выбрать другой способ",pay_with_platega_button:"Оплатить картой (СБП)",pay_with_cryptopay_button:"Оплатить криптой",support:"Поддержка",account_title:"Аккаунт",account_caption:"Способы входа",email_label:"Email",telegram_label:"Telegram",linked:"Привязан",not_linked:"Не привязан",email_login_tab:"Email",telegram_login_tab:"Telegram",email_placeholder:"mail@example.com",code_placeholder:"000000",send_code:"Отправить код",resend_code:"Отправить еще раз",confirm:"Подтвердить",login:"Войти",login_title:"Войдите или зарегистрируйтесь",login_continue:"Продолжить",email_code_title:"Подтвердите вход",email_code_caption:"Введите 6-значный код из письма.",email_code_aria:"Код подтверждения",email_auth_disabled:"Вход по email пока не настроен.",email_required:"Введите email",email_invalid:"Введите корректный email",email_code_sending:"Отправляю код...",email_code_sent:"Код отправлен на почту",email_code_send_failed:"Не удалось отправить код",email_code_invalid:"Неверный код",email_code_expired:"Код устарел",email_rate_limited:"Повторная отправка доступна через {seconds} сек.",email_linked:"Email привязан",telegram_linked:"Telegram привязан",account_merge_toast:"Аккаунты объединены",account_merge_title:"Аккаунты объединены",account_merge_caption:"Оплаченные периоды сложились, и теперь у вас один аккаунт.",account_merge_body:"Мы объединили ваш Telegram и email в один профиль. Оплаченные периоды сложились, ссылка на подписку осталась прежней, а более поздний аккаунт был удалён автоматически.",account_merge_primary_account_label:"Оставлен аккаунт",account_merge_removed_account_label:"Удалён аккаунт",account_merge_end_date_label:"Новая дата окончания",account_merge_conflict:"Этот аккаунт уже связан с другими данными.",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:"Не активна",subscription_active:"Подписка активна",subscription_inactive:"Подписка не активна",sub_remaining_template:"Действует еще {value}",sub_ends_template:"Закончится {date}",sub_ended_template:"Закончилась {date}",sub_no_expiry:"Бессрочно",traffic_unlimited:"∞ бесконечный трафик",link_panel_title:"Привяжите способ входа",link_panel_caption:"Чтобы не потерять доступ к кабинету",link_email_title:"Привяжите email",link_email_caption:"Получайте код для входа на почту",link_telegram_title:"Привяжите Telegram",link_telegram_caption:"Входите одним нажатием через Telegram",guest_user:"Гость",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:"Пользовательское соглашение",promo_title:"Промокод",promo_caption:"Введите код, чтобы начислить бонусные дни.",promo_placeholder:"PROMO2026",apply_promo:"Применить",promo_applied:"Промокод применен",promo_applied_ok:"Промокод применен, всё ок.",promo_empty:"Введите промокод",promo_invalid:"Промокод не найден или недействителен.",referral_title:"Пригласить друга",referral_caption:"Поделитесь ссылкой и получайте бонусы.",referral_code:"Ваш код",invited_friends:"Приглашено",purchased_friends:"Оплатили",copy_code:"Скопировать код",copy_webapp_link:"Скопировать ссылку Web App",copy_bot_link:"Скопировать ссылку бота",referral_copied:"Ссылка скопирована",code_copied:"Код скопирован",referral_site_label:"Пригласить через сайт",referral_telegram_label:"Пригласить через телеграм",referral_bonus_title:"Бонусы",referral_bonus_pair:"Вы: {inviter} дн. / друг: {friend} дн.",referral_no_bonuses:"Бонусы пока не настроены.",referral_bonus_explanation:"Бонусы начисляются один раз за каждого приглашенного пользователя, когда приглашенный пользователь покупает подписку."},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 and tap a payment method. The payment service will open right away.",close:"Close",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",payment_amount_label:"Selected period cost",choose_payment_method:"Payment method",back:"Back",create_payment:"Create payment",open_payment:"Open payment",check_payment:"Check payment",choose_other_method:"Choose another method",pay_with_platega_button:"Pay by card (SBP)",pay_with_cryptopay_button:"Pay with crypto",support:"Support",account_title:"Account",account_caption:"Sign-in methods",email_label:"Email",telegram_label:"Telegram",linked:"Linked",not_linked:"Not linked",email_login_tab:"Email",telegram_login_tab:"Telegram",email_placeholder:"mail@example.com",code_placeholder:"000000",send_code:"Send code",resend_code:"Send again",confirm:"Confirm",login:"Log in",login_title:"Log in or sign up",login_continue:"Continue",email_code_title:"Confirm login",email_code_caption:"Enter the 6-digit code from the email.",email_code_aria:"Verification code",email_auth_disabled:"Email sign-in is not configured yet.",email_required:"Enter your email",email_invalid:"Enter a valid email address",email_code_sending:"Sending code...",email_code_sent:"Code sent to email",email_code_send_failed:"Could not send code",email_code_invalid:"Invalid code",email_code_expired:"Code expired",email_rate_limited:"Try again in {seconds} sec.",email_linked:"Email linked",telegram_linked:"Telegram linked",account_merge_toast:"Accounts merged",account_merge_title:"Accounts merged",account_merge_caption:"Your paid periods were combined and you now have one account.",account_merge_body:"We merged your Telegram and email accounts into one profile. Your paid periods were combined, your subscription link stayed the same, and the later account was removed automatically.",account_merge_primary_account_label:"Kept account",account_merge_removed_account_label:"Removed account",account_merge_end_date_label:"New end date",account_merge_conflict:"This account is already linked to different data.",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",subscription_active:"Subscription is active",subscription_inactive:"Subscription is inactive",sub_remaining_template:"Valid for {value} more",sub_ends_template:"Expires {date}",sub_ended_template:"Expired {date}",sub_no_expiry:"No expiration",traffic_unlimited:"∞ unlimited traffic",link_panel_title:"Link a sign-in method",link_panel_caption:"So you never lose access",link_email_title:"Link email",link_email_caption:"Receive sign-in codes by email",link_telegram_title:"Link Telegram",link_telegram_caption:"Sign in with one tap via Telegram",guest_user:"Guest",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",promo_title:"Promo code",promo_caption:"Enter a code to add bonus days.",promo_placeholder:"PROMO2026",apply_promo:"Apply",promo_applied:"Promo code applied",promo_applied_ok:"Promo code applied, all good.",promo_empty:"Enter promo code",promo_invalid:"Promo code was not found or is not valid.",referral_title:"Invite friend",referral_caption:"Share your link and receive bonuses.",referral_code:"Your code",invited_friends:"Invited",purchased_friends:"Purchased",copy_code:"Copy code",copy_webapp_link:"Copy Web App link",copy_bot_link:"Copy bot link",referral_copied:"Link copied",code_copied:"Code copied",referral_site_label:"Invite via site",referral_telegram_label:"Invite via Telegram",referral_bonus_title:"Bonuses",referral_bonus_pair:"You: {inviter} d. / friend: {friend} d.",referral_no_bonuses:"Bonuses are not configured yet.",referral_bonus_explanation:"Bonuses are awarded once for each invited user when they purchase a subscription."}},I18N=readJsonScript("i18n")||MOCK&&MOCK.i18n||{},accent=CFG.primaryColor||"#00fe7a";if(document.documentElement.style.setProperty("--accent",accent),setBrand(),applyI18n(),applyLegalLinks(),CFG.supportUrl){const e=document.getElementById("support-link");e.href=CFG.supportUrl,e.classList.remove("hidden")}if(tg)try{tg.ready(),tg.expand(),typeof tg.isVersionAtLeast=="function"&&tg.isVersionAtLeast("6.1")&&(tg.setHeaderColor("#05070a"),tg.setBackgroundColor("#05070a"))}catch(e){}bindEmailLoginInput(),bindEmailCodeInput(),bindPromoInput(),document.addEventListener("keydown",e=>{handleDocumentKeyForUserMenu(e),e.key==="Escape"&&(state.emailLoginCodeModalOpen?closeEmailLoginCodeModal():state.accountMergeModalOpen?closeAccountMergeModal():state.promoModalOpen?closePromoModal():state.referralModalOpen?closeReferralModal():state.paymentFlowOpen&&closePaymentFlow())}),document.addEventListener("click",handleDocumentActionClick),document.addEventListener("click",handleDocumentClickForUserMenu),document.addEventListener("click",handleDocumentClickForLangMenu),renderLangMenu(),boot();async function boot(){if(showLoader(),MOCK){await loadData();return}const e=readTelegramLoginWidgetAuthData();if(e){if(await finalizeTelegramAuth(e))return;clearToken(),startExternalAuth({resetStatus:!1});return}if(tg&&tg.initData)try{if(await finalizeTelegramAuth(tg.initData,"init_data"))return}catch(n){}if(state.token)try{await loadData();return}catch(n){clearToken()}await startExternalAuth()}function readTelegramLoginWidgetAuthData(){const e=new URLSearchParams(window.location.search),n=["id","first_name","last_name","username","photo_url","auth_date","hash"],a={};let i=!1;return n.forEach(o=>{e.has(o)&&(a[o]=e.get(o)||"",i=!0)}),!i||!a.id||!a.auth_date||!a.hash?null:a}function readReferralParam(){const e=new URLSearchParams(window.location.search),n=e.get("ref")||e.get("start")||e.get("start_param")||"",a=tg&&tg.initDataUnsafe&&tg.initDataUnsafe.start_param?tg.initDataUnsafe.start_param:"",i=String(a||n||"").trim();return i?(localStorage.setItem("rw_webapp_referral",i),i):localStorage.getItem("rw_webapp_referral")||""}function clearTelegramLoginWidgetQuery(){const e=new URL(window.location.href);["id","first_name","last_name","username","photo_url","auth_date","hash"].forEach(a=>e.searchParams.delete(a)),window.history&&window.history.replaceState&&window.history.replaceState({},document.title,e.pathname+e.search+e.hash)}async function finalizeTelegramAuth(e,n="auth_data"){if(state.authInProgress)return!1;state.authInProgress=!0,setAuthStatus(t("telegram_auth_verifying"));try{const a=n==="init_data"?{init_data:e}:{auth_data:e};state.referralParam&&(a.referral_code=state.referralParam);const i=await fetch(CFG.apiBase+"/auth/token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),o=await i.json();if(i.ok&&o.ok&&o.token){setToken(o.token),clearTelegramLoginWidgetQuery();try{return setAuthStatus(""),await loadData(),!0}catch(l){return clearToken(),setAuthStatus(t("telegram_auth_failed"),!0),!1}}const r=o.error==="banned"||o.error==="access_denied"?"telegram_auth_access_denied":"telegram_auth_failed";setAuthStatus(t(r),!0)}catch(a){setAuthStatus(t("telegram_auth_failed"),!0)}finally{state.authInProgress=!1}return clearTelegramLoginWidgetQuery(),!1}async function handleTelegramLoginWidgetAuth(e){if(!e){setAuthStatus(t("telegram_auth_failed"),!0);return}await finalizeTelegramAuth(e,"auth_data")}function setAuthMode(e){state.authMode=e==="telegram"?"telegram":"email",renderAuthMode()}function renderAuthMode(){const e=document.getElementById("email-auth-tab"),n=document.getElementById("telegram-auth-tab"),a=document.getElementById("email-login-pane"),i=document.getElementById("telegram-login-pane"),o=document.querySelector(".login-auth-body"),r=CFG.emailAuthEnabled!==!1;!r&&state.authMode==="email"&&(state.authMode="telegram"),e.classList.toggle("active",state.authMode==="email"),n.classList.toggle("active",state.authMode==="telegram"),a.classList.toggle("hidden",state.authMode!=="email"),i.classList.toggle("hidden",state.authMode!=="telegram"),o&&o.classList.toggle("login-auth-body--telegram",state.authMode==="telegram"),e.disabled=!r,state.authMode==="telegram"?renderTelegramLoginWidget():r||setAuthStatus(t("email_auth_disabled"),!0)}function renderTelegramLoginWidget(){const e=document.getElementById("telegram-login-widget");if(!e)return;e.innerHTML="";const n=String(CFG.telegramLoginBotUsername||"").trim();if(!n){setAuthStatus(t("telegram_auth_unavailable"),!0);return}typeof window.onTelegramAuth!="function"&&(window.onTelegramAuth=async function(i){await handleTelegramLoginWidgetAuth(i)});const a=document.createElement("script");a.async=!0,a.src=TELEGRAM_LOGIN_WIDGET_URL,a.setAttribute("data-telegram-login",n),a.setAttribute("data-size","large"),a.setAttribute("data-userpic","true"),a.setAttribute("data-request-access","write"),a.setAttribute("data-onauth","onTelegramAuth(user)"),a.onerror=()=>setAuthStatus(t("telegram_auth_unavailable"),!0),e.appendChild(a)}function bindEmailLoginInput(){const e=document.getElementById("email-login-input");e&&(e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),requestEmailLoginCode())}),e.addEventListener("input",()=>{e.getAttribute("aria-invalid")==="true"&&(e.removeAttribute("aria-invalid"),setAuthStatus(""))}))}function bindEmailCodeInput(){const e=document.getElementById("email-login-code-input");e&&(e.addEventListener("input",()=>{e.value=sanitizeCode(e.value),updateEmailLoginCodeSlots()}),e.addEventListener("focus",updateEmailLoginCodeSlots),e.addEventListener("blur",updateEmailLoginCodeSlots),e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),sanitizeCode(e.value).length===6&&!state.emailLoginVerifying&&verifyEmailLoginCode())}))}function sanitizeCode(e){return String(e||"").replace(/\D/g,"").slice(0,6)}function isValidEmail(e){const n=normalizeEmail(e);return!!(n&&n.length<=254&&/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))}function readValidEmailLoginInput(){const e=document.getElementById("email-login-input"),n=normalizeEmail(e&&e.value);return!n||!isValidEmail(n)?(e&&(e.setAttribute("aria-invalid","true"),e.focus()),setAuthStatus(t(n?"email_invalid":"email_required"),!0),""):(e&&(e.value=n,e.removeAttribute("aria-invalid")),n)}function openEmailLoginCodeModal(e,n,a=!1){state.emailLoginEmail=e,state.emailLoginCodeModalOpen=!0;const i=document.getElementById("email-login-code-input");i&&(i.value=""),renderEmailLoginCodeModal(),setEmailCodeStatus(n||t("email_code_sent"),a),window.setTimeout(()=>{const o=document.getElementById("email-login-code-input");state.emailLoginCodeModalOpen&&o&&o.focus()},80)}function closeEmailLoginCodeModal(){state.emailLoginCodeModalOpen=!1;const e=document.getElementById("email-login-code-input");e&&(e.value=""),setEmailCodeStatus(""),renderEmailLoginCodeModal()}function renderEmailLoginCodeModal(){const e=document.getElementById("email-code-modal");if(!e)return;if(!state.emailLoginCodeModalOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.emailLoginCodeModalOpen||e.classList.add("hidden")},180);return}const n=document.getElementById("email-code-address");n&&(n.textContent=state.emailLoginEmail||"..."),e.classList.remove("hidden"),syncModalLock(),applyI18n(e),updateEmailLoginCodeSlots(),window.requestAnimationFrame(()=>e.classList.add("show"))}function updateEmailLoginCodeSlots(){const e=document.getElementById("email-login-code-input"),n=sanitizeCode(e&&e.value);e&&e.value!==n&&(e.value=n);for(let o=0;o<6;o+=1){const r=document.getElementById("email-code-slot-"+o);if(!r)continue;const l=n[o]||"";r.textContent=l,r.classList.toggle("filled",!!l),r.classList.toggle("active",state.emailLoginCodeModalOpen&&document.activeElement===e&&o===Math.min(n.length,5))}const a=document.getElementById("email-login-verify-btn");a&&(a.disabled=n.length!==6||state.emailLoginVerifying||state.emailLoginPending);const i=document.getElementById("email-login-resend-btn");i&&(i.disabled=state.emailLoginPending||state.emailLoginResending)}async function requestEmailLoginCode(){if(state.emailLoginPending)return;if(CFG.emailAuthEnabled===!1){setAuthStatus(t("email_auth_disabled"),!0);return}const e=readValidEmailLoginInput();if(e){state.emailLoginPending=!0,setButtonBusy("email-login-send-btn",!0),openEmailLoginCodeModal(e,t("email_code_sending")),setAuthStatus("");try{const n=await publicApi("/auth/email/request",{email:e,language:getLanguage()});if(!n.ok)throw n;setEmailCodeStatus(t("email_code_sent"))}catch(n){const a=emailErrorMessage(n,"email_code_send_failed");if(n&&n.error==="invalid_email"){closeEmailLoginCodeModal();const i=document.getElementById("email-login-input");i&&i.setAttribute("aria-invalid","true"),setAuthStatus(a,!0)}else setEmailCodeStatus(a,!0)}finally{state.emailLoginPending=!1,setButtonBusy("email-login-send-btn",!1),updateEmailLoginCodeSlots()}}}async function resendEmailLoginCode(){if(state.emailLoginPending||state.emailLoginResending)return;const e=state.emailLoginEmail||normalizeEmail(document.getElementById("email-login-input").value);if(!e||!isValidEmail(e)){setEmailCodeStatus(t(e?"email_invalid":"email_required"),!0);return}state.emailLoginResending=!0,updateEmailLoginCodeSlots(),setEmailCodeStatus(t("email_code_sending"));try{const n=await publicApi("/auth/email/request",{email:e,language:getLanguage()});if(!n.ok)throw n;state.emailLoginEmail=e;const a=document.getElementById("email-login-code-input");a&&(a.value=""),setEmailCodeStatus(t("email_code_sent"))}catch(n){setEmailCodeStatus(emailErrorMessage(n,"email_code_send_failed"),!0)}finally{state.emailLoginResending=!1,updateEmailLoginCodeSlots()}}async function verifyEmailLoginCode(){const e=state.emailLoginEmail||normalizeEmail(document.getElementById("email-login-input").value),n=document.getElementById("email-login-code-input"),a=sanitizeCode(n&&n.value);if(!e||a.length!==6){setEmailCodeStatus(t("email_code_invalid"),!0),updateEmailLoginCodeSlots();return}state.emailLoginVerifying=!0,updateEmailLoginCodeSlots(),setEmailCodeStatus(t("telegram_auth_verifying"));try{const i=await publicApi("/auth/email/verify",{email:e,code:a,referral_code:state.referralParam||""});if(!i.ok||!i.token)throw i;setToken(i.token),closeEmailLoginCodeModal(),await loadData()}catch(i){clearToken(),setEmailCodeStatus(emailErrorMessage(i,"email_code_invalid"),!0)}finally{state.emailLoginVerifying=!1,updateEmailLoginCodeSlots()}}function startExternalAuth(e={}){const n=e.resetStatus!==!1;showLogin(),n&&setAuthStatus(""),renderAuthMode()}async function loadData(){const e=await api("/me");if(!e.ok)throw new Error(e.error||"load failed");state.data=e,setBrand(),applyI18n(),applyLegalLinks();const n=e.plans||[];state.selectedPlan=getDefaultPaymentPlan(n),state.payment=null,state.creatingPayment=!1,state.telegramLinkRendered=!1,render(),showApp()}function render(){renderSubscription(state.data.subscription),renderUserMenu(state.data.user||{}),renderAccount(state.data.user||{}),renderReferral(state.data.referral||{}),renderAccountMergeModal(),renderPaymentFlow()}function renderSubscription(e){const n=document.getElementById("sub-status"),a=document.getElementById("sub-status-text"),i=document.getElementById("remaining"),o=document.getElementById("end-date-caption");if(n.classList.toggle("active",!!e.active),a.textContent=e.active?t("subscription_active"):t("subscription_inactive"),e.active){const r=formatCompactRemaining(e.days_left,getLanguage())||e.remaining_text||"";i.textContent=r}else i.textContent=t("no_active_subscription");if(e.end_date_text){const r=e.active?"sub_ends_template":"sub_ended_template";o.textContent=t(r,{date:e.end_date_text}),o.classList.remove("hidden")}else e.active?(o.textContent=t("sub_no_expiry"),o.classList.remove("hidden")):(o.textContent="",o.classList.add("hidden"));renderTraffic(e),document.getElementById("connect-actions").classList.toggle("hidden",!e.connect_url&&!e.config_link)}function renderTraffic(e){const n=document.getElementById("traffic-bar"),a=document.getElementById("traffic-bar-fill"),i=document.getElementById("traffic");if(!n||!a||!i)return;const o=Number(e.traffic_limit_bytes),r=Number(e.traffic_used_bytes),l=Number.isFinite(o)&&o>0,s=Number.isFinite(r)&&r>=0,d=e.traffic_used||t("not_available"),c=e.traffic_limit||t("not_available");if(n.classList.remove("over","unlimited"),!l){n.classList.add("unlimited"),a.style.width="100%",i.textContent=t("traffic_unlimited"),n.setAttribute("aria-valuenow","0");return}const m=s?r/o:0,u=Math.max(0,Math.min(100,Math.round(m*100)));a.style.width=u+"%",i.textContent=d+" / "+c,n.setAttribute("aria-valuenow",String(u)),s&&r>=o&&n.classList.add("over")}function formatCompactRemaining(e,n){const a=Math.max(0,Math.floor(Number(e)||0));if(a<=0)return null;if(a>=365){const i=Math.round(a/365*2)/2;return formatDecimal(i,n)+" "+pluralizeUnit(i,"year",n)}if(a>=30){const i=Math.round(a/30*2)/2;return formatDecimal(i,n)+" "+pluralizeUnit(i,"month",n)}return formatDecimal(a,n)+" "+pluralizeUnit(a,"day",n)}function formatDecimal(e,n){if(Number.isInteger(e))return String(e);const a=e.toFixed(1);return n==="ru"?a.replace(".",","):a}function pluralizeUnit(e,n,a){const i={year:["year","years"],month:["month","months"],day:["day","days"]},o={year:["год","года","лет"],month:["месяц","месяца","месяцев"],day:["день","дня","дней"]};if(a==="en")return e===1?i[n][0]:i[n][1];const r=o[n];if(!Number.isInteger(e))return r[1];const l=Math.abs(e),s=l%10,d=l%100;return s===1&&d!==11?r[0]:s>=2&&s<=4&&(d<12||d>14)?r[1]:r[2]}function renderUserMenu(e){const n=!!(e.email&&e.email_verified),a=!!e.telegram_linked,i=getUserDisplayName(e),o=getUserSecondaryText(e,n,a),r=getUserAvatarSrc(e),l=buildIdenticon(getUserAvatarSeed(e,n)),s=document.getElementById("user-chip-name"),d=document.getElementById("user-chip-avatar"),c=document.getElementById("user-dropdown-avatar"),m=document.getElementById("user-dropdown-name"),u=document.getElementById("user-dropdown-sub"),p=document.getElementById("user-dropdown-email-status"),g=document.getElementById("user-dropdown-telegram-status");s.textContent=i,setAvatarWithFallback(d,r,l,i),setAvatarWithFallback(c,r,l,i),m.textContent=i,u.textContent=o,u.classList.toggle("hidden",!o),p.textContent=n?e.email:t("not_linked"),p.classList.toggle("linked",n),p.classList.toggle("unlinked",!n),g.textContent=a?e.telegram_id?String(e.telegram_id):t("linked"):t("not_linked"),g.classList.toggle("linked",a),g.classList.toggle("unlinked",!a)}function renderAccount(e){const n=!!(e.email&&e.email_verified),a=!!e.telegram_linked,i=!!(state.data.settings&&state.data.settings.email_auth_enabled),o=!n&&i,r=!a,l=document.getElementById("email-link-box"),s=document.getElementById("telegram-link-box"),d=document.getElementById("link-panel");l.classList.toggle("hidden",!o),s.classList.toggle("hidden",!r),d.classList.toggle("hidden",!o&&!r),a?state.telegramLinkRendered=!1:renderTelegramLinkWidget()}function getTelegramInitUser(){return!tg||!tg.initDataUnsafe||!tg.initDataUnsafe.user?null:tg.initDataUnsafe.user}function getUserAvatarSeed(e,n){const a=getTelegramInitUser();return n&&e&&e.email?e.email.trim().toLowerCase():String(e&&e.id||e&&e.telegram_id||a&&a.id||"guest")}function getUserAvatarSrc(e){const n=getTelegramInitUser();return n&&n.photo_url?n.photo_url:e&&e.telegram_photo_url?e.telegram_photo_url:""}function getUserDisplayName(e){const n=getTelegramInitUser();return n&&n.first_name?n.first_name:e&&e.first_name?e.first_name:e&&e.username?"@"+e.username:n&&n.username?"@"+n.username:e&&e.email?e.email:t("guest_user")}function getUserSecondaryText(e,n,a){return n&&e.email?e.email:a&&e.telegram_id?"#"+e.telegram_id:""}function setAvatarWithFallback(e,n,a,i){if(e){if(e.alt=i||"",!n){e.onerror=null,e.src=a;return}e.onerror=()=>{e.onerror=null,e.src=a},e.src=n}}function buildIdenticon(e){const n=String(e||"guest");let a=2166136261;for(let c=0;c>>0;const i=a%360,o="hsl("+i+", 60%, 18%)",r="hsl("+(i+180)%360+", 78%, 62%)";let l="",s=a;for(let c=0;c<5;c++)for(let m=0;m<3;m++){const u=s&1;s=s>>>1^(s&1?46080:0),u&&(l+='',m!==2&&(l+=''))}const d=''+l+"";return"data:image/svg+xml;utf8,"+encodeURIComponent(d)}function toggleUserMenu(e){const n=typeof e=="boolean"?e:!state.userMenuOpen;state.userMenuOpen=n;const a=document.getElementById("user-chip"),i=document.getElementById("user-dropdown");if(!(!a||!i)){if(state.userMenuCloseTimer&&(window.clearTimeout(state.userMenuCloseTimer),state.userMenuCloseTimer=null),a.setAttribute("aria-expanded",n?"true":"false"),n){i.classList.remove("hidden"),i.classList.remove("show"),window.requestAnimationFrame(()=>{state.userMenuOpen&&i.classList.add("show")});return}i.classList.remove("show"),state.userMenuCloseTimer=window.setTimeout(()=>{state.userMenuOpen||i.classList.add("hidden"),state.userMenuCloseTimer=null},180)}}function handleDocumentClickForUserMenu(e){if(!state.userMenuOpen)return;const n=document.querySelector(".user-menu");n&&n.contains(e.target)||toggleUserMenu(!1)}function handleDocumentKeyForUserMenu(e){e.key==="Escape"&&state.userMenuOpen&&toggleUserMenu(!1),e.key==="Escape"&&state.langMenuOpen&&toggleLangMenu(!1)}function renderLangMenu(){const e=getLanguage(),n=document.getElementById("lang-chip-label"),a=document.getElementById("lang-chip-flag"),i=LANG_OPTIONS.find(r=>r.code===e)||LANG_OPTIONS[0];n&&(n.textContent=i.short),a&&(a.textContent=i.flag);const o=document.getElementById("lang-dropdown");o&&(o.innerHTML=LANG_OPTIONS.map(r=>'").join(""),o.querySelectorAll("[data-lang]").forEach(r=>{r.addEventListener("click",()=>setLanguage(r.dataset.lang))}))}function toggleLangMenu(e){const n=typeof e=="boolean"?e:!state.langMenuOpen;state.langMenuOpen=n;const a=document.getElementById("lang-chip"),i=document.getElementById("lang-dropdown");if(!(!a||!i)){if(state.langMenuCloseTimer&&(window.clearTimeout(state.langMenuCloseTimer),state.langMenuCloseTimer=null),a.setAttribute("aria-expanded",n?"true":"false"),n){i.classList.remove("hidden"),i.classList.remove("show"),window.requestAnimationFrame(()=>{state.langMenuOpen&&i.classList.add("show")});return}i.classList.remove("show"),state.langMenuCloseTimer=window.setTimeout(()=>{state.langMenuOpen||i.classList.add("hidden"),state.langMenuCloseTimer=null},180)}}function handleDocumentClickForLangMenu(e){if(!state.langMenuOpen)return;const n=document.querySelector(".lang-menu");n&&n.contains(e.target)||toggleLangMenu(!1)}function handleDocumentActionClick(e){const n=e.target instanceof Element?e.target.closest("[data-action]"):null;if(!n||!document.contains(n)||n.disabled)return;const a=String(n.dataset.action||"");if(a){switch(a){case"toggle-user-menu":toggleUserMenu();break;case"toggle-lang-menu":toggleLangMenu();break;case"logout":logout();break;case"open-connect-link":openConnectLink();break;case"toggle-payment-flow":togglePaymentFlow();break;case"open-referral-modal":openReferralModal();break;case"open-promo-modal":openPromoModal();break;case"close-payment-flow":closePaymentFlow();break;case"close-promo-modal":closePromoModal();break;case"close-referral-modal":closeReferralModal();break;case"close-account-merge-modal":closeAccountMergeModal();break;case"close-email-login-code-modal":closeEmailLoginCodeModal();break;case"request-email-link-code":requestEmailLinkCode();break;case"verify-email-link-code":verifyEmailLinkCode();break;case"request-email-login-code":requestEmailLoginCode();break;case"verify-email-login-code":verifyEmailLoginCode();break;case"resend-email-login-code":resendEmailLoginCode();break;case"set-auth-mode":setAuthMode(n.dataset.mode||"email");break;case"apply-promo-code":applyPromoCode();break;case"select-plan":selectPlan(n.dataset.months||"");break;case"create-payment":createAndOpenPayment(n.dataset.method||"");break;case"copy-referral-link":copyReferralLink(n.dataset.kind||"webapp");break;case"copy-config-link":copyConfigLink();break;case"check-payment":checkPayment();break;default:return}e.preventDefault()}}function openPromoModal(){state.promoModalOpen=!0,clearPromoStatus(),renderPromoModal(),window.setTimeout(()=>{const e=document.getElementById("promo-code-input");e&&e.focus()},80)}function closePromoModal(){state.promoModalOpen=!1,clearPromoStatus(),renderPromoModal()}function renderPromoModal(){const e=document.getElementById("promo-modal");if(e){if(!state.promoModalOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.promoModalOpen||e.classList.add("hidden")},180);return}e.classList.remove("hidden"),syncModalLock(),renderPromoStatus(),applyI18n(e),window.requestAnimationFrame(()=>e.classList.add("show"))}}function bindPromoInput(){const e=document.getElementById("promo-code-input");e&&(e.addEventListener("input",()=>{e.getAttribute("aria-invalid")==="true"&&e.removeAttribute("aria-invalid"),state.promoStatusText&&clearPromoStatus()}),e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),applyPromoCode())}))}function setPromoStatus(e,n="error"){state.promoStatusType=n==="success"?"success":"error",state.promoStatusText=normalizeStatusText(e||""),renderPromoStatus()}function clearPromoStatus(){state.promoStatusType="",state.promoStatusText="",renderPromoStatus()}function renderPromoStatus(){const e=document.getElementById("promo-status");if(e){if(!state.promoStatusText){e.textContent="",e.classList.add("hidden"),e.classList.remove("error","success");return}e.textContent=state.promoStatusText,e.classList.remove("hidden"),e.classList.toggle("error",state.promoStatusType==="error"),e.classList.toggle("success",state.promoStatusType==="success")}}function normalizeStatusText(e){const n=String(e||""),a=document.createElement("div");return a.innerHTML=n,(a.textContent||a.innerText||"").trim()}function openReferralModal(){state.referralModalOpen=!0,renderReferral(state.data&&state.data.referral||{})}function closeReferralModal(){state.referralModalOpen=!1,renderReferral(state.data&&state.data.referral||{})}function closeToolModals(){state.promoModalOpen=!1,state.referralModalOpen=!1,state.accountMergeModalOpen=!1,clearPromoStatus(),renderPromoModal(),renderReferral(state.data&&state.data.referral||{}),renderAccountMergeModal()}function openAccountMergeModal(e=null){e&&(state.accountMergeNotice=e),state.accountMergeModalOpen=!0,renderAccountMergeModal()}function closeAccountMergeModal(){state.accountMergeModalOpen=!1,renderAccountMergeModal()}function renderAccountMergeModal(){const e=document.getElementById("account-merge-modal"),n=document.getElementById("account-merge-panel");if(!e||!n)return;if(!state.accountMergeModalOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.accountMergeModalOpen||e.classList.add("hidden")},180);return}e.classList.remove("hidden"),syncModalLock();const a=state.accountMergeNotice||{},i=a.primary_user_id!=null?String(a.primary_user_id):t("not_available"),o=a.removed_user_id!=null?String(a.removed_user_id):t("not_available"),r=a.final_end_date_text||t("not_available");n.innerHTML=` -
-
-
${escapeHtml(t("account_merge_title"))}
-
${escapeHtml(t("account_merge_caption"))}
-
- -
- -
-
${escapeHtml(t("account_merge_body"))}
-
- -
-
-
${escapeHtml(t("account_merge_primary_account_label"))}
-
#${escapeHtml(i)}
-
-
-
${escapeHtml(t("account_merge_removed_account_label"))}
-
#${escapeHtml(o)}
-
-
-
${escapeHtml(t("account_merge_end_date_label"))}
-
${escapeHtml(r)}
-
-
- `,applyI18n(e),window.requestAnimationFrame(()=>e.classList.add("show"))}function renderReferral(e){const n=document.getElementById("referral-modal"),a=document.getElementById("referral-panel");if(!n||!a)return;if(!state.referralModalOpen){n.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.referralModalOpen||n.classList.add("hidden")},180);return}if(n.classList.remove("hidden"),syncModalLock(),!state.referralModalOpen)return;const i=(e.bonus_details||[]).map(o=>` -
- ${escapeHtml(o.title||"")} - ${escapeHtml(t("referral_bonus_pair",{inviter:o.inviter_days||0,friend:o.friend_days||0}))} -
- `).join("");a.innerHTML=` -
-
-
${escapeHtml(t("referral_title"))}
-
${escapeHtml(t("referral_caption"))}
-
- -
- -
-
- - -
- -
-
- -
${escapeHtml(t("referral_bonus_explanation"))}
-
-
${i||'
'+escapeHtml(t("referral_no_bonuses"))+"
"}
-
-
- `,applyI18n(n),window.requestAnimationFrame(()=>n.classList.add("show"))}function renderReferralLinkRow(e,n,a){return` -
-
-
${escapeHtml(n)}
-
${escapeHtml(a||t("not_available"))}
-
- -
- `}async function applyPromoCode(){if(state.promoApplying)return;const e=document.getElementById("promo-code-input"),n=String(e&&e.value||"").trim();if(!n){setPromoStatus(t("promo_empty"),"error"),e&&e.setAttribute("aria-invalid","true"),e&&e.focus();return}state.promoApplying=!0,setButtonBusy("promo-apply-btn",!0),clearPromoStatus(),e&&e.removeAttribute("aria-invalid");try{const a=await api("/promo/apply",{method:"POST",body:JSON.stringify({code:n})});if(!a.ok)throw a;e&&(e.value=""),setPromoStatus(t("promo_applied_ok"),"success"),await loadData()}catch(a){const i=a&&a.error,o=a&&(a.message||a.detail);e&&e.setAttribute("aria-invalid","true"),setPromoStatus(i==="empty_code"?t("promo_empty"):o||t(i==="promo_apply_failed"?"promo_invalid":"payment_error"),"error")}finally{state.promoApplying=!1,setButtonBusy("promo-apply-btn",!1)}}async function copyReferralLink(e){const n=state.data&&state.data.referral,a=n&&(e==="bot"?n.bot_link:n.webapp_link);if(!a){showToast(t("no_link"));return}await copyText(a),showToast(t("referral_copied"))}async function copyReferralCode(){const e=state.data&&state.data.referral,n=e&&e.code;if(!n){showToast(t("no_link"));return}await copyText(n),showToast(t("code_copied"))}async function requestEmailLinkCode(){const e=document.getElementById("email-link-input"),n=normalizeEmail(e.value);if(!n){showToast(t("email_code_send_failed"));return}state.emailLinkPending=!0,setButtonBusy("email-link-send-btn",!0);try{const a=await api("/account/email/request",{method:"POST",body:JSON.stringify({email:n})});if(!a.ok)throw a;if(a.already_linked){showToast(t("email_linked")),await loadData();return}state.emailLinkEmail=n,document.getElementById("email-link-code-row").classList.remove("hidden"),showToast(t("email_code_sent"))}catch(a){showToast(emailErrorMessage(a,"email_code_send_failed"))}finally{state.emailLinkPending=!1,setButtonBusy("email-link-send-btn",!1)}}async function verifyEmailLinkCode(){const e=state.emailLinkEmail||normalizeEmail(document.getElementById("email-link-input").value),n=document.getElementById("email-link-code-input").value;setButtonBusy("email-link-verify-btn",!0);let a=null;try{const i=await api("/account/email/verify",{method:"POST",body:JSON.stringify({email:e,code:n})});if(!i.ok)throw i;i.token&&setToken(i.token),a=i.account_merge&&i.account_merge.merged?i.account_merge:null,state.accountMergeNotice=a,showToast(t(a?"account_merge_toast":"email_linked")),state.emailLinkEmail="";try{await loadData(),a&&openAccountMergeModal(a)}catch(o){console.warn("Email account linked, but data refresh failed",o),a&&openAccountMergeModal(a)}}catch(i){showToast(emailErrorMessage(i,"email_code_invalid"))}finally{setButtonBusy("email-link-verify-btn",!1)}}function renderTelegramLinkWidget(){const e=document.getElementById("telegram-link-widget");if(!e||state.telegramLinkRendered)return;e.innerHTML="";const n=String(CFG.telegramLoginBotUsername||"").trim();if(!n){setTelegramLinkStatus(t("telegram_auth_unavailable"),!0);return}window.onTelegramLinkAuth=async function(i){await linkTelegramAccount(i)};const a=document.createElement("script");a.async=!0,a.src=TELEGRAM_LOGIN_WIDGET_URL,a.setAttribute("data-telegram-login",n),a.setAttribute("data-size","large"),a.setAttribute("data-userpic","true"),a.setAttribute("data-request-access","write"),a.setAttribute("data-onauth","onTelegramLinkAuth(user)"),a.onerror=()=>setTelegramLinkStatus(t("telegram_auth_unavailable"),!0),e.appendChild(a),state.telegramLinkRendered=!0}async function linkTelegramAccount(e){if(!(!e||state.telegramLinkInProgress)){state.telegramLinkInProgress=!0,setTelegramLinkStatus(t("telegram_auth_verifying"));try{const n=await api("/account/telegram/link",{method:"POST",body:JSON.stringify({auth_data:e})});if(!n.ok)throw n;await finishTelegramLink(n,e.id)}catch(n){if(await refreshLinkedTelegramAfterError()){setTelegramLinkStatus("");try{showToast(t("telegram_linked"))}catch(a){console.warn("Telegram link success toast failed",a)}return}setTelegramLinkStatus(emailErrorMessage(n,"telegram_auth_failed"),!0)}finally{state.telegramLinkInProgress=!1}}}async function finishTelegramLink(e,n){e.token&&setToken(e.token),markTelegramLinked(e.telegram_id||n),setTelegramLinkStatus(""),state.telegramLinkRendered=!1;const a=e.account_merge&&e.account_merge.merged?e.account_merge:null;state.accountMergeNotice=a;try{showToast(t(a?"account_merge_toast":"telegram_linked"))}catch(i){console.warn("Telegram link success toast failed",i)}try{await loadData(),a&&openAccountMergeModal(a)}catch(i){console.warn("Telegram account linked, but data refresh failed",i),a&&openAccountMergeModal(a)}}function markTelegramLinked(e){!state.data||!state.data.user||(state.data.user.telegram_linked=!0,e&&(state.data.user.telegram_id=Number(e)),renderAccount(state.data.user))}async function refreshLinkedTelegramAfterError(){try{return await loadData(),!!(state.data&&state.data.user&&state.data.user.telegram_linked)}catch(e){return console.warn("Telegram link status refresh failed",e),!1}}function togglePaymentFlow(){if(state.paymentFlowOpen){closePaymentFlow();return}state.paymentFlowOpen=!0,state.creatingPayment=!1,state.payment=null,state.selectedPlan=getDefaultPaymentPlan(state.data&&state.data.plans||[]),renderPaymentFlow()}function closePaymentFlow(){state.paymentFlowOpen=!1,state.creatingPayment=!1,state.payment=null,renderPaymentFlow()}function renderPaymentFlow(){const e=document.getElementById("payment-modal");if(e){if(!state.paymentFlowOpen){e.classList.remove("show"),syncModalLock(),window.setTimeout(()=>{state.paymentFlowOpen||e.classList.add("hidden")},180);return}e.classList.remove("hidden"),syncModalLock(),window.requestAnimationFrame(()=>e.classList.add("show")),renderPlans(state.data&&state.data.plans||[]),renderPaymentSummary(),renderMethods(),applyI18n(e)}}function syncModalLock(){document.body.classList.toggle("modal-open",!!(state.paymentFlowOpen||state.emailLoginCodeModalOpen||state.promoModalOpen||state.referralModalOpen||state.accountMergeModalOpen))}function getDefaultPaymentPlan(e=[]){return e.length&&(e.find(n=>Number(n.months)===3)||e[0])||null}function renderPlans(e){const n=document.getElementById("plans");if(n){if(!e.length){n.innerHTML='
'+escapeHtml(t("no_plans"))+"
";return}(!state.selectedPlan||!e.some(a=>Number(a.months)===Number(state.selectedPlan.months)))&&(state.selectedPlan=getDefaultPaymentPlan(e)),n.innerHTML=e.map(a=>{const i=Number(a.months),o=state.selectedPlan&&Number(state.selectedPlan.months)===i;return` - - `}).join("")}}function renderPaymentSummary(){const e=document.getElementById("selected-plan-price");if(e){if(!state.selectedPlan){e.textContent=t("no_plans");return}e.textContent=formatMoney(state.selectedPlan.price,state.selectedPlan.currency)}}function renderMethods(){const e=document.getElementById("payment-methods");if(!e)return;if(!state.selectedPlan){e.innerHTML='
'+escapeHtml(t("choose_plan_first"))+"
";return}const n=getAvailableMethods();if(!n.length){e.innerHTML='
'+escapeHtml(t("no_methods"))+"
";return}e.innerHTML=n.map(a=>{const i=a.id==="platega"?"pay_with_platega_button":"pay_with_cryptopay_button",o=a.id==="platega"?TW.paymentMethodCardPlatega:TW.paymentMethodCardCryptopay;return` - - `}).join("")}function selectPlan(e){state.creatingPayment||(state.selectedPlan=(state.data&&state.data.plans||[]).find(n=>Number(n.months)===Number(e))||null,state.payment=null,renderPaymentFlow())}async function createAndOpenPayment(e){if(state.creatingPayment)return;if(!state.selectedPlan){showToast(t("choose_plan_toast"));return}if(!getAvailableMethods().some(a=>a.id===e)){showToast(t("no_methods"));return}state.creatingPayment=!0,state.payment=null,renderPaymentFlow();try{const a=await api("/payments",{method:"POST",body:JSON.stringify({months:state.selectedPlan.months,method:e})});if(!a.ok||!a.payment_url)throw new Error(a.message||t("payment_error"));state.payment=a,openPaymentUrl(a.payment_url)&&closePaymentFlow()}catch(a){showToast(a.message||t("payment_error"))}finally{state.creatingPayment=!1,state.paymentFlowOpen&&renderPaymentFlow()}}function getAvailableMethods(){if(!state.data||!state.selectedPlan)return[];const e=new Map((state.data.payment_methods||[]).map(n=>[String(n.id||"").toLowerCase(),n]));return["platega","cryptopay"].map(n=>e.get(n)).filter(Boolean)}function openPaymentUrl(e){const n=String(e||state.payment&&state.payment.payment_url||"").trim();return n?tg&&tg.openLink?(tg.openLink(n),!0):(window.open(n,"_blank","noopener")||window.location.assign(n),!0):!1}async function checkPayment(){if(!state.payment||!state.payment.payment_id)return;(await api("/payments/"+state.payment.payment_id)).paid?(showToast(t("payment_confirmed")),state.paymentFlowOpen=!1,await loadData()):showToast(t("payment_pending"))}function openConnectLink(){const e=state.data&&state.data.subscription,n=e&&(e.connect_url||e.config_link);if(!n){showToast(t("no_link"));return}tg&&tg.openLink?tg.openLink(n):window.open(n,"_blank","noopener")}async function copyConfigLink(){const e=state.data&&state.data.subscription,n=e&&e.config_link;if(!n){showToast(t("no_link"));return}await copyText(n),showToast(t("link_copied"))}async function copyText(e){try{await navigator.clipboard.writeText(e)}catch(n){const a=document.createElement("textarea");a.value=e,document.body.appendChild(a),a.select(),document.execCommand("copy"),a.remove()}}async function api(e,n={}){if(MOCK)return mockApi(e,n);const a=Object.assign({Authorization:"Bearer "+state.token},n.headers||{});n.body&&!a["Content-Type"]&&(a["Content-Type"]="application/json");const i=await fetch(CFG.apiBase+e,Object.assign({},n,{headers:a}));if(i.status===401)throw clearToken(),await startExternalAuth(),new Error("Unauthorized");return i.json()}async function publicApi(e,n={}){return MOCK?mockApi(e,{method:"POST",body:JSON.stringify(n)}):(await fetch(CFG.apiBase+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json()}async function mockApi(e,n={}){return await new Promise(a=>window.setTimeout(a,120)),e==="/me"?JSON.parse(JSON.stringify(MOCK.data)):e==="/auth/email/request"||e==="/account/email/request"?{ok:!0}:e==="/auth/email/verify"?{ok:!0,token:"local-preview"}:e==="/account/email/verify"?(MOCK.data.user.email="preview@example.com",MOCK.data.user.email_verified=!0,{ok:!0,token:"local-preview"}):e==="/account/telegram/link"?(MOCK.data.user.telegram_linked=!0,MOCK.data.user.telegram_id=100200300,{ok:!0,token:"local-preview"}):e==="/promo/apply"?{ok:!0,end_date_text:"20.05.2026 12:00"}:e==="/payments"&&String(n.method||"").toUpperCase()==="POST"?{ok:!0,action:"open_link",payment_url:"https://example.com/payment-preview",payment_id:10001}:e.startsWith("/payments/")?{ok:!0,payment_id:10001,status:"pending",paid:!1}:{ok:!1,error:"not_found"}}function setToken(e){state.token=e,localStorage.setItem("rw_webapp_token",e)}function clearToken(){state.token="",localStorage.removeItem("rw_webapp_token")}function normalizeEmail(e){return String(e||"").trim().toLowerCase()}function emailErrorMessage(e,n){const a=e&&e.error;return a==="rate_limited"?t("email_rate_limited",{seconds:e.retry_after||60}):t(a==="invalid_email"?"email_invalid":a==="expired_code"?"email_code_expired":a==="invalid_code"||a==="too_many_attempts"?"email_code_invalid":a==="email_auth_not_configured"?"email_auth_disabled":a==="account_merge_conflict"?"account_merge_conflict":a==="invalid_auth"?"telegram_auth_failed":n)}function setButtonBusy(e,n){const a=document.getElementById(e);a&&(a.disabled=!!n)}function logout(){toggleUserMenu(!1),clearToken(),closePaymentFlow(),closeEmailLoginCodeModal(),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("login").classList.add("hidden"),document.getElementById("app").classList.remove("hidden")}function showLogin(){document.getElementById("loader").classList.add("hidden"),document.getElementById("app").classList.add("hidden"),document.getElementById("login").classList.remove("hidden"),document.getElementById("login").classList.add("show")}function setAuthStatus(e,n=!1){const a=document.getElementById("auth-status");if(a){if(!e){a.textContent="",a.classList.add("hidden"),a.classList.remove("error");return}a.textContent=e,a.classList.remove("hidden"),a.classList.toggle("error",!!n)}}function setEmailCodeStatus(e,n=!1){const a=document.getElementById("email-code-status");if(a){if(!e){a.textContent="",a.classList.add("hidden"),a.classList.remove("error");return}a.textContent=e,a.classList.remove("hidden"),a.classList.toggle("error",!!n)}}function setTelegramLinkStatus(e,n=!1){const a=document.getElementById("telegram-link-status");if(a){if(!e){a.textContent="",a.classList.add("hidden"),a.classList.remove("error");return}a.textContent=e,a.classList.remove("hidden"),a.classList.toggle("error",!!n)}}function showToast(e){const n=document.getElementById("toast");if(n){if(n.textContent=e,n.classList.remove("hidden"),n.offsetWidth,n.classList.add("show"),tg&&tg.HapticFeedback)try{tg.HapticFeedback.notificationOccurred("success")}catch(a){console.warn("Haptic feedback failed",a)}state.toastTimer&&window.clearTimeout(state.toastTimer),state.toastTimer=window.setTimeout(()=>{n.classList.remove("show"),window.setTimeout(()=>{n.classList.contains("show")||n.classList.add("hidden")},250)},2200)}}function setFavicon(e){let n=document.getElementById("app-favicon");n||(n=document.createElement("link"),n.id="app-favicon",n.rel="icon",n.setAttribute("sizes","any"),document.head.appendChild(n)),n.href=e||"data:,"}function setBrandLogo(e,n){if(!e)return;const a=e.querySelector("[data-brand-logo]"),i=e.querySelector("[data-brand-logo-spinner]");if(!a)return;if(a.onload=null,a.onerror=null,a.removeAttribute("data-brand-logo-url"),!n){a.removeAttribute("src"),a.classList.add("hidden"),i&&i.classList.add("hidden"),e.classList.add("hidden");return}const o=n;e.classList.remove("hidden"),i&&i.classList.remove("hidden"),a.classList.add("hidden"),a.dataset.brandLogoUrl=o,a.onload=()=>{a.dataset.brandLogoUrl===o&&(a.classList.remove("hidden"),i&&i.classList.add("hidden"))},a.onerror=()=>{a.dataset.brandLogoUrl===o&&(a.removeAttribute("src"),a.classList.add("hidden"),i&&i.classList.add("hidden"),e.classList.add("hidden"))},a.src=n,a.complete&&a.naturalWidth>0&&(a.classList.remove("hidden"),i&&i.classList.add("hidden"))}function setBrand(){const e=CFG.title||document.title||t("page_title"),n=CFG.logoUrl||"";document.title=e,setFavicon(n),document.querySelectorAll("[data-brand-title]").forEach(a=>{a.textContent=e}),document.querySelectorAll("[data-brand-logo-shell]").forEach(a=>{setBrandLogo(a,n)})}function applyI18n(e=document){document.documentElement.lang=getLanguage(),e.querySelectorAll("[data-i18n]").forEach(n=>{n.textContent=t(n.dataset.i18n)}),e.querySelectorAll("[data-title-i18n]").forEach(n=>{const a=t(n.dataset.titleI18n);n.setAttribute("title",a),n.setAttribute("aria-label",a)}),e.querySelectorAll("[data-aria-i18n]").forEach(n=>{n.setAttribute("aria-label",t(n.dataset.ariaI18n))}),e.querySelectorAll("[data-placeholder-i18n]").forEach(n=>{n.setAttribute("placeholder",t(n.dataset.placeholderI18n))})}function applyLegalLinks(e=document){const n={privacyPolicyUrl:CFG.privacyPolicyUrl||"",userAgreementUrl:CFG.userAgreementUrl||""};e.querySelectorAll("[data-legal-key]").forEach(a=>{const i=a.dataset.legalKey,o=n[i]||"";a.href=o||"#",a.classList.toggle("hidden",!o),a.setAttribute("aria-hidden",o?"false":"true")}),e.querySelectorAll("[data-legal-links]").forEach(a=>{const i=Array.from(a.querySelectorAll("[data-legal-key]")).some(o=>!o.classList.contains("hidden"));a.classList.toggle("hidden",!i)})}function getLanguage(){const e=[];state.langOverride&&e.push(state.langOverride),state.data&&state.data.user&&state.data.user.language_code&&e.push(state.data.user.language_code);const n=tg&&tg.initDataUnsafe&&tg.initDataUnsafe.user;n&&n.language_code&&e.push(n.language_code),typeof navigator!="undefined"&&(Array.isArray(navigator.languages)&&navigator.languages.forEach(a=>a&&e.push(a)),navigator.language&&e.push(navigator.language)),CFG.language&&e.push(CFG.language),document.documentElement.lang&&e.push(document.documentElement.lang);for(const a of e){const i=String(a).toLowerCase().split("-")[0];if(I18N&&I18N[i])return i}return"ru"}function setLanguage(e){const n=String(e||"").toLowerCase().split("-")[0];if(!(I18N&&Object.keys(I18N).length&&!I18N[n])){state.langOverride=n;try{localStorage.setItem("rw_webapp_lang",n)}catch(a){}if(applyI18n(),state.data)try{render()}catch(a){}renderLangMenu(),toggleLangMenu(!1)}}function t(e,n={}){const a=I18N&&I18N[getLanguage()]||I18N&&I18N.ru||{},i=I18N&&I18N.ru&&I18N.ru[e]||e;return(a[e]||i).replace(/\{(\w+)\}/g,(r,l)=>Object.prototype.hasOwnProperty.call(n,l)?String(n[l]):"")}function formatMoney(e,n){const a=Number(e||0);return(Number.isInteger(a)?String(a):a.toFixed(2))+" "+(n||CFG.currency||"RUB")}function escapeHtml(e){return String(e==null?"":e).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function escapeAttr(e){return escapeHtml(e).replaceAll("`","`")} diff --git a/requirements.txt b/requirements.txt index 9207699..2ccc54e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ pydantic==2.12.5 yookassa==3.9.0 httpx>=0.27.0 pydantic_settings==2.12.0 +email-validator==2.3.0 sqlalchemy[asyncio]==2.0.45 asyncpg==0.31.0 aiocryptopay==0.4.8 diff --git a/tests/test_security.py b/tests/test_security.py index e5e31de..b6f0e99 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -4,6 +4,11 @@ import unittest from types import SimpleNamespace from unittest.mock import AsyncMock +from aiohttp import web + +from bot.app.web import subscription_webapp +from bot.app.web.webapp_auth import create_webapp_session_token +from bot.services.crypto_pay_service import CryptoPayService from bot.handlers.user.payment import yookassa_webhook_route from bot.services.freekassa_service import FreeKassaService from bot.utils.request_security import request_client_ip @@ -97,6 +102,132 @@ class FreeKassaServiceTests(unittest.TestCase): request.read.assert_not_awaited() +class CryptoPayServiceTests(unittest.TestCase): + def _make_service(self) -> CryptoPayService: + service = CryptoPayService.__new__(CryptoPayService) + service.token = "cryptopay-token" + return service + + def test_validate_webhook_signature_accepts_valid_signature(self): + service = self._make_service() + raw_body = b'{"payload":"42"}' + expected_signature = hmac.new( + hashlib.sha256(service.token.encode("utf-8")).digest(), + raw_body, + hashlib.sha256, + ).hexdigest() + + self.assertTrue(service._validate_webhook_signature(raw_body, expected_signature)) + + def test_validate_webhook_signature_rejects_invalid_signature(self): + service = self._make_service() + + self.assertFalse(service._validate_webhook_signature(b"payload", "not-a-signature")) + + +class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase): + def test_require_user_id_falls_back_to_cookie_session(self): + settings = SimpleNamespace( + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + ) + token = create_webapp_session_token(settings, 321) + request = SimpleNamespace( + app={"settings": settings}, + headers={}, + cookies={"rw_webapp_session": token}, + ) + + self.assertEqual(subscription_webapp._require_user_id(request), 321) + + async def test_csrf_middleware_rejects_mismatched_token_when_cookie_session_exists(self): + settings = SimpleNamespace( + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + ) + request = SimpleNamespace( + method="POST", + path="/api/payments", + headers={"X-CSRF-Token": "bad-token"}, + cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"}, + app={"settings": settings}, + ) + handler = AsyncMock(return_value=web.Response(text="ok")) + + response = await subscription_webapp._csrf_protection_middleware(request, handler) + + self.assertEqual(response.status, 403) + handler.assert_not_awaited() + + async def test_csrf_middleware_allows_matching_token_when_cookie_session_exists(self): + settings = SimpleNamespace( + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + ) + request = SimpleNamespace( + method="POST", + path="/api/payments", + headers={"X-CSRF-Token": "good-token"}, + cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"}, + app={"settings": settings}, + ) + handler = AsyncMock(return_value=web.Response(text="ok")) + + response = await subscription_webapp._csrf_protection_middleware(request, handler) + + self.assertEqual(response.text, "ok") + handler.assert_awaited_once() + + async def test_csrf_middleware_allows_valid_bearer_authorization_for_compatibility(self): + settings = SimpleNamespace( + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + ) + token = create_webapp_session_token(settings, 321) + request = SimpleNamespace( + method="POST", + path="/api/payments", + headers={ + "Authorization": f"Bearer {token}", + "X-CSRF-Token": "bad-token", + }, + cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"}, + app={"settings": settings}, + ) + handler = AsyncMock(return_value=web.Response(text="ok")) + + response = await subscription_webapp._csrf_protection_middleware(request, handler) + + self.assertEqual(response.text, "ok") + handler.assert_awaited_once() + + def test_email_payload_rejects_overlong_email(self): + long_email = ("a" * 245) + "@example.com" + + model, response = subscription_webapp._validate_model_payload( + subscription_webapp.WebAppEmailPayload, + {"email": long_email}, + ) + + self.assertIsNone(model) + self.assertEqual(response.status, 400) + self.assertIn("email_too_long", response.text) + + def test_payment_payload_rejects_overlong_description(self): + model, response = subscription_webapp._validate_model_payload( + subscription_webapp.WebAppPaymentCreatePayload, + { + "method": "platega", + "months": 3, + "description": "x" * 4097, + }, + ) + + self.assertIsNone(model) + self.assertEqual(response.status, 400) + self.assertIn("description_too_long", response.text) + + def asyncio_run(coro): import asyncio