feat: email login, smtp codes

This commit is contained in:
3252a8
2026-04-23 21:20:32 +03:00
parent b9cb1fec06
commit 807a8933b9
20 changed files with 2321 additions and 137 deletions
+17 -12
View File
@@ -43,16 +43,30 @@ WEBAPP_SESSION_TTL_SECONDS=2592000 #
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
# Payment Method Toggles # Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS STARS_ENABLED=True # Turn on STARS
TRIBUTE_ENABLED=True # Turn on TRIBUTE
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay # Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# YooKassa Payment Gateway Configuration # YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
@@ -97,31 +111,22 @@ SEVERPAY_TOKEN= #
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link) SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default) SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
# Subscription Options. Specify cost parameters or payment links here. # Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True 1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150 RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0 STARS_PRICE_1_MONTH=0
TRIBUTE_LINK_1_MONTH=
3_MONTHS_ENABLED=True 3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300 RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0 STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS=
6_MONTHS_ENABLED=True 6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500 RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0 STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS=
12_MONTHS_ENABLED=True 12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900 RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0 STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Traffic Packages (enables traffic sale mode when set) # Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
+20 -2
View File
@@ -8,6 +8,7 @@
- **Регистрация и выбор языка:** Поддержка русского и английского языков. - **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию. - **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки. - **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
- **Вход по email:** вход и регистрация в Web App по коду из письма, а также привязка email и Telegram к одному аккаунту.
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`). - **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
@@ -75,6 +76,13 @@
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` | | `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` | | `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` | | `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` |
| `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` |
| `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` |
| `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` |
| `SMTP_TIMEOUT_SECONDS` | Timeout для каждой SMTP-попытки подключения и отправки. | `30` |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | Логин и SMTP key/password из Brevo. Если не заданы вместе с `SMTP_FROM_EMAIL`, вход по email скрывается. | `user@smtp-brevo.com` |
| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | Подтвержденный отправитель и отображаемое имя отправителя для писем с кодом. | `no-reply@example.com` |
| `EMAIL_CODE_TTL_SECONDS` | Срок действия кода подтверждения email. | `600` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
@@ -194,7 +202,7 @@
### Настройка Web App / Mini App ### Настройка Web App / Mini App
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. После успешного входа страница обновляет данные сразу, без сообщений боту. Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
1. Укажите в `.env` публичный URL Web App и порт: 1. Укажите в `.env` публичный URL Web App и порт:
@@ -206,8 +214,16 @@ Web App запускается в том же контейнере, что и б
WEBAPP_TITLE="Моя подписка" WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#00fe7a" WEBAPP_PRIMARY_COLOR="#00fe7a"
WEBAPP_LOGO_URL= WEBAPP_LOGO_URL=
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_FALLBACK_PORTS=2525,465
SMTP_USERNAME=<brevo-smtp-login>
SMTP_PASSWORD=<brevo-smtp-key>
SMTP_FROM_EMAIL=no-reply@domain.com
``` ```
Если основной порт не отвечает, отправка письма автоматически пробует fallback-порты из `SMTP_FALLBACK_PORTS`. Для Brevo типичная схема: `587` с STARTTLS, затем `2525`, затем `465` через SSL.
2. Убедитесь, что `docker-compose.yml` публикует порт Web App: 2. Убедитесь, что `docker-compose.yml` публикует порт Web App:
```yaml ```yaml
@@ -250,7 +266,9 @@ Web App запускается в том же контейнере, что и б
docker compose up -d --build docker compose up -d --build
``` ```
После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками. После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами.
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов. Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
+394 -2
View File
@@ -17,6 +17,7 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token, verify_webapp_session_token,
) )
from bot.services.crypto_pay_service import CryptoPayService from bot.services.crypto_pay_service import CryptoPayService
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.freekassa_service import FreeKassaService from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService from bot.services.platega_service import PlategaService
from bot.services.severpay_service import SeverPayService from bot.services.severpay_service import SeverPayService
@@ -25,6 +26,7 @@ from bot.services.yookassa_service import YooKassaService
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal from db.dal import payment_dal, subscription_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User from db.models import Payment, User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,6 +56,7 @@ def create_subscription_webapp_application(
app["settings"] = settings app["settings"] = settings
app["async_session_factory"] = async_session_factory app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance") app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
for key in ( for key in (
"subscription_service", "subscription_service",
@@ -81,7 +84,12 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/token", auth_token_route) 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_get("/api/me", me_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)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post("/api/payments", create_payment_route) app.router.add_post("/api/payments", create_payment_route)
app.router.add_get("/api/payments/{payment_id}", payment_status_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route)
@@ -272,6 +280,7 @@ async def index_route(request: web.Request) -> web.Response:
"userAgreementUrl": settings.USER_AGREEMENT_URL or "", "userAgreementUrl": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"language": _normalize_language(settings.DEFAULT_LANGUAGE), "language": _normalize_language(settings.DEFAULT_LANGUAGE),
"emailAuthEnabled": settings.email_auth_configured,
} }
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
html = html.replace( html = html.replace(
@@ -340,22 +349,220 @@ async def auth_token_route(request: web.Request) -> web.Response:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data") return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"] async_session_factory: sessionmaker = request.app["async_session_factory"]
authenticated_user_id: Optional[int] = None
async with async_session_factory() as session: async with async_session_factory() as session:
try: try:
db_user = await _ensure_user_from_telegram(session, telegram_user, settings) db_user = await _ensure_user_from_telegram(session, telegram_user, settings)
if db_user.is_banned: if db_user.is_banned:
await session.rollback() await session.rollback()
return _json_error(403, "banned", "Access denied") return _json_error(403, "banned", "Access denied")
authenticated_user_id = int(db_user.user_id)
await session.commit() await session.commit()
except Exception as exc: except Exception as exc:
await session.rollback() await session.rollback()
logger.error("WebApp auth failed: %s", exc, exc_info=True) logger.error("WebApp auth failed: %s", exc, exc_info=True)
return _json_error(500, "auth_failed", "Auth failed") return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(telegram_user["id"])) token = create_webapp_session_token(settings, int(authenticated_user_id))
return web.json_response({"ok": True, "token": token}) return web.json_response({"ok": True, "token": token})
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 ""))
lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE))
return await _request_email_code(
request,
email=email,
purpose="login",
language_code=lang,
target_user_id=None,
)
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_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="login",
code=code,
target_user_id=None,
)
if not verify_result.ok:
await session.rollback()
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
db_user = await user_dal.get_user_by_email(session, email)
if not db_user:
db_user, _ = await user_dal.create_email_user(
session,
email=email,
language_code=_normalize_language(settings.DEFAULT_LANGUAGE),
email_verified_at=datetime.now(timezone.utc),
)
elif not db_user.email_verified_at:
db_user.email_verified_at = datetime.now(timezone.utc)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Email WebApp auth failed: %s", exc, exc_info=True)
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
return web.json_response(
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
)
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 ""))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
if db_user.email == email and db_user.email_verified_at:
return web.json_response({"ok": True, "already_linked": True})
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="link_email",
language_code=lang,
target_user_id=user_id,
)
async def account_email_verify_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
code = str(payload.get("code") or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="link_email",
code=code,
target_user_id=user_id,
)
if not verify_result.ok:
await session.rollback()
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
current_user = await user_dal.get_user_by_id(session, user_id)
if not current_user or current_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
existing_email_user = await user_dal.get_user_by_email(session, email)
if existing_email_user and existing_email_user.user_id != current_user.user_id:
current_user = await user_dal.merge_users(
session,
source_user_id=existing_email_user.user_id,
target_user_id=current_user.user_id,
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception as exc:
await session.rollback()
logger.error("Email account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(current_user.user_id))
return web.json_response({"ok": True, "token": token})
async def account_telegram_link_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
init_data = str(payload.get("init_data") or "")
auth_data = payload.get("auth_data")
telegram_user = None
if init_data:
telegram_user = validate_telegram_webapp_init_data(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
elif auth_data is not None:
telegram_user = validate_telegram_login_widget_data(
auth_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await _link_telegram_to_user(
request,
session,
current_user_id=user_id,
telegram_user=telegram_user,
settings=settings,
)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
await session.commit()
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception as exc:
await session.rollback()
logger.error("Telegram account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
return web.json_response(
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
)
async def me_route(request: web.Request) -> web.Response: async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request) user_id = _require_user_id(request)
data = await _build_user_payload(request, user_id) data = await _build_user_payload(request, user_id)
@@ -448,6 +655,183 @@ def _require_user_id(request: web.Request) -> int:
return user_id return user_id
async def _request_email_code(
request: web.Request,
*,
email: str,
purpose: str,
language_code: str,
target_user_id: Optional[int],
) -> web.Response:
email_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
result = await email_service.request_code(
session,
email=email,
purpose=purpose,
language_code=language_code,
target_user_id=target_user_id,
)
if not result.ok:
await session.rollback()
status = 429 if result.error == "rate_limited" else 400
if result.error == "email_auth_not_configured":
status = 503
return web.json_response(
{
"ok": False,
"error": result.error,
"retry_after": result.retry_after,
},
status=status,
)
await session.commit()
return web.json_response({"ok": True})
except Exception as exc:
await session.rollback()
logger.error("Failed to send email verification code: %s", exc, exc_info=True)
return _json_error(502, "email_send_failed", "Failed to send email")
def _telegram_id_for_user(user: User) -> Optional[int]:
if user.telegram_id:
return int(user.telegram_id)
if user.user_id and int(user.user_id) > 0:
return int(user.user_id)
return None
def _panel_description_for_user(user: User) -> str:
lines = [
user.email or "",
user.username or "",
user.first_name or "",
user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
async def _sync_panel_identity_for_user(request: web.Request, user: User) -> None:
if not user.panel_user_uuid:
return
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return
payload: Dict[str, Any] = {
"description": _panel_description_for_user(user),
}
telegram_id = _telegram_id_for_user(user)
if telegram_id:
payload["telegramId"] = telegram_id
if user.email:
payload["email"] = user.email
try:
await subscription_service.panel_service.update_user_details_on_panel(
user.panel_user_uuid,
payload,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to sync linked identities to panel for user %s: %s",
user.user_id,
exc,
)
def _apply_telegram_profile_to_user(
user: User,
telegram_user: Dict[str, Any],
settings: Settings,
) -> None:
language_code = telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = user.language_code or settings.DEFAULT_LANGUAGE
user.telegram_id = int(telegram_user["id"])
user.username = sanitize_username(telegram_user.get("username"))
user.first_name = sanitize_display_name(telegram_user.get("first_name"))
user.last_name = sanitize_display_name(telegram_user.get("last_name"))
user.language_code = language_code
async def _link_telegram_to_user(
request: web.Request,
session: AsyncSession,
*,
current_user_id: int,
telegram_user: Dict[str, Any],
settings: Settings,
) -> User:
telegram_id = int(telegram_user["id"])
current_user = await user_dal.get_user_by_id(session, current_user_id)
if not current_user:
raise ValueError("Current user not found.")
existing_telegram_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
if not existing_telegram_user:
existing_telegram_user = await user_dal.get_user_by_id(session, telegram_id)
if existing_telegram_user and existing_telegram_user.user_id != current_user.user_id:
if (
current_user.email
and existing_telegram_user.email
and current_user.email != existing_telegram_user.email
):
raise UserMergeConflictError(
"Telegram account is already linked to a different email."
)
merged_user = await user_dal.merge_users(
session,
source_user_id=current_user.user_id,
target_user_id=existing_telegram_user.user_id,
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = telegram_user.get("language_code") or current_user.language_code or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
target_user, _ = await user_dal.create_user(
session,
{
"user_id": telegram_id,
"telegram_id": telegram_id,
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
"registration_date": current_user.registration_date or datetime.now(timezone.utc),
},
)
target_user.referral_code = None
await session.flush()
merged_user = await user_dal.merge_users(
session,
source_user_id=current_user.user_id,
target_user_id=target_user.user_id,
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
raise UserMergeConflictError("Current account is already linked to Telegram.")
_apply_telegram_profile_to_user(current_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, current_user)
return current_user
async def _ensure_user_from_telegram( async def _ensure_user_from_telegram(
session: AsyncSession, session: AsyncSession,
telegram_user: Dict[str, Any], telegram_user: Dict[str, Any],
@@ -459,12 +843,15 @@ async def _ensure_user_from_telegram(
language_code = settings.DEFAULT_LANGUAGE language_code = settings.DEFAULT_LANGUAGE
update_data = { update_data = {
"telegram_id": user_id,
"username": sanitize_username(telegram_user.get("username")), "username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")), "first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")), "last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code, "language_code": language_code,
} }
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user: if not db_user:
db_user, _ = await user_dal.create_user( db_user, _ = await user_dal.create_user(
@@ -483,7 +870,7 @@ async def _ensure_user_from_telegram(
if getattr(db_user, key) != value if getattr(db_user, key) != value
} }
if changed: if changed:
db_user = await user_dal.update_user(session, user_id, changed) or db_user db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
return db_user return db_user
@@ -518,6 +905,10 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"user": { "user": {
"id": user_id, "id": user_id,
"username": db_user.username, "username": db_user.username,
"email": db_user.email,
"email_verified": bool(db_user.email_verified_at),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"first_name": db_user.first_name, "first_name": db_user.first_name,
"language_code": lang, "language_code": lang,
}, },
@@ -527,6 +918,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"settings": { "settings": {
"support_url": settings.SUPPORT_LINK, "support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode), "traffic_mode": bool(settings.traffic_sale_mode),
"email_auth_enabled": settings.email_auth_configured,
}, },
} }
@@ -176,6 +176,96 @@
gap: 16px; gap: 16px;
} }
.account-panel {
padding: 16px;
display: grid;
gap: 14px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.section-title {
font-size: 18px;
font-weight: 850;
line-height: 1.2;
}
.account-grid {
display: grid;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.account-row {
min-height: 46px;
padding: 10px 12px;
display: grid;
grid-template-columns: minmax(0, 0.68fr) minmax(0, 1.32fr);
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.02);
}
.account-row + .account-row {
border-top: 1px solid var(--border);
}
.link-box,
.field-stack {
display: grid;
gap: 8px;
}
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
gap: 8px;
align-items: stretch;
}
.input {
width: 100%;
min-height: 46px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.035);
color: var(--text-primary);
padding: 11px 12px;
font: inherit;
font-weight: 700;
outline: none;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.input:focus {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.input[aria-invalid="true"] {
border-color: color-mix(in srgb, var(--danger) 72%, var(--border));
}
.input[aria-invalid="true"]:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
}
.input::placeholder {
color: var(--text-muted);
}
.code-input {
font-family: var(--font-mono);
text-align: center;
letter-spacing: 0;
}
.badge-row { .badge-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -474,6 +564,22 @@
gap: 14px; gap: 14px;
} }
.code-modal-stack {
position: relative;
z-index: 1;
width: min(100%, 420px);
display: grid;
gap: 10px;
justify-items: stretch;
}
.code-modal-card {
width: 100%;
padding: 16px;
display: grid;
gap: 14px;
}
.flow-head { .flow-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -493,6 +599,91 @@
font-size: 13px; font-size: 13px;
} }
.code-summary {
min-height: 46px;
padding: 10px 12px;
display: grid;
grid-template-columns: minmax(0, 0.6fr) minmax(0, 1.4fr);
align-items: center;
gap: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
}
.otp-field {
position: relative;
display: block;
min-height: 56px;
}
.otp-input {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
border: 0;
background: transparent;
color: transparent;
caret-color: transparent;
opacity: 0.01;
outline: none;
}
.otp-slots {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.otp-slots span {
min-width: 0;
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.035);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 20px;
font-weight: 850;
line-height: 1;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.otp-slots span.filled {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
}
.otp-slots span.active {
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.code-modal-actions {
display: grid;
gap: 8px;
}
.code-modal-resend {
justify-self: center;
min-height: 28px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 13px;
font-weight: 750;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
text-underline-offset: 3px;
transition: color var(--transition), opacity var(--transition);
}
.stepper { .stepper {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -654,6 +845,36 @@
text-align: center; text-align: center;
} }
.auth-tabs {
min-height: 46px;
padding: 4px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
}
.auth-tab {
min-height: 36px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-weight: 850;
transition: color var(--transition), background var(--transition);
}
.auth-tab.active {
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
}
.auth-pane {
display: grid;
gap: 10px;
}
.login-brand { .login-brand {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -747,6 +968,10 @@
background: color-mix(in srgb, var(--accent) 10%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
} }
.code-modal-resend:hover:not(:disabled) {
color: var(--accent);
}
} }
@media (max-width: 380px) { @media (max-width: 380px) {
@@ -767,6 +992,11 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.account-row,
.field-row {
grid-template-columns: 1fr;
}
.metric-value { .metric-value {
text-align: left; text-align: left;
} }
@@ -775,6 +1005,18 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.code-summary {
grid-template-columns: 1fr;
}
.otp-slots {
gap: 6px;
}
.otp-slots span {
font-size: 18px;
}
.stepper { .stepper {
gap: 6px; gap: 6px;
} }
@@ -830,6 +1072,30 @@
border-radius: 0; border-radius: 0;
} }
.auth-code-modal {
align-items: center;
justify-items: center;
top: 0;
bottom: 0;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
}
.auth-code-modal .code-modal-stack {
width: min(100%, 420px);
}
.auth-code-modal .modal-card {
width: 100%;
height: auto;
max-height: min(90vh, 620px);
border-radius: var(--radius-lg);
transform: translateY(12px) scale(0.98);
}
.auth-code-modal.show .modal-card {
transform: translateY(0) scale(1);
}
.legal-link { .legal-link {
flex: 1 1 100%; flex: 1 1 100%;
} }
@@ -74,6 +74,42 @@
</div> </div>
</section> </section>
<section class="panel account-panel">
<div class="panel-head">
<div>
<div class="section-title" data-i18n="account_title">Аккаунт</div>
<div class="flow-caption" data-i18n="account_caption">Способы входа</div>
</div>
</div>
<div class="account-grid">
<div class="account-row">
<div class="metric-label" data-i18n="email_label">Email</div>
<div id="account-email" class="metric-value">...</div>
</div>
<div class="account-row">
<div class="metric-label" data-i18n="telegram_label">Telegram</div>
<div id="account-telegram" class="metric-value">...</div>
</div>
</div>
<div id="email-link-box" class="link-box hidden">
<div class="field-row">
<input id="email-link-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-link-send-btn" class="btn" type="button" onclick="requestEmailLinkCode()" data-i18n="send_code">Отправить код</button>
</div>
<div id="email-link-code-row" class="field-row hidden">
<input id="email-link-code-input" class="input code-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
<button id="email-link-verify-btn" class="btn primary" type="button" onclick="verifyEmailLinkCode()" data-i18n="confirm">Подтвердить</button>
</div>
</div>
<div id="telegram-link-box" class="link-box hidden">
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
<div id="telegram-link-status" class="login-text login-status hidden" aria-live="polite"></div>
</div>
</section>
<a id="support-link" class="btn support-link hidden" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a> <a id="support-link" class="btn support-link hidden" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
<div id="legal-links-app" class="legal-links hidden"> <div id="legal-links-app" class="legal-links hidden">
<a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a> <a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
@@ -134,13 +170,64 @@
</section> </section>
</div> </div>
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
<div class="code-modal-stack">
<section class="panel modal-card code-modal-card">
<div class="flow-head">
<div>
<div id="email-code-title" class="flow-title" data-i18n="email_code_title">Подтвердите вход</div>
<div id="email-code-caption" class="flow-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()">×</button>
</div>
<div class="code-summary">
<div class="metric-label" data-i18n="email_label">Email</div>
<div id="email-code-address" class="metric-value">...</div>
</div>
<label class="otp-field" for="email-login-code-input">
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
<span class="otp-slots" aria-hidden="true">
<span id="email-code-slot-0"></span>
<span id="email-code-slot-1"></span>
<span id="email-code-slot-2"></span>
<span id="email-code-slot-3"></span>
<span id="email-code-slot-4"></span>
<span id="email-code-slot-5"></span>
</span>
</label>
<div id="email-code-status" class="login-text login-status hidden" aria-live="polite"></div>
<div class="code-modal-actions">
<button id="email-login-verify-btn" class="btn primary full" type="button" onclick="verifyEmailLoginCode()" data-i18n="login">Войти</button>
</div>
</section>
<button id="email-login-resend-btn" class="code-modal-resend" type="button" onclick="resendEmailLoginCode()" data-i18n="resend_code">Отправить еще раз</button>
</div>
</div>
<section id="login" class="login"> <section id="login" class="login">
<div class="panel login-card"> <div class="panel login-card">
<div class="login-brand"> <div class="login-brand">
<img id="login-brand-logo" class="brand-logo brand-logo--lg hidden" data-brand-logo alt="" aria-hidden="true"> <img id="login-brand-logo" class="brand-logo brand-logo--lg hidden" data-brand-logo alt="" aria-hidden="true">
<div id="login-brand-title" class="login-brand-title" data-brand-title>Моя подписка</div> <div id="login-brand-title" class="login-brand-title" data-brand-title>Моя подписка</div>
</div> </div>
<div class="auth-tabs" role="tablist">
<button id="email-auth-tab" class="auth-tab active" type="button" onclick="setAuthMode('email')" data-i18n="email_login_tab">Email</button>
<button id="telegram-auth-tab" class="auth-tab" type="button" onclick="setAuthMode('telegram')" data-i18n="telegram_login_tab">Telegram</button>
</div>
<div id="email-login-pane" class="auth-pane">
<div class="field-stack">
<input id="email-login-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-login-send-btn" class="btn primary full" type="button" onclick="requestEmailLoginCode()" data-i18n="send_code">Отправить код</button>
</div>
</div>
<div id="telegram-login-pane" class="auth-pane hidden">
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div> <div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
</div>
<div id="auth-status" class="login-text login-status hidden" aria-live="polite"></div> <div id="auth-status" class="login-text login-status hidden" aria-live="polite"></div>
</div> </div>
<div id="legal-links-login" class="legal-links hidden"> <div id="legal-links-login" class="legal-links hidden">
+613 -7
View File
@@ -9,13 +9,19 @@ window.__WEBAPP_DEV_MOCK__ = {
privacyPolicyUrl: 'https://example.com/privacy', privacyPolicyUrl: 'https://example.com/privacy',
userAgreementUrl: 'https://example.com/agreement', userAgreementUrl: 'https://example.com/agreement',
currency: 'RUB', currency: 'RUB',
language: 'ru' language: 'ru',
emailAuthEnabled: true,
telegramLoginBotUsername: 'preview_bot'
}, },
data: { data: {
ok: true, ok: true,
user: { user: {
id: 100200300, id: 100200300,
username: 'preview', username: 'preview',
email: '',
email_verified: false,
telegram_id: 100200300,
telegram_linked: true,
first_name: 'Preview', first_name: 'Preview',
language_code: 'ru' language_code: 'ru'
}, },
@@ -45,7 +51,8 @@ window.__WEBAPP_DEV_MOCK__ = {
], ],
settings: { settings: {
support_url: 'https://t.me/support', support_url: 'https://t.me/support',
traffic_mode: false traffic_mode: false,
email_auth_enabled: true
} }
} }
}; };
@@ -70,6 +77,16 @@ const MOCK = (() => {
paymentStep: 'plan', paymentStep: 'plan',
creatingPayment: false, creatingPayment: false,
authInProgress: false, authInProgress: false,
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
emailLoginPending: false,
emailLoginEmail: '',
emailLoginCodeModalOpen: false,
emailLoginVerifying: false,
emailLoginResending: false,
emailLinkPending: false,
emailLinkEmail: '',
telegramLinkRendered: false,
telegramLinkInProgress: false,
toastTimer: null toastTimer: null
}; };
@@ -85,9 +102,10 @@ const MOCK = (() => {
traffic: 'Трафик', traffic: 'Трафик',
connect: 'Подключиться', connect: 'Подключиться',
copy_link: 'Скопировать ссылку', copy_link: 'Скопировать ссылку',
extend_subscription: 'Продлить подписку/Добавить дни', extend_subscription: 'Купить подписку / Добавить дни',
payment_title: 'Оплата подписки', payment_title: 'Оплата подписки',
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.', payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
close: 'Закрыть',
close_payment: 'Закрыть оплату', close_payment: 'Закрыть оплату',
payment_steps: 'Шаги оплаты', payment_steps: 'Шаги оплаты',
step_plan: 'Срок', step_plan: 'Срок',
@@ -103,6 +121,35 @@ const MOCK = (() => {
check_payment: 'Проверить оплату', check_payment: 'Проверить оплату',
choose_other_method: 'Выбрать другой способ', choose_other_method: 'Выбрать другой способ',
support: 'Поддержка', 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: 'Войти',
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_conflict: 'Этот аккаунт уже связан с другими данными.',
telegram_auth: 'Telegram auth', telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Проверяю вход...', telegram_auth_verifying: 'Проверяю вход...',
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.', telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
@@ -145,6 +192,7 @@ const MOCK = (() => {
extend_subscription: 'Renew subscription/Add days', extend_subscription: 'Renew subscription/Add days',
payment_title: 'Subscription payment', payment_title: 'Subscription payment',
payment_caption: 'Choose a period, payment method, and create a payment.', payment_caption: 'Choose a period, payment method, and create a payment.',
close: 'Close',
close_payment: 'Close payment', close_payment: 'Close payment',
payment_steps: 'Payment steps', payment_steps: 'Payment steps',
step_plan: 'Period', step_plan: 'Period',
@@ -160,6 +208,35 @@ const MOCK = (() => {
check_payment: 'Check payment', check_payment: 'Check payment',
choose_other_method: 'Choose another method', choose_other_method: 'Choose another method',
support: 'Support', 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',
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_conflict: 'This account is already linked to different data.',
telegram_auth: 'Telegram auth', telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Verifying login...', telegram_auth_verifying: 'Verifying login...',
telegram_auth_failed: 'Could not verify Telegram login. Try again.', telegram_auth_failed: 'Could not verify Telegram login. Try again.',
@@ -213,8 +290,14 @@ const MOCK = (() => {
} catch (e) { } } catch (e) { }
} }
bindEmailLoginInput();
bindEmailCodeInput();
document.addEventListener('keydown', event => { document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.paymentFlowOpen) { if (event.key !== 'Escape') return;
if (state.emailLoginCodeModalOpen) {
closeEmailLoginCodeModal();
} else if (state.paymentFlowOpen) {
closePaymentFlow(); closePaymentFlow();
} }
}); });
@@ -339,6 +422,34 @@ const MOCK = (() => {
await finalizeTelegramAuth(user, 'auth_data'); await finalizeTelegramAuth(user, 'auth_data');
} }
function setAuthMode(mode) {
state.authMode = mode === 'telegram' ? 'telegram' : 'email';
renderAuthMode();
}
function renderAuthMode() {
const emailTab = document.getElementById('email-auth-tab');
const telegramTab = document.getElementById('telegram-auth-tab');
const emailPane = document.getElementById('email-login-pane');
const telegramPane = document.getElementById('telegram-login-pane');
const emailEnabled = CFG.emailAuthEnabled !== false;
if (!emailEnabled && state.authMode === 'email') {
state.authMode = 'telegram';
}
emailTab.classList.toggle('active', state.authMode === 'email');
telegramTab.classList.toggle('active', state.authMode === 'telegram');
emailPane.classList.toggle('hidden', state.authMode !== 'email');
telegramPane.classList.toggle('hidden', state.authMode !== 'telegram');
emailTab.disabled = !emailEnabled;
if (state.authMode === 'telegram') {
renderTelegramLoginWidget();
} else if (!emailEnabled) {
setAuthStatus(t('email_auth_disabled'), true);
}
}
function renderTelegramLoginWidget() { function renderTelegramLoginWidget() {
const container = document.getElementById('telegram-login-widget'); const container = document.getElementById('telegram-login-widget');
if (!container) return; if (!container) return;
@@ -368,13 +479,250 @@ const MOCK = (() => {
container.appendChild(script); container.appendChild(script);
} }
function bindEmailLoginInput() {
const input = document.getElementById('email-login-input');
if (!input) return;
input.addEventListener('keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault();
requestEmailLoginCode();
});
input.addEventListener('input', () => {
if (input.getAttribute('aria-invalid') !== 'true') return;
input.removeAttribute('aria-invalid');
setAuthStatus('');
});
}
function bindEmailCodeInput() {
const input = document.getElementById('email-login-code-input');
if (!input) return;
input.addEventListener('input', () => {
input.value = sanitizeCode(input.value);
updateEmailLoginCodeSlots();
});
input.addEventListener('focus', updateEmailLoginCodeSlots);
input.addEventListener('blur', updateEmailLoginCodeSlots);
input.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
if (sanitizeCode(input.value).length === 6 && !state.emailLoginVerifying) {
verifyEmailLoginCode();
}
}
});
const field = input.closest('.otp-field');
if (field) {
field.addEventListener('click', () => input.focus());
}
}
function sanitizeCode(value) {
return String(value || '').replace(/\D/g, '').slice(0, 6);
}
function isValidEmail(value) {
const email = normalizeEmail(value);
return Boolean(email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
}
function readValidEmailLoginInput() {
const input = document.getElementById('email-login-input');
const email = normalizeEmail(input && input.value);
if (!email || !isValidEmail(email)) {
if (input) {
input.setAttribute('aria-invalid', 'true');
input.focus();
}
setAuthStatus(t(email ? 'email_invalid' : 'email_required'), true);
return '';
}
if (input) {
input.value = email;
input.removeAttribute('aria-invalid');
}
return email;
}
function openEmailLoginCodeModal(email, message, isError = false) {
state.emailLoginEmail = email;
state.emailLoginCodeModalOpen = true;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
renderEmailLoginCodeModal();
setEmailCodeStatus(message || t('email_code_sent'), isError);
window.setTimeout(() => {
const codeInput = document.getElementById('email-login-code-input');
if (state.emailLoginCodeModalOpen && codeInput) codeInput.focus();
}, 80);
}
function closeEmailLoginCodeModal() {
state.emailLoginCodeModalOpen = false;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
setEmailCodeStatus('');
renderEmailLoginCodeModal();
}
function renderEmailLoginCodeModal() {
const modal = document.getElementById('email-code-modal');
if (!modal) return;
if (!state.emailLoginCodeModalOpen) {
modal.classList.remove('show');
syncModalLock();
window.setTimeout(() => {
if (!state.emailLoginCodeModalOpen) modal.classList.add('hidden');
}, 180);
return;
}
const address = document.getElementById('email-code-address');
if (address) address.textContent = state.emailLoginEmail || '...';
modal.classList.remove('hidden');
syncModalLock();
applyI18n(modal);
updateEmailLoginCodeSlots();
window.requestAnimationFrame(() => modal.classList.add('show'));
}
function updateEmailLoginCodeSlots() {
const input = document.getElementById('email-login-code-input');
const value = sanitizeCode(input && input.value);
if (input && input.value !== value) input.value = value;
for (let index = 0; index < 6; index += 1) {
const slot = document.getElementById('email-code-slot-' + index);
if (!slot) continue;
const character = value[index] || '';
slot.textContent = character;
slot.classList.toggle('filled', Boolean(character));
slot.classList.toggle(
'active',
state.emailLoginCodeModalOpen
&& document.activeElement === input
&& index === Math.min(value.length, 5)
);
}
const verifyButton = document.getElementById('email-login-verify-btn');
if (verifyButton) {
verifyButton.disabled = value.length !== 6 || state.emailLoginVerifying || state.emailLoginPending;
}
const resendButton = document.getElementById('email-login-resend-btn');
if (resendButton) {
resendButton.disabled = state.emailLoginPending || state.emailLoginResending;
}
}
async function requestEmailLoginCode() {
if (state.emailLoginPending) return;
if (CFG.emailAuthEnabled === false) {
setAuthStatus(t('email_auth_disabled'), true);
return;
}
const email = readValidEmailLoginInput();
if (!email) {
return;
}
state.emailLoginPending = true;
setButtonBusy('email-login-send-btn', true);
openEmailLoginCodeModal(email, t('email_code_sending'));
setAuthStatus('');
try {
const data = await publicApi('/auth/email/request', {
email,
language: getLanguage()
});
if (!data.ok) throw data;
setEmailCodeStatus(t('email_code_sent'));
} catch (e) {
const message = emailErrorMessage(e, 'email_code_send_failed');
if (e && e.error === 'invalid_email') {
closeEmailLoginCodeModal();
const input = document.getElementById('email-login-input');
if (input) input.setAttribute('aria-invalid', 'true');
setAuthStatus(message, true);
} else {
setEmailCodeStatus(message, true);
}
} finally {
state.emailLoginPending = false;
setButtonBusy('email-login-send-btn', false);
updateEmailLoginCodeSlots();
}
}
async function resendEmailLoginCode() {
if (state.emailLoginPending || state.emailLoginResending) return;
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
if (!email || !isValidEmail(email)) {
setEmailCodeStatus(t(email ? 'email_invalid' : 'email_required'), true);
return;
}
state.emailLoginResending = true;
updateEmailLoginCodeSlots();
setEmailCodeStatus(t('email_code_sending'));
try {
const data = await publicApi('/auth/email/request', {
email,
language: getLanguage()
});
if (!data.ok) throw data;
state.emailLoginEmail = email;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
setEmailCodeStatus(t('email_code_sent'));
} catch (e) {
setEmailCodeStatus(emailErrorMessage(e, 'email_code_send_failed'), true);
} finally {
state.emailLoginResending = false;
updateEmailLoginCodeSlots();
}
}
async function verifyEmailLoginCode() {
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
const input = document.getElementById('email-login-code-input');
const code = sanitizeCode(input && input.value);
if (!email || code.length !== 6) {
setEmailCodeStatus(t('email_code_invalid'), true);
updateEmailLoginCodeSlots();
return;
}
state.emailLoginVerifying = true;
updateEmailLoginCodeSlots();
setEmailCodeStatus(t('telegram_auth_verifying'));
try {
const data = await publicApi('/auth/email/verify', {email, code});
if (!data.ok || !data.token) throw data;
setToken(data.token);
closeEmailLoginCodeModal();
await loadData();
} catch (e) {
clearToken();
setEmailCodeStatus(emailErrorMessage(e, 'email_code_invalid'), true);
} finally {
state.emailLoginVerifying = false;
updateEmailLoginCodeSlots();
}
}
function startExternalAuth(options = {}) { function startExternalAuth(options = {}) {
const resetStatus = options.resetStatus !== false; const resetStatus = options.resetStatus !== false;
showLogin(); showLogin();
if (resetStatus) { if (resetStatus) {
setAuthStatus(''); setAuthStatus('');
} }
renderTelegramLoginWidget(); renderAuthMode();
} }
async function loadData() { async function loadData() {
@@ -390,6 +738,7 @@ const MOCK = (() => {
state.selectedMethod = null; state.selectedMethod = null;
state.payment = null; state.payment = null;
state.paymentStep = 'plan'; state.paymentStep = 'plan';
state.telegramLinkRendered = false;
render(); render();
showApp(); showApp();
} }
@@ -401,6 +750,7 @@ const MOCK = (() => {
function render() { function render() {
renderSubscription(state.data.subscription); renderSubscription(state.data.subscription);
renderAccount(state.data.user || {});
renderPaymentFlow(); renderPaymentFlow();
} }
@@ -414,6 +764,168 @@ const MOCK = (() => {
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link); document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
} }
function renderAccount(user) {
const emailLinked = Boolean(user.email && user.email_verified);
const telegramLinked = Boolean(user.telegram_linked);
document.getElementById('account-email').textContent = emailLinked ? user.email : t('not_linked');
document.getElementById('account-telegram').textContent = telegramLinked
? (user.telegram_id ? String(user.telegram_id) : t('linked'))
: t('not_linked');
const emailBox = document.getElementById('email-link-box');
const telegramBox = document.getElementById('telegram-link-box');
emailBox.classList.toggle('hidden', emailLinked || !state.data.settings.email_auth_enabled);
telegramBox.classList.toggle('hidden', telegramLinked);
if (!telegramLinked) {
renderTelegramLinkWidget();
} else {
state.telegramLinkRendered = false;
}
}
async function requestEmailLinkCode() {
const input = document.getElementById('email-link-input');
const email = normalizeEmail(input.value);
if (!email) {
showToast(t('email_code_send_failed'));
return;
}
state.emailLinkPending = true;
setButtonBusy('email-link-send-btn', true);
try {
const data = await api('/account/email/request', {
method: 'POST',
body: JSON.stringify({email})
});
if (!data.ok) throw data;
if (data.already_linked) {
showToast(t('email_linked'));
await loadData();
return;
}
state.emailLinkEmail = email;
document.getElementById('email-link-code-row').classList.remove('hidden');
showToast(t('email_code_sent'));
} catch (e) {
showToast(emailErrorMessage(e, 'email_code_send_failed'));
} finally {
state.emailLinkPending = false;
setButtonBusy('email-link-send-btn', false);
}
}
async function verifyEmailLinkCode() {
const email = state.emailLinkEmail || normalizeEmail(document.getElementById('email-link-input').value);
const code = document.getElementById('email-link-code-input').value;
setButtonBusy('email-link-verify-btn', true);
try {
const data = await api('/account/email/verify', {
method: 'POST',
body: JSON.stringify({email, code})
});
if (!data.ok) throw data;
if (data.token) setToken(data.token);
showToast(t('email_linked'));
state.emailLinkEmail = '';
await loadData();
} catch (e) {
showToast(emailErrorMessage(e, 'email_code_invalid'));
} finally {
setButtonBusy('email-link-verify-btn', false);
}
}
function renderTelegramLinkWidget() {
const container = document.getElementById('telegram-link-widget');
if (!container || state.telegramLinkRendered) return;
container.innerHTML = '';
const botUsername = String(CFG.telegramLoginBotUsername || '').trim();
if (!botUsername) {
setTelegramLinkStatus(t('telegram_auth_unavailable'), true);
return;
}
window.onTelegramLinkAuth = async function(user) {
await linkTelegramAccount(user);
};
const script = document.createElement('script');
script.async = true;
script.src = TELEGRAM_LOGIN_WIDGET_URL;
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-userpic', 'false');
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'onTelegramLinkAuth(user)');
script.onerror = () => setTelegramLinkStatus(t('telegram_auth_unavailable'), true);
container.appendChild(script);
state.telegramLinkRendered = true;
}
async function linkTelegramAccount(user) {
if (!user || state.telegramLinkInProgress) return;
state.telegramLinkInProgress = true;
setTelegramLinkStatus(t('telegram_auth_verifying'));
try {
const data = await api('/account/telegram/link', {
method: 'POST',
body: JSON.stringify({auth_data: user})
});
if (!data.ok) throw data;
await finishTelegramLink(data, user.id);
} catch (e) {
if (await refreshLinkedTelegramAfterError()) {
setTelegramLinkStatus('');
try {
showToast(t('telegram_linked'));
} catch (toastError) {
console.warn('Telegram link success toast failed', toastError);
}
return;
}
setTelegramLinkStatus(emailErrorMessage(e, 'telegram_auth_failed'), true);
} finally {
state.telegramLinkInProgress = false;
}
}
async function finishTelegramLink(data, fallbackTelegramId) {
if (data.token) setToken(data.token);
markTelegramLinked(data.telegram_id || fallbackTelegramId);
setTelegramLinkStatus('');
state.telegramLinkRendered = false;
try {
showToast(t('telegram_linked'));
} catch (toastError) {
console.warn('Telegram link success toast failed', toastError);
}
try {
await loadData();
} catch (refreshError) {
console.warn('Telegram account linked, but data refresh failed', refreshError);
}
}
function markTelegramLinked(telegramId) {
if (!state.data || !state.data.user) return;
state.data.user.telegram_linked = true;
if (telegramId) {
state.data.user.telegram_id = Number(telegramId);
}
renderAccount(state.data.user);
}
async function refreshLinkedTelegramAfterError() {
try {
await loadData();
return Boolean(state.data && state.data.user && state.data.user.telegram_linked);
} catch (refreshError) {
console.warn('Telegram link status refresh failed', refreshError);
return false;
}
}
function togglePaymentFlow() { function togglePaymentFlow() {
if (state.paymentFlowOpen) { if (state.paymentFlowOpen) {
closePaymentFlow(); closePaymentFlow();
@@ -438,7 +950,7 @@ const MOCK = (() => {
const modal = document.getElementById('payment-modal'); const modal = document.getElementById('payment-modal');
if (!state.paymentFlowOpen) { if (!state.paymentFlowOpen) {
modal.classList.remove('show'); modal.classList.remove('show');
document.body.classList.remove('modal-open'); syncModalLock();
window.setTimeout(() => { window.setTimeout(() => {
if (!state.paymentFlowOpen) modal.classList.add('hidden'); if (!state.paymentFlowOpen) modal.classList.add('hidden');
}, 180); }, 180);
@@ -446,7 +958,7 @@ const MOCK = (() => {
} }
modal.classList.remove('hidden'); modal.classList.remove('hidden');
document.body.classList.add('modal-open'); syncModalLock();
window.requestAnimationFrame(() => modal.classList.add('show')); window.requestAnimationFrame(() => modal.classList.add('show'));
applyI18n(modal); applyI18n(modal);
renderStepState(); renderStepState();
@@ -455,6 +967,13 @@ const MOCK = (() => {
renderPaymentResult(); renderPaymentResult();
} }
function syncModalLock() {
document.body.classList.toggle(
'modal-open',
Boolean(state.paymentFlowOpen || state.emailLoginCodeModalOpen)
);
}
function renderStepState() { function renderStepState() {
const order = ['plan', 'method', 'result']; const order = ['plan', 'method', 'result'];
order.forEach((step, index) => { order.forEach((step, index) => {
@@ -694,11 +1213,42 @@ const MOCK = (() => {
return response.json(); return response.json();
} }
async function publicApi(path, payload = {}) {
if (MOCK) {
return mockApi(path, {
method: 'POST',
body: JSON.stringify(payload)
});
}
const response = await fetch(CFG.apiBase + path, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
return response.json();
}
async function mockApi(path, options = {}) { async function mockApi(path, options = {}) {
await new Promise(resolve => window.setTimeout(resolve, 120)); await new Promise(resolve => window.setTimeout(resolve, 120));
if (path === '/me') { if (path === '/me') {
return JSON.parse(JSON.stringify(MOCK.data)); return JSON.parse(JSON.stringify(MOCK.data));
} }
if (path === '/auth/email/request' || path === '/account/email/request') {
return {ok: true};
}
if (path === '/auth/email/verify') {
return {ok: true, token: 'local-preview'};
}
if (path === '/account/email/verify') {
MOCK.data.user.email = 'preview@example.com';
MOCK.data.user.email_verified = true;
return {ok: true, token: 'local-preview'};
}
if (path === '/account/telegram/link') {
MOCK.data.user.telegram_linked = true;
MOCK.data.user.telegram_id = 100200300;
return {ok: true, token: 'local-preview'};
}
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') { if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
return { return {
ok: true, ok: true,
@@ -728,9 +1278,33 @@ const MOCK = (() => {
localStorage.removeItem('rw_webapp_token'); localStorage.removeItem('rw_webapp_token');
} }
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase();
}
function emailErrorMessage(error, fallbackKey) {
const code = error && error.error;
if (code === 'rate_limited') {
return t('email_rate_limited', {seconds: error.retry_after || 60});
}
if (code === 'invalid_email') return t('email_invalid');
if (code === 'expired_code') return t('email_code_expired');
if (code === 'invalid_code' || code === 'too_many_attempts') return t('email_code_invalid');
if (code === 'email_auth_not_configured') return t('email_auth_disabled');
if (code === 'account_merge_conflict') return t('account_merge_conflict');
if (code === 'invalid_auth') return t('telegram_auth_failed');
return t(fallbackKey);
}
function setButtonBusy(id, busy) {
const button = document.getElementById(id);
if (button) button.disabled = Boolean(busy);
}
function logout() { function logout() {
clearToken(); clearToken();
closePaymentFlow(); closePaymentFlow();
closeEmailLoginCodeModal();
startExternalAuth(); startExternalAuth();
} }
@@ -767,6 +1341,35 @@ const MOCK = (() => {
status.classList.toggle('error', Boolean(isError)); status.classList.toggle('error', Boolean(isError));
} }
function setEmailCodeStatus(message, isError = false) {
const status = document.getElementById('email-code-status');
if (!status) return;
if (!message) {
status.textContent = '';
status.classList.add('hidden');
status.classList.remove('error');
return;
}
status.textContent = message;
status.classList.remove('hidden');
status.classList.toggle('error', Boolean(isError));
}
function setTelegramLinkStatus(message, isError = false) {
const status = document.getElementById('telegram-link-status');
if (!status) return;
if (!message) {
status.textContent = '';
status.classList.add('hidden');
status.classList.remove('error');
return;
}
status.textContent = message;
status.classList.remove('hidden');
status.classList.toggle('error', Boolean(isError));
}
function showToast(message) { function showToast(message) {
if (tg && tg.showAlert) { if (tg && tg.showAlert) {
tg.showAlert(message); tg.showAlert(message);
@@ -826,6 +1429,9 @@ const MOCK = (() => {
root.querySelectorAll('[data-aria-i18n]').forEach(node => { root.querySelectorAll('[data-aria-i18n]').forEach(node => {
node.setAttribute('aria-label', t(node.dataset.ariaI18n)); node.setAttribute('aria-label', t(node.dataset.ariaI18n));
}); });
root.querySelectorAll('[data-placeholder-i18n]').forEach(node => {
node.setAttribute('placeholder', t(node.dataset.placeholderI18n));
});
} }
function applyLegalLinks(root = document) { function applyLegalLinks(root = document) {
+6 -3
View File
@@ -23,6 +23,7 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_logs_router") router = Router(name="admin_logs_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict, async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
@@ -225,12 +226,14 @@ async def process_user_id_for_logs_handler(message: types.Message,
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model_for_logs: Optional[User] = None user_model_for_logs: Optional[User] = None
if input_text.isdigit(): if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
try: try:
user_model_for_logs = await user_dal.get_user_by_id( user_model_for_logs = await user_dal.get_user_by_id(
session, int(input_text)) session, int(input_text))
except ValueError: except ValueError:
pass pass
elif EMAIL_REGEX.match(input_text):
user_model_for_logs = await user_dal.get_user_by_email(session, input_text)
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model_for_logs = await user_dal.get_user_by_username( user_model_for_logs = await user_dal.get_user_by_username(
session, input_text[1:]) session, input_text[1:])
@@ -245,7 +248,7 @@ async def process_user_id_for_logs_handler(message: types.Message,
target_user_id = user_model_for_logs.user_id target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or ( user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}" f"@{user_model_for_logs.username}"
if user_model_for_logs.username else f"ID {target_user_id}") if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
logs_models = await message_log_dal.get_user_message_logs( logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, 0) session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
@@ -292,7 +295,7 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
user_display_name = user_model_for_logs.first_name or ( user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}" f"@{user_model_for_logs.username}"
if user_model_for_logs.username else f"ID {target_user_id}") if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
logs_models = await message_log_dal.get_user_message_logs( logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, session, target_user_id, settings.LOGS_PAGE_SIZE,
+55 -3
View File
@@ -94,6 +94,7 @@ async def perform_sync(
"shortUuid" "shortUuid"
) )
telegram_id_from_panel = panel_user_dict.get("telegramId") telegram_id_from_panel = panel_user_dict.get("telegramId")
email_from_panel = (panel_user_dict.get("email") or "").strip().lower() or None
if not panel_uuid: if not panel_uuid:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}") sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
@@ -111,6 +112,10 @@ async def perform_sync(
# First, try to find by telegram ID if available # First, try to find by telegram ID if available
if telegram_id_from_panel: if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_telegram_id(
session, telegram_id_from_panel
)
if not existing_user:
existing_user = await user_dal.get_user_by_id( existing_user = await user_dal.get_user_by_id(
session, telegram_id_from_panel session, telegram_id_from_panel
) )
@@ -119,6 +124,13 @@ async def perform_sync(
f"Found user by telegramId {telegram_id_from_panel}" f"Found user by telegramId {telegram_id_from_panel}"
) )
if not existing_user and email_from_panel:
existing_user = await user_dal.get_user_by_email(
session, email_from_panel
)
if existing_user:
logging.debug(f"Found user by email {email_from_panel}")
# If not found by telegram ID, try to find by panel UUID # If not found by telegram ID, try to find by panel UUID
if not existing_user: if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid( existing_user = await user_dal.get_user_by_panel_uuid(
@@ -144,6 +156,8 @@ async def perform_sync(
try: try:
user_data = { user_data = {
"user_id": telegram_id_from_panel, "user_id": telegram_id_from_panel,
"telegram_id": telegram_id_from_panel,
"email": email_from_panel,
"username": None, # Username will be updated when user interacts with bot "username": None, # Username will be updated when user interacts with bot
"first_name": None, # Panel doesn't provide this info "first_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info "last_name": None, # Panel doesn't provide this info
@@ -172,6 +186,28 @@ async def perform_sync(
f"Error creating user {telegram_id_from_panel}: {e_create}" f"Error creating user {telegram_id_from_panel}: {e_create}"
) )
continue continue
elif email_from_panel:
try:
new_user, was_created = await user_dal.create_email_user(
session,
email=email_from_panel,
language_code="ru",
)
new_user.panel_user_uuid = panel_uuid
if was_created:
users_created += 1
logging.info(
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}"
)
existing_user = new_user
except Exception as e_create_email:
sync_errors.append(
f"Error creating email user {email_from_panel}: {str(e_create_email)}"
)
logging.error(
f"Error creating email user {email_from_panel}: {e_create_email}"
)
continue
else: else:
logging.debug( logging.debug(
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping" f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
@@ -193,6 +229,17 @@ async def perform_sync(
logging.info( logging.info(
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}" f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
) )
if email_from_panel and existing_user.email != email_from_panel:
existing_user.email = email_from_panel
if not existing_user.email_verified_at:
existing_user.email_verified_at = datetime.now(timezone.utc)
user_was_updated = True
if (
telegram_id_from_panel
and existing_user.telegram_id != telegram_id_from_panel
):
existing_user.telegram_id = telegram_id_from_panel
user_was_updated = True
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict) lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
if ( if (
@@ -206,11 +253,12 @@ async def perform_sync(
try: try:
if panel_uuid and existing_user: if panel_uuid and existing_user:
description_text = "\n".join( description_text = "\n".join(
[ line for line in [
existing_user.email or "",
existing_user.username or "", existing_user.username or "",
existing_user.first_name or "", existing_user.first_name or "",
existing_user.last_name or "", existing_user.last_name or "",
] ] if line
) )
# Update description only when it differs from the current one on panel # Update description only when it differs from the current one on panel
current_panel_description = ( current_panel_description = (
@@ -222,7 +270,11 @@ async def perform_sync(
and desired_description != current_panel_description and desired_description != current_panel_description
): ):
await panel_service.update_user_details_on_panel( await panel_service.update_user_details_on_panel(
panel_uuid, {"description": description_text} panel_uuid, {
"description": description_text,
**({"email": existing_user.email} if existing_user.email else {}),
**({"telegramId": existing_user.telegram_id} if existing_user.telegram_id else {}),
}
) )
except Exception as e_desc: except Exception as e_desc:
logging.warning( logging.warning(
+26 -36
View File
@@ -31,6 +31,7 @@ from bot.utils.telegram_markup import (
router = Router(name="admin_user_management_router") router = Router(name="admin_user_management_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]: def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
@@ -53,6 +54,24 @@ def _format_used_with_period(get_text: Callable[..., str], used_display: str, pe
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label) return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
async def _find_user_by_admin_input(
session: AsyncSession,
input_text: str,
) -> Optional[User]:
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
try:
return await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
return None
if EMAIL_REGEX.match(input_text):
return await user_dal.get_user_by_email(session, input_text)
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
return await user_dal.get_user_by_username(session, input_text[1:])
if USERNAME_REGEX.match(input_text):
return await user_dal.get_user_by_username(session, input_text)
return None
async def users_list_handler(callback: types.CallbackQuery, async def users_list_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings, i18n_data: dict, settings: Settings,
session: AsyncSession, page: int = 0): session: AsyncSession, page: int = 0):
@@ -250,6 +269,10 @@ async def format_user_card(user: User, session: AsyncSession,
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}") card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}") card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}") card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
if user.email:
card_parts.append(f"{_('admin_user_email_label')} {hcode(user.email)}")
if user.telegram_id and int(user.telegram_id) != int(user.user_id):
card_parts.append(f"{_('admin_user_telegram_id_label')} {hcode(str(user.telegram_id))}")
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}") card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}") card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
@@ -361,18 +384,7 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None user_model = await _find_user_by_admin_input(session, input_text)
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
if not user_model: if not user_model:
await message.answer(_( await message.answer(_(
@@ -1177,18 +1189,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None user_model = await _find_user_by_admin_input(session, input_text)
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
if not user_model: if not user_model:
await message.answer(_( await message.answer(_(
@@ -1244,18 +1245,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else "" input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None user_model = await _find_user_by_admin_input(session, input_text)
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
if not user_model: if not user_model:
await message.answer(_( await message.answer(_(
+4
View File
@@ -268,6 +268,8 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
user_display_parts.append(user_row.first_name) user_display_parts.append(user_row.first_name)
if user_row.username: if user_row.username:
user_display_parts.append(f"(@{user_row.username})") user_display_parts.append(f"(@{user_row.username})")
elif user_row.email:
user_display_parts.append(f"({user_row.email})")
if not user_display_parts: if not user_display_parts:
user_display_parts.append(f"ID: {user_row.user_id}") user_display_parts.append(f"ID: {user_row.user_id}")
@@ -321,6 +323,8 @@ def get_users_list_keyboard(users: List[User], current_page: int,
user_display_parts = [] user_display_parts = []
if user.username: if user.username:
user_display_parts.append(f"@{user.username}") user_display_parts.append(f"@{user.username}")
elif user.email:
user_display_parts.append(user.email)
user_display_parts.append(f"ID: {user.user_id}") user_display_parts.append(f"ID: {user.user_id}")
if user.first_name: if user.first_name:
user_display_parts.append(f"- {user.first_name}") user_display_parts.append(f"- {user.first_name}")
+13 -2
View File
@@ -22,6 +22,8 @@ class ProfileSyncMiddleware(BaseMiddleware):
if session and tg_user: if session and tg_user:
try: try:
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, tg_user.id) db_user = await user_dal.get_user_by_id(session, tg_user.id)
if db_user: if db_user:
update_payload: Dict[str, Any] = {} update_payload: Dict[str, Any] = {}
@@ -29,6 +31,8 @@ class ProfileSyncMiddleware(BaseMiddleware):
sanitized_first_name = sanitize_display_name(tg_user.first_name) sanitized_first_name = sanitize_display_name(tg_user.first_name)
sanitized_last_name = sanitize_display_name(tg_user.last_name) sanitized_last_name = sanitize_display_name(tg_user.last_name)
if db_user.telegram_id != tg_user.id:
update_payload["telegram_id"] = tg_user.id
if db_user.username != sanitized_username: if db_user.username != sanitized_username:
update_payload["username"] = sanitized_username update_payload["username"] = sanitized_username
if db_user.first_name != sanitized_first_name: if db_user.first_name != sanitized_first_name:
@@ -37,7 +41,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
update_payload["last_name"] = sanitized_last_name update_payload["last_name"] = sanitized_last_name
if update_payload: if update_payload:
await user_dal.update_user(session, tg_user.id, update_payload) await user_dal.update_user(session, db_user.user_id, update_payload)
logging.info( logging.info(
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
) )
@@ -47,13 +51,20 @@ class ProfileSyncMiddleware(BaseMiddleware):
panel_service = data.get("panel_service") panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid: if panel_service and db_user.panel_user_uuid:
description_text = "\n".join([ description_text = "\n".join([
db_user.email or "",
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "", username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
sanitized_first_name or "", sanitized_first_name or "",
sanitized_last_name or "", sanitized_last_name or "",
]).strip() ]).strip()
panel_payload = {
"description": description_text,
"telegramId": tg_user.id,
}
if db_user.email:
panel_payload["email"] = db_user.email
await panel_service.update_user_details_on_panel( await panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, db_user.panel_user_uuid,
{"description": description_text}, panel_payload,
) )
except Exception as e_upd_desc: except Exception as e_upd_desc:
logging.warning( logging.warning(
+328
View File
@@ -0,0 +1,328 @@
import asyncio
import hashlib
import hmac
import logging
import re
import secrets
import smtplib
import ssl
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from email.message import EmailMessage
from email.utils import formataddr
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.models import EmailVerificationCode
logger = logging.getLogger(__name__)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass(frozen=True)
class SmtpAttempt:
port: int
use_ssl: bool
starttls: bool
@dataclass(frozen=True)
class EmailCodeRequestResult:
ok: bool
error: Optional[str] = None
retry_after: Optional[int] = None
@dataclass(frozen=True)
class EmailCodeVerifyResult:
ok: bool
error: Optional[str] = None
def normalize_email(value: str) -> str:
return (value or "").strip().lower()
def is_valid_email(value: str) -> bool:
email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
class EmailAuthService:
def __init__(self, settings: Settings):
self.settings = settings
def _smtp_attempts(self) -> list[SmtpAttempt]:
attempts: list[SmtpAttempt] = []
primary_port = int(self.settings.SMTP_PORT)
for port in self.settings.smtp_ports_to_try:
if port == primary_port:
use_ssl = bool(self.settings.SMTP_USE_SSL or port == 465)
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
else:
use_ssl = port == 465
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
attempts.append(SmtpAttempt(port=port, use_ssl=use_ssl, starttls=starttls))
return attempts or [
SmtpAttempt(
port=primary_port,
use_ssl=bool(self.settings.SMTP_USE_SSL or primary_port == 465),
starttls=bool(
self.settings.SMTP_STARTTLS
and not self.settings.SMTP_USE_SSL
and primary_port != 465
),
)
]
def _hash_code(self, email: str, purpose: str, code: str) -> str:
secret = hmac.new(
self.settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-email-code",
hashlib.sha256,
).digest()
payload = f"{purpose}:{email}:{code}".encode("utf-8")
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
async def request_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
language_code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeRequestResult:
normalized_email = normalize_email(email)
if not self.settings.email_auth_configured:
return EmailCodeRequestResult(ok=False, error="email_auth_not_configured")
if not is_valid_email(normalized_email):
return EmailCodeRequestResult(ok=False, error="invalid_email")
now = datetime.now(timezone.utc)
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if latest_code and latest_code.created_at:
created_at = latest_code.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
resend_after = max(1, int(self.settings.EMAIL_CODE_RESEND_SECONDS))
elapsed = int((now - created_at).total_seconds())
if elapsed < resend_after and latest_code.consumed_at is None:
return EmailCodeRequestResult(
ok=False,
error="rate_limited",
retry_after=resend_after - elapsed,
)
code = f"{secrets.randbelow(1_000_000):06d}"
code_model = EmailVerificationCode(
email=normalized_email,
code_hash=self._hash_code(normalized_email, purpose, code),
purpose=purpose,
target_user_id=target_user_id,
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
)
session.add(code_model)
await session.flush()
await self._send_code_email(
email=normalized_email,
code=code,
language_code=language_code,
)
return EmailCodeRequestResult(ok=True)
async def verify_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeVerifyResult:
normalized_email = normalize_email(email)
normalized_code = re.sub(r"\D", "", code or "")
if not is_valid_email(normalized_email) or len(normalized_code) != 6:
return EmailCodeVerifyResult(ok=False, error="invalid_code")
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if not latest_code or latest_code.consumed_at is not None:
return EmailCodeVerifyResult(ok=False, error="invalid_code")
now = datetime.now(timezone.utc)
expires_at = latest_code.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
return EmailCodeVerifyResult(ok=False, error="expired_code")
max_attempts = max(1, int(self.settings.EMAIL_CODE_MAX_ATTEMPTS))
if int(latest_code.attempts or 0) >= max_attempts:
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
latest_code.attempts = int(latest_code.attempts or 0) + 1
await session.flush()
return EmailCodeVerifyResult(ok=False, error="invalid_code")
latest_code.consumed_at = now
await session.flush()
return EmailCodeVerifyResult(ok=True)
async def _get_latest_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
target_user_id: Optional[int],
) -> Optional[EmailVerificationCode]:
stmt = (
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
)
.order_by(EmailVerificationCode.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def _send_code_email(
self,
*,
email: str,
code: str,
language_code: str,
) -> None:
await asyncio.to_thread(
self._send_code_email_sync,
email=email,
code=code,
language_code=language_code,
)
def _send_code_email_sync(
self,
*,
email: str,
code: str,
language_code: str,
) -> None:
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
if lang == "en":
subject = "Your login code"
body = (
f"Your verification code: {code}\n\n"
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
)
else:
subject = "Код подтверждения"
body = (
f"Ваш код подтверждения: {code}\n\n"
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
)
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(body)
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
attempts = self._smtp_attempts()
last_error: Optional[BaseException] = None
for attempt_number, attempt in enumerate(attempts, start=1):
try:
self._send_message_via_smtp(
message=message,
smtp_host=smtp_host,
smtp_port=attempt.port,
timeout=timeout,
context=context,
use_ssl=attempt.use_ssl,
starttls=attempt.starttls,
)
logger.info(
"Email verification code sent to %s via %s:%s",
email,
smtp_host,
attempt.port,
)
return
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
last_error = exc
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed via %s:%s (ssl=%s, starttls=%s): %s",
attempt_number,
len(attempts),
smtp_host,
attempt.port,
attempt.use_ssl,
attempt.starttls,
exc,
)
if last_error:
raise last_error
def _send_message_via_smtp(
self,
*,
message: EmailMessage,
smtp_host: str,
smtp_port: int,
timeout: int,
context: ssl.SSLContext,
use_ssl: bool,
starttls: bool,
) -> None:
if use_ssl:
with smtplib.SMTP_SSL(
smtp_host,
smtp_port,
context=context,
timeout=timeout,
) as smtp:
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
return
with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as smtp:
smtp.ehlo()
if starttls:
smtp.starttls(context=context)
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
+5 -2
View File
@@ -54,7 +54,10 @@ class PanelWebhookService:
return return
async with self.async_session_factory() as session: async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
internal_user_id = db_user.user_id if db_user else user_id
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}" first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
@@ -69,7 +72,7 @@ class PanelWebhookService:
if subscription_service: if subscription_service:
async with self.async_session_factory() as session: async with self.async_session_factory() as session:
from db.dal import subscription_dal from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa': if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
try: try:
ok = await subscription_service.charge_subscription_renewal(session, sub) ok = await subscription_service.charge_subscription_renewal(session, sub)
@@ -89,7 +92,7 @@ class PanelWebhookService:
if days_left == 2: if days_left == 2:
async with self.async_session_factory() as session: async with self.async_session_factory() as session:
from db.dal import subscription_dal from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
logging.info( logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s", "48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id, user_id,
+73 -53
View File
@@ -96,6 +96,42 @@ class SubscriptionService:
f"Failed to notify admin {admin_id} about panel user creation failure: {e}" f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
) )
def _telegram_id_for_panel(self, db_user: User) -> Optional[int]:
if db_user.telegram_id:
return int(db_user.telegram_id)
if db_user.user_id and int(db_user.user_id) > 0:
return int(db_user.user_id)
return None
async def _panel_username_for_user(
self, session: AsyncSession, db_user: User
) -> str:
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id and int(db_user.user_id) == telegram_id:
return f"tg_{telegram_id}"
referral_code = await user_dal.ensure_referral_code(session, db_user)
return f"em_{referral_code}"
def _panel_description_for_user(self, db_user: User) -> str:
lines = [
db_user.email or "",
db_user.username or "",
db_user.first_name or "",
db_user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"description": self._panel_description_for_user(db_user),
}
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id:
payload["telegramId"] = telegram_id
if db_user.email:
payload["email"] = db_user.email
return payload
async def _get_or_create_panel_user_link_details( async def _get_or_create_panel_user_link_details(
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]: ) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
@@ -109,22 +145,42 @@ class SubscriptionService:
return None, None, None, False return None, None, None, False
current_local_panel_uuid = db_user.panel_user_uuid current_local_panel_uuid = db_user.panel_user_uuid
panel_username_on_panel_standard = f"tg_{user_id}" panel_username_on_panel_standard = await self._panel_username_for_user(
session, db_user
)
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
panel_user_obj_from_api = None panel_user_obj_from_api = None
panel_user_created_or_linked_now = False panel_user_created_or_linked_now = False
panel_users_by_tg_id_list = None
if telegram_id_for_panel:
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter( panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
telegram_id=user_id telegram_id=telegram_id_for_panel
) )
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1: if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
panel_user_obj_from_api = panel_users_by_tg_id_list[0] panel_user_obj_from_api = panel_users_by_tg_id_list[0]
logging.info( logging.info(
f"Found panel user by telegramId {user_id}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
) )
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1: elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
logging.error( logging.error(
f"CRITICAL: Multiple panel users found for telegramId {user_id}. Manual intervention needed." f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed."
)
return None, None, None, False
if not panel_user_obj_from_api and db_user.email:
panel_users_by_email_list = await self.panel_service.get_users_by_filter(
email=db_user.email
)
if panel_users_by_email_list and len(panel_users_by_email_list) == 1:
panel_user_obj_from_api = panel_users_by_email_list[0]
logging.info(
f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
)
elif panel_users_by_email_list and len(panel_users_by_email_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed."
) )
return None, None, None, False return None, None, None, False
@@ -146,12 +202,9 @@ class SubscriptionService:
) )
creation_response = await self.panel_service.create_panel_user( creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard, username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id, telegram_id=telegram_id_for_panel,
description="\n".join([ email=db_user.email,
(db_user.username or "") if db_user else "", description=self._panel_description_for_user(db_user),
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]),
specific_squad_uuids=self.settings.parsed_user_squad_uuids, specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid, external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
@@ -175,12 +228,9 @@ class SubscriptionService:
) )
creation_response = await self.panel_service.create_panel_user( creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard, username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id, telegram_id=telegram_id_for_panel,
description="\n".join([ email=db_user.email,
(db_user.username or "") if db_user else "", description=self._panel_description_for_user(db_user),
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]),
specific_squad_uuids=self.settings.parsed_user_squad_uuids, specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid, external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
@@ -226,7 +276,6 @@ class SubscriptionService:
) )
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid") actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId") panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
if not actual_panel_uuid_from_api: if not actual_panel_uuid_from_api:
@@ -293,24 +342,15 @@ class SubscriptionService:
if ( if (
panel_user_obj_from_api panel_user_obj_from_api
and current_local_panel_uuid and current_local_panel_uuid
and panel_telegram_id_int != user_id and telegram_id_for_panel
and panel_telegram_id_int != telegram_id_for_panel
): ):
logging.info( logging.info(
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'." f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'."
) )
# Also set readable description with Telegram fields
await self.panel_service.update_user_details_on_panel( await self.panel_service.update_user_details_on_panel(
current_local_panel_uuid, current_local_panel_uuid,
{ self._panel_identity_payload_for_user(db_user),
"telegramId": user_id,
"description": "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
),
},
) )
panel_sub_link_id = panel_user_obj_from_api.get( panel_sub_link_id = panel_user_obj_from_api.get(
@@ -408,14 +448,7 @@ class SubscriptionService:
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes, traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
) )
# Add user description based on Telegram profile panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
panel_update_payload["description"] = "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
)
updated_panel_user = await self.panel_service.update_user_details_on_panel( updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload panel_user_uuid, panel_update_payload
@@ -525,13 +558,7 @@ class SubscriptionService:
traffic_limit_strategy="NO_RESET", traffic_limit_strategy="NO_RESET",
) )
panel_update_payload["description"] = "\n".join( panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
)
updated_panel_user = await self.panel_service.update_user_details_on_panel( updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload panel_user_uuid, panel_update_payload
@@ -695,14 +722,7 @@ class SubscriptionService:
traffic_limit_bytes=self.settings.user_traffic_limit_bytes, traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
) )
# Add user description based on Telegram profile panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
panel_update_payload["description"] = "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
)
updated_panel_user = await self.panel_service.update_user_details_on_panel( updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload panel_user_uuid, panel_update_payload
+48
View File
@@ -215,6 +215,20 @@ class Settings(BaseSettings):
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60) WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60) WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
SMTP_PORT: int = Field(default=587)
SMTP_FALLBACK_PORTS: Optional[str] = Field(default="2525,465")
SMTP_TIMEOUT_SECONDS: int = Field(default=30)
SMTP_USERNAME: Optional[str] = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
SMTP_FROM_NAME: Optional[str] = Field(default=None)
SMTP_STARTTLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
LOGS_PAGE_SIZE: int = Field(default=10) LOGS_PAGE_SIZE: int = Field(default=10)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None) SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
@@ -533,6 +547,35 @@ class Settings(BaseSettings):
methods.append(slug) methods.append(slug)
return methods or default_order return methods or default_order
@computed_field
@property
def email_auth_configured(self) -> bool:
return bool(
self.SMTP_HOST
and self.SMTP_PORT
and self.SMTP_USERNAME
and self.SMTP_PASSWORD
and self.SMTP_FROM_EMAIL
)
@computed_field
@property
def smtp_ports_to_try(self) -> List[int]:
ports: List[int] = []
def add_port(value: Any) -> None:
try:
port = int(str(value).strip())
except (TypeError, ValueError):
return
if 0 < port <= 65535 and port not in ports:
ports.append(port)
add_port(self.SMTP_PORT)
for item in (self.SMTP_FALLBACK_PORTS or "").split(","):
add_port(item)
return ports
# Logging Configuration # Logging Configuration
LOG_LEVEL: str = Field( LOG_LEVEL: str = Field(
default="INFO", default="INFO",
@@ -568,6 +611,11 @@ class Settings(BaseSettings):
'USER_AGREEMENT_URL', 'USER_AGREEMENT_URL',
'SUBSCRIPTION_MINI_APP_URL', 'SUBSCRIPTION_MINI_APP_URL',
'WEBAPP_LOGO_URL', 'WEBAPP_LOGO_URL',
'SMTP_USERNAME',
'SMTP_PASSWORD',
'SMTP_FROM_EMAIL',
'SMTP_FROM_NAME',
'SMTP_FALLBACK_PORTS',
mode='before', mode='before',
) )
@classmethod @classmethod
+241
View File
@@ -24,6 +24,11 @@ from ..models import (
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
REFERRAL_CODE_LENGTH = 9 REFERRAL_CODE_LENGTH = 9
MAX_REFERRAL_CODE_ATTEMPTS = 25 MAX_REFERRAL_CODE_ATTEMPTS = 25
MAX_EMAIL_USER_ID_ATTEMPTS = 25
class UserMergeConflictError(ValueError):
pass
def _generate_referral_code_candidate() -> str: def _generate_referral_code_candidate() -> str:
@@ -50,6 +55,14 @@ async def generate_unique_referral_code(session: AsyncSession) -> str:
raise RuntimeError("Failed to generate a unique referral code after several attempts.") raise RuntimeError("Failed to generate a unique referral code after several attempts.")
async def generate_unique_email_user_id(session: AsyncSession) -> int:
for _ in range(MAX_EMAIL_USER_ID_ATTEMPTS):
candidate = -(secrets.randbelow(9_000_000_000_000_000) + 1)
if not await get_user_by_id(session, candidate):
return candidate
raise RuntimeError("Failed to generate a unique email user id after several attempts.")
async def ensure_referral_code(session: AsyncSession, user: User) -> str: async def ensure_referral_code(session: AsyncSession, user: User) -> str:
""" """
Ensure the provided user has a referral code, generating and persisting it if missing. Ensure the provided user has a referral code, generating and persisting it if missing.
@@ -82,6 +95,23 @@ async def get_user_by_username(session: AsyncSession, username: str) -> Optional
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]:
clean_email = (email or "").strip().lower()
if not clean_email:
return None
stmt = select(User).where(func.lower(User.email) == clean_email)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_telegram_id(
session: AsyncSession, telegram_id: int
) -> Optional[User]:
stmt = select(User).where(User.telegram_id == telegram_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_panel_uuid( async def get_user_by_panel_uuid(
session: AsyncSession, panel_uuid: str session: AsyncSession, panel_uuid: str
) -> Optional[User]: ) -> Optional[User]:
@@ -135,6 +165,217 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
return user, created return user, created
async def create_email_user(
session: AsyncSession,
*,
email: str,
language_code: str,
email_verified_at: Optional[datetime] = None,
referred_by_id: Optional[int] = None,
) -> Tuple[User, bool]:
normalized_email = (email or "").strip().lower()
user_id = await generate_unique_email_user_id(session)
return await create_user(
session,
{
"user_id": user_id,
"email": normalized_email,
"email_verified_at": email_verified_at or datetime.now(timezone.utc),
"language_code": language_code,
"referred_by_id": referred_by_id,
"registration_date": datetime.now(timezone.utc),
},
)
async def _has_active_panel_subscription(
session: AsyncSession, user_id: int, panel_user_uuid: str
) -> bool:
stmt = (
select(Subscription.subscription_id)
.where(
Subscription.user_id == user_id,
Subscription.panel_user_uuid == panel_user_uuid,
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def merge_users(
session: AsyncSession,
*,
source_user_id: int,
target_user_id: int,
) -> User:
"""Merge source user data into target user and remove the source row."""
if source_user_id == target_user_id:
target = await get_user_by_id(session, target_user_id)
if not target:
raise ValueError("Target user not found.")
return target
source = await get_user_by_id(session, source_user_id)
target = await get_user_by_id(session, target_user_id)
if not source or not target:
raise ValueError("Both source and target users are required for merge.")
if source.email and target.email and source.email != target.email:
raise UserMergeConflictError("Both accounts already have different emails.")
if (
source.telegram_id
and target.telegram_id
and int(source.telegram_id) != int(target.telegram_id)
):
raise UserMergeConflictError("Both accounts already have different Telegram IDs.")
source_panel_uuid = source.panel_user_uuid
target_panel_uuid = target.panel_user_uuid
panel_uuid_to_move = None
if source_panel_uuid and not target_panel_uuid:
panel_uuid_to_move = source_panel_uuid
elif source_panel_uuid and target_panel_uuid and source_panel_uuid != target_panel_uuid:
source_has_active = await _has_active_panel_subscription(
session, source_user_id, source_panel_uuid
)
target_has_active = await _has_active_panel_subscription(
session, target_user_id, target_panel_uuid
)
if source_has_active and target_has_active:
raise UserMergeConflictError(
"Both accounts have active subscriptions on different panel users."
)
if source_has_active and not target_has_active:
panel_uuid_to_move = source_panel_uuid
email_to_move = source.email if source.email and not target.email else None
email_verified_at_to_move = (
source.email_verified_at
if source.email and (not target.email_verified_at or email_to_move)
else None
)
telegram_id_to_move = (
source.telegram_id if source.telegram_id and not target.telegram_id else None
)
referral_code_to_move = (
source.referral_code if source.referral_code and not target.referral_code else None
)
if email_to_move:
source.email = None
if panel_uuid_to_move:
source.panel_user_uuid = None
if telegram_id_to_move:
source.telegram_id = None
if referral_code_to_move:
source.referral_code = None
if email_to_move or panel_uuid_to_move or telegram_id_to_move or referral_code_to_move:
await session.flush()
if email_to_move:
target.email = email_to_move
if email_verified_at_to_move and not target.email_verified_at:
target.email_verified_at = email_verified_at_to_move
if telegram_id_to_move:
target.telegram_id = telegram_id_to_move
if panel_uuid_to_move:
target.panel_user_uuid = panel_uuid_to_move
if referral_code_to_move:
target.referral_code = referral_code_to_move
for attr in ("username", "first_name", "last_name", "language_code"):
if not getattr(target, attr) and getattr(source, attr):
setattr(target, attr, getattr(source, attr))
if not target.referred_by_id and source.referred_by_id != target_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == source_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == target_user_id:
target.referred_by_id = None
target_method_ids = select(UserPaymentMethod.provider_payment_method_id).where(
UserPaymentMethod.user_id == target_user_id
)
await session.execute(
delete(UserPaymentMethod).where(
UserPaymentMethod.user_id == source_user_id,
UserPaymentMethod.provider_payment_method_id.in_(target_method_ids),
)
)
target_promo_ids = select(PromoCodeActivation.promo_code_id).where(
PromoCodeActivation.user_id == target_user_id
)
await session.execute(
delete(PromoCodeActivation).where(
PromoCodeActivation.user_id == source_user_id,
PromoCodeActivation.promo_code_id.in_(target_promo_ids),
)
)
target_has_billing = (
await session.execute(
select(UserBilling.user_id).where(UserBilling.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_billing:
await session.execute(delete(UserBilling).where(UserBilling.user_id == source_user_id))
else:
await session.execute(
update(UserBilling)
.where(UserBilling.user_id == source_user_id)
.values(user_id=target_user_id)
)
target_has_attribution = (
await session.execute(
select(AdAttribution.user_id).where(AdAttribution.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_attribution:
await session.execute(
delete(AdAttribution).where(AdAttribution.user_id == source_user_id)
)
else:
await session.execute(
update(AdAttribution)
.where(AdAttribution.user_id == source_user_id)
.values(user_id=target_user_id)
)
for model in (Subscription, Payment, PromoCodeActivation, UserPaymentMethod):
await session.execute(
update(model)
.where(model.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.target_user_id == source_user_id)
.values(target_user_id=target_user_id)
)
await session.execute(
update(User)
.where(User.referred_by_id == source_user_id)
.values(referred_by_id=target_user_id)
)
await session.delete(source)
await session.flush()
await session.refresh(target)
return target
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]: async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
normalized = referral_code.strip().upper() normalized = referral_code.strip().upper()
if not normalized: if not normalized:
+82
View File
@@ -125,6 +125,83 @@ def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None:
) )
) )
def _migration_0005_add_email_auth_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "email" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR"))
if "email_verified_at" not in columns:
connection.execute(
text("ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ")
)
if "telegram_id" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN telegram_id BIGINT"))
connection.execute(
text(
"""
UPDATE users
SET telegram_id = user_id
WHERE telegram_id IS NULL
AND user_id > 0
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email
ON users (email)
WHERE email IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_telegram_id
ON users (telegram_id)
WHERE telegram_id IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS email_verification_codes (
code_id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
code_hash VARCHAR NOT NULL,
purpose VARCHAR NOT NULL,
target_user_id BIGINT NULL REFERENCES users(user_id),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_lookup
ON email_verification_codes (email, purpose, target_user_id, created_at DESC)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_expires_at
ON email_verification_codes (expires_at)
"""
)
)
MIGRATIONS: List[Migration] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -146,6 +223,11 @@ MIGRATIONS: List[Migration] = [
description="Store lifetime traffic usage for users", description="Store lifetime traffic usage for users",
upgrade=_migration_0004_add_lifetime_used_traffic, upgrade=_migration_0004_add_lifetime_used_traffic,
), ),
Migration(
id="0005_add_email_auth_fields",
description="Add email login identities and verification codes",
upgrade=_migration_0005_add_email_auth_fields,
),
] ]
+24
View File
@@ -14,6 +14,9 @@ class User(Base):
user_id = Column(BigInteger, primary_key=True, index=True) user_id = Column(BigInteger, primary_key=True, index=True)
username = Column(String, nullable=True, index=True) username = Column(String, nullable=True, index=True)
email = Column(String, nullable=True, unique=True, index=True)
email_verified_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
first_name = Column(String, nullable=True) first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True) last_name = Column(String, nullable=True)
language_code = Column(String, default="ru") language_code = Column(String, default="ru")
@@ -86,6 +89,27 @@ class Subscription(Base):
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>" return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>"
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
code_id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String, nullable=False, index=True)
code_hash = Column(String, nullable=False)
purpose = Column(String, nullable=False, index=True)
target_user_id = Column(
BigInteger,
ForeignKey("users.user_id"),
nullable=True,
index=True,
)
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
consumed_at = Column(DateTime(timezone=True), nullable=True)
attempts = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
target_user = relationship("User")
class Payment(Base): class Payment(Base):
__tablename__ = "payments" __tablename__ = "payments"
+6 -4
View File
@@ -234,9 +234,9 @@
"admin_promo_edit_bonus_days": "🎁 Bonus Days", "admin_promo_edit_bonus_days": "🎁 Bonus Days",
"admin_promo_edit_max_activations": "🔢 Max Activations", "admin_promo_edit_max_activations": "🔢 Max Activations",
"admin_promo_edit_validity": "⏰ Validity", "admin_promo_edit_validity": "⏰ Validity",
"admin_ban_user_prompt": "Enter user ID or @username to ban:", "admin_ban_user_prompt": "Enter user ID, @username, or email to ban:",
"admin_user_already_banned": "User {user_id_or_username} is already banned.", "admin_user_already_banned": "User {user_id_or_username} is already banned.",
"admin_unban_user_prompt": "Enter user ID or @username to unban:", "admin_unban_user_prompt": "Enter user ID, @username, or email to unban:",
"admin_user_not_banned": "⚠️ User is not banned", "admin_user_not_banned": "⚠️ User is not banned",
"admin_banned_user_button_text": "{user_display} (ID: {user_id})", "admin_banned_user_button_text": "{user_display} (ID: {user_id})",
"prev_page_button": "⬅️ Prev.", "prev_page_button": "⬅️ Prev.",
@@ -253,7 +253,7 @@
"admin_export_logs_csv_button": "📄 Export to CSV", "admin_export_logs_csv_button": "📄 Export to CSV",
"admin_no_logs_found": "No logs found.", "admin_no_logs_found": "No logs found.",
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}", "admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
"admin_prompt_for_user_id_or_username_logs": "Enter user ID or @username to view logs:", "admin_prompt_for_user_id_or_username_logs": "Enter user ID, @username, or email to view logs:",
"admin_log_user_not_found": "User \"{input}\" not found in bot database.", "admin_log_user_not_found": "User \"{input}\" not found in bot database.",
"sync_started_simple": "🔄 Starting synchronization...", "sync_started_simple": "🔄 Starting synchronization...",
"sync_success_simple": "✅ Synchronization completed successfully", "sync_success_simple": "✅ Synchronization completed successfully",
@@ -273,7 +273,7 @@
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.", "autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.", "autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}", "yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:", "admin_user_management_prompt": "👤 User Management\n\nEnter user ID, @username, or email to search:",
"admin_user_subscription_info": "Subscription Information:", "admin_user_subscription_info": "Subscription Information:",
"admin_user_reset_trial_button": "🔄 Reset Trial", "admin_user_reset_trial_button": "🔄 Reset Trial",
"admin_user_add_subscription_button": " Add Days", "admin_user_add_subscription_button": " Add Days",
@@ -369,6 +369,8 @@
"admin_user_id_label": "🆔 <b>ID:</b>", "admin_user_id_label": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Name:</b>", "admin_user_name_label": "👤 <b>Name:</b>",
"admin_user_username_label": "📱 <b>Username:</b>", "admin_user_username_label": "📱 <b>Username:</b>",
"admin_user_email_label": "✉️ <b>Email:</b>",
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
"admin_user_language_label": "🌍 <b>Language:</b>", "admin_user_language_label": "🌍 <b>Language:</b>",
"admin_user_registration_label": "📅 <b>Registration:</b>", "admin_user_registration_label": "📅 <b>Registration:</b>",
"admin_user_status_label": "🛡 <b>Status:</b>", "admin_user_status_label": "🛡 <b>Status:</b>",
+6 -4
View File
@@ -243,9 +243,9 @@
"admin_promo_edit_bonus_days": "🎁 Бонусные дни", "admin_promo_edit_bonus_days": "🎁 Бонусные дни",
"admin_promo_edit_max_activations": "🔢 Макс. активации", "admin_promo_edit_max_activations": "🔢 Макс. активации",
"admin_promo_edit_validity": "⏰ Срок действия", "admin_promo_edit_validity": "⏰ Срок действия",
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:", "admin_ban_user_prompt": "Введите ID, @username или email пользователя для блокировки:",
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.", "admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:", "admin_unban_user_prompt": "Введите ID, @username или email пользователя для разблокировки:",
"admin_user_not_banned": "⚠️ Пользователь не заблокирован", "admin_user_not_banned": "⚠️ Пользователь не заблокирован",
"admin_banned_user_button_text": "{user_display} (ID: {user_id})", "admin_banned_user_button_text": "{user_display} (ID: {user_id})",
"prev_page_button": "⬅️ Пред.", "prev_page_button": "⬅️ Пред.",
@@ -262,7 +262,7 @@
"admin_export_logs_csv_button": "📄 Экспорт в CSV", "admin_export_logs_csv_button": "📄 Экспорт в CSV",
"admin_no_logs_found": "Логи не найдены.", "admin_no_logs_found": "Логи не найдены.",
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}", "admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:", "admin_prompt_for_user_id_or_username_logs": "Введите ID, @username или email пользователя для просмотра его логов:",
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.", "admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
"sync_started_simple": "🔄 Начинаю синхронизацию...", "sync_started_simple": "🔄 Начинаю синхронизацию...",
"sync_success_simple": "✅ Синхронизация успешно завершена", "sync_success_simple": "✅ Синхронизация успешно завершена",
@@ -273,7 +273,7 @@
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.", "admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.", "error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
"error_displaying_statistics": "Ошибка отображения статистики.", "error_displaying_statistics": "Ошибка отображения статистики.",
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:", "admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя, @username или email для поиска:",
"admin_user_subscription_info": "Информация о подписке:", "admin_user_subscription_info": "Информация о подписке:",
"admin_user_reset_trial_button": "🔄 Сбросить триал", "admin_user_reset_trial_button": "🔄 Сбросить триал",
"admin_user_add_subscription_button": "➕ Добавить дни", "admin_user_add_subscription_button": "➕ Добавить дни",
@@ -369,6 +369,8 @@
"admin_user_id_label": "🆔 <b>ID:</b>", "admin_user_id_label": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Имя:</b>", "admin_user_name_label": "👤 <b>Имя:</b>",
"admin_user_username_label": "📱 <b>Username:</b>", "admin_user_username_label": "📱 <b>Username:</b>",
"admin_user_email_label": "✉️ <b>Email:</b>",
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
"admin_user_language_label": "🌍 <b>Язык:</b>", "admin_user_language_label": "🌍 <b>Язык:</b>",
"admin_user_registration_label": "📅 <b>Регистрация:</b>", "admin_user_registration_label": "📅 <b>Регистрация:</b>",
"admin_user_status_label": "🛡 <b>Статус:</b>", "admin_user_status_label": "🛡 <b>Статус:</b>",