From 4f1b7d08328c67e026704bbe00e4c9feb9305ca4 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 22 Apr 2026 16:00:55 +0300 Subject: [PATCH] feat: web app --- .env.example | 13 +- README.md | 87 +- bot/app/web/subscription_webapp.py | 887 ++++++++++++++++++ .../web/templates/subscription_webapp.html | 791 ++++++++++++++++ bot/app/web/web_server.py | 61 +- bot/app/web/webapp_auth.py | 173 ++++ bot/handlers/user/start.py | 18 +- bot/keyboards/inline/user_keyboards.py | 18 +- config/settings.py | 16 + docker-compose-remote-server.yml | 1 + docker-compose.yml | 1 + locales/en.json | 2 + locales/ru.json | 2 + 13 files changed, 2045 insertions(+), 25 deletions(-) create mode 100644 bot/app/web/subscription_webapp.py create mode 100644 bot/app/web/templates/subscription_webapp.html create mode 100644 bot/app/web/webapp_auth.py diff --git a/.env.example b/.env.example index 69e3bf3..68b28bc 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement -SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app +SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/ START_COMMAND_DESCRIPTION= # Description of the /start command DISABLE_WELCOME_MESSAGE= # Disable the welcome message MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu @@ -32,6 +32,17 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel # # Webhook Base URL (used for Telegram and payment providers) WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld +# Subscription Mini App (same container, separate port) +WEBAPP_ENABLED=True # Run Mini App HTTP server +WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host +WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port +WEBAPP_TITLE="Моя подписка" # Mini App title +WEBAPP_PRIMARY_COLOR="#10b981" # Main UI color +WEBAPP_LOGO_URL= # Optional logo URL +WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime +WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age +WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime + # Payment Method Toggles YOOKASSA_ENABLED=True # Turn on YOOKASSA FREEKASSA_ENABLED=True # Turn on FreeKassa diff --git a/README.md b/README.md index c952878..daf7ee1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ### Для пользователей: - **Регистрация и выбор языка:** Поддержка русского и английского языков. - **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию. +- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки. - **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`). - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. @@ -65,9 +66,14 @@ | `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` | | `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` | | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | - | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | - | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` | - | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | +| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | +| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) Публичный URL Mini App для показа подписки. Если задан, кнопка «Моя подписка» откроет Web App. | `https://app.domain.com/` | +| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` | +| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` | +| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` | +| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#10b981` | +| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. | `https://domain.com/logo.png` | +| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` | @@ -77,10 +83,12 @@ | Переменная | Описание | | --- | --- | - | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | - | `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | - | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | - | `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. | +| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | +| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | +| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | +| `WEBAPP_SERVER_HOST` | Хост отдельного веб-сервера Mini App. | `0.0.0.0` | +| `WEBAPP_SERVER_PORT` | Порт отдельного веб-сервера Mini App. | `8081` | +| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. | | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | @@ -170,6 +178,11 @@ Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `` — порт, указанный в `.env`. + **Отдельный порт Web App:** + - `https://<домен_web_app>/` → `http://remnawave-tg-shop:/` + + Web App не должен проксироваться на `WEB_SERVER_PORT`: этот порт оставьте для Telegram, платежных и Remnawave webhooks. + 5. **Просмотр логов:** ```bash docker compose logs -f remnawave-tg-shop @@ -177,6 +190,66 @@ > 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. +### Настройка Web App / Mini App + +Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, используется fallback через deep-link в бота. + +1. Укажите в `.env` публичный URL Web App и порт: + + ```env + WEBAPP_ENABLED=True + WEBAPP_SERVER_HOST=0.0.0.0 + WEBAPP_SERVER_PORT=8081 + SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ + WEBAPP_TITLE="Моя подписка" + WEBAPP_PRIMARY_COLOR="#10b981" + WEBAPP_LOGO_URL= + ``` + +2. Убедитесь, что `docker-compose.yml` публикует порт Web App: + + ```yaml + ports: + - 127.0.0.1:8080:8080 + - 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081} + ``` + +3. Проксируйте отдельный домен или location на порт Web App: + + ```nginx + upstream remnawave-tg-shop-webapp { + server remnawave-tg-shop:8081; + } + + server { + server_name app.domain.com; + listen 443 ssl; + http2 on; + + ssl_certificate "/etc/nginx/ssl/app_fullchain.pem"; + ssl_certificate_key "/etc/nginx/ssl/app_privkey.key"; + + location / { + proxy_pass http://remnawave-tg-shop-webapp; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + ``` + +4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. + +5. Перезапустите контейнер: + + ```bash + docker compose up -d --build + ``` + +После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени, трафик, тарифы и создает платежные ссылки через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками. + ## Подробная инструкция для развертывания на сервере с панелью Remnawave ### 1. Клонирование репозитория diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py new file mode 100644 index 0000000..731091c --- /dev/null +++ b/bot/app/web/subscription_webapp.py @@ -0,0 +1,887 @@ +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from aiohttp import web +from aiogram import Bot, Dispatcher +from aiogram.types import LabeledPrice +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.app.web.webapp_auth import ( + consume_authorized_webapp_auth_token, + create_pending_webapp_auth_token, + create_webapp_session_token, + validate_telegram_webapp_init_data, + verify_webapp_session_token, +) +from bot.services.crypto_pay_service import CryptoPayService +from bot.services.freekassa_service import FreeKassaService +from bot.services.platega_service import PlategaService +from bot.services.severpay_service import SeverPayService +from bot.services.subscription_service import SubscriptionService +from bot.services.yookassa_service import YooKassaService +from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username +from config.settings import Settings +from db.dal import payment_dal, subscription_dal, user_dal +from db.models import Payment, User + +logger = logging.getLogger(__name__) + +TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html" + + +def create_subscription_webapp_application( + dp: Dispatcher, + bot: Bot, + settings: Settings, + async_session_factory: sessionmaker, +) -> web.Application: + app = web.Application() + app["bot"] = bot + app["dp"] = dp + app["settings"] = settings + app["async_session_factory"] = async_session_factory + app["i18n"] = dp.get("i18n_instance") + + for key in ( + "subscription_service", + "yookassa_service", + "freekassa_service", + "cryptopay_service", + "platega_service", + "severpay_service", + ): + if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] + app[key] = dp.workflow_data[key] # type: ignore[index] + + setup_subscription_webapp_routes(app) + return app + + +def setup_subscription_webapp_routes(app: web.Application) -> None: + app.router.add_get("/", index_route) + app.router.add_get("/health", health_route) + app.router.add_post("/api/auth/token", auth_token_route) + app.router.add_get("/api/auth/request-token", auth_request_token_route) + app.router.add_get("/api/auth/check-token/{token}", auth_check_token_route) + app.router.add_get("/api/me", me_route) + app.router.add_post("/api/payments", create_payment_route) + app.router.add_get("/api/payments/{payment_id}", payment_status_route) + + +async def health_route(request: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + +async def index_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + html = TEMPLATE_PATH.read_text(encoding="utf-8") + config = { + "title": settings.WEBAPP_TITLE, + "primaryColor": settings.WEBAPP_PRIMARY_COLOR, + "logoUrl": settings.WEBAPP_LOGO_URL or "", + "apiBase": "/api", + "supportUrl": settings.SUPPORT_LINK or "", + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + } + html = html.replace( + "__WEBAPP_CONFIG__", + json.dumps(config, ensure_ascii=False, separators=(",", ":")), + ) + return web.Response(text=html, content_type="text/html", charset="utf-8") + + +async def auth_token_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + payload = await _read_json(request) + init_data = str(payload.get("init_data") or "") + telegram_user = validate_telegram_webapp_init_data( + init_data, + settings.BOT_TOKEN, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + 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 _ensure_user_from_telegram(session, telegram_user, settings) + 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("WebApp auth failed: %s", exc, exc_info=True) + return _json_error(500, "auth_failed", "Auth failed") + + token = create_webapp_session_token(settings, int(telegram_user["id"])) + return web.json_response({"ok": True, "token": token}) + + +async def auth_request_token_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + token = create_pending_webapp_auth_token(settings) + bot: Bot = request.app["bot"] + bot_info = await bot.get_me() + auth_url = f"https://t.me/{bot_info.username}?start=webapp_auth_{token}" + return web.json_response({"ok": True, "token": token, "auth_url": auth_url}) + + +async def auth_check_token_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + token = request.match_info.get("token", "") + user_id = consume_authorized_webapp_auth_token(settings, token) + if not user_id: + return web.json_response({"ok": True, "authorized": False}) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return web.json_response( + {"ok": True, "authorized": False, "error": "Access denied"} + ) + + session_token = create_webapp_session_token(settings, user_id) + return web.json_response( + { + "ok": True, + "authorized": True, + "token": session_token, + } + ) + + +async def me_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + data = await _build_user_payload(request, user_id) + return web.json_response({"ok": True, **data}) + + +async def create_payment_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + method = str(payload.get("method") or "").strip().lower() + try: + months = int(float(payload.get("months"))) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid subscription period") + + settings: Settings = request.app["settings"] + price = settings.subscription_options.get(months) + stars_price = settings.stars_subscription_options.get(months) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Subscription period is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + + 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") + lang = db_user.language_code or settings.DEFAULT_LANGUAGE + return await _create_subscription_payment( + request=request, + session=session, + user_id=user_id, + method=method, + months=months, + price=float(price or 0), + stars_price=stars_price, + lang=lang, + ) + + +async def payment_status_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + try: + payment_id = int(request.match_info["payment_id"]) + except (TypeError, ValueError): + return _json_error(400, "invalid_payment", "Invalid payment id") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + payment = await payment_dal.get_payment_by_db_id(session, payment_id) + if not payment or payment.user_id != user_id: + return _json_error(404, "not_found", "Payment not found") + return web.json_response( + { + "ok": True, + "payment_id": payment.payment_id, + "status": payment.status, + "paid": payment.status == "succeeded", + } + ) + + +async def _read_json(request: web.Request) -> Dict[str, Any]: + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _json_error(status: int, code: str, message: str) -> web.Response: + return web.json_response( + {"ok": False, "error": code, "message": message}, + status=status, + ) + + +def _require_user_id(request: web.Request) -> int: + settings: Settings = request.app["settings"] + header = request.headers.get("Authorization", "") + prefix = "Bearer " + token = header[len(prefix):].strip() if header.startswith(prefix) else "" + user_id = verify_webapp_session_token(settings, token) + if not user_id: + raise web.HTTPUnauthorized( + text=json.dumps({"ok": False, "error": "unauthorized"}), + content_type="application/json", + ) + return user_id + + +async def _ensure_user_from_telegram( + session: AsyncSession, + telegram_user: Dict[str, Any], + settings: Settings, +) -> User: + user_id = int(telegram_user["id"]) + language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE + if language_code not in {"ru", "en"}: + language_code = settings.DEFAULT_LANGUAGE + + update_data = { + "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, + } + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + db_user, _ = await user_dal.create_user( + session, + { + "user_id": user_id, + **update_data, + "registration_date": datetime.now(timezone.utc), + }, + ) + return db_user + + changed = { + key: value + for key, value in update_data.items() + if getattr(db_user, key) != value + } + if changed: + db_user = await user_dal.update_user(session, user_id, changed) or db_user + return db_user + + +async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + + 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: + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "access_denied"}), + content_type="application/json", + ) + + active = await subscription_service.get_active_subscription_details( + session, user_id + ) + local_sub = await subscription_dal.get_active_subscription_by_user_id( + session, + user_id, + db_user.panel_user_uuid, + ) if db_user.panel_user_uuid else None + try: + await session.commit() + except Exception: + await session.rollback() + + return { + "user": { + "id": user_id, + "username": db_user.username, + "first_name": db_user.first_name, + "language_code": db_user.language_code or settings.DEFAULT_LANGUAGE, + }, + "subscription": _serialize_subscription(active, local_sub), + "plans": _serialize_plans(settings), + "payment_methods": _serialize_payment_methods(settings, request.app), + "settings": { + "support_url": settings.SUPPORT_LINK, + "traffic_mode": bool(settings.traffic_sale_mode), + }, + } + + +def _serialize_subscription( + active: Optional[Dict[str, Any]], + local_sub: Optional[Any], +) -> Dict[str, Any]: + if not active: + return { + "active": False, + "status": "INACTIVE", + "remaining_text": "Нет активной подписки", + "days_left": 0, + "config_link": None, + "connect_url": None, + } + + end_date = active.get("end_date") + if end_date and end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + seconds_left = 0 + if end_date: + seconds_left = max( + 0, + int((end_date - datetime.now(timezone.utc)).total_seconds()), + ) + + return { + "active": seconds_left > 0, + "status": active.get("status_from_panel") or "UNKNOWN", + "end_date": end_date.isoformat() if end_date else None, + "end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A", + "days_left": seconds_left // 86400, + "remaining_text": _format_remaining(seconds_left), + "config_link": active.get("config_link"), + "connect_url": active.get("connect_button_url") or active.get("config_link"), + "traffic_limit": _format_bytes(active.get("traffic_limit_bytes")), + "traffic_used": _format_bytes(active.get("traffic_used_bytes")), + "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), + "provider": getattr(local_sub, "provider", None), + } + + +def _serialize_plans(settings: Settings) -> List[Dict[str, Any]]: + plans: List[Dict[str, Any]] = [] + for months, price in sorted(settings.subscription_options.items()): + plan = { + "months": int(months), + "price": float(price), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": _format_months_title(int(months)), + } + stars_price = settings.stars_subscription_options.get(months) + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + +def _serialize_payment_methods( + settings: Settings, + app: web.Application, +) -> List[Dict[str, Any]]: + labels = { + "severpay": "SeverPay", + "freekassa": "FreeKassa / СБП", + "platega": "Platega", + "yookassa": "Банковская карта", + "stars": "Telegram Stars", + "cryptopay": "CryptoPay", + } + methods: List[Dict[str, Any]] = [] + for method in settings.payment_methods_order: + method = method.lower() + if method == "severpay" and _service_configured(app, "severpay_service"): + methods.append({"id": method, "name": labels[method]}) + elif method == "freekassa" and _service_configured(app, "freekassa_service"): + methods.append({"id": method, "name": labels[method]}) + elif method == "platega" and _service_configured(app, "platega_service"): + methods.append({"id": method, "name": labels[method]}) + elif method == "yookassa" and _service_configured(app, "yookassa_service"): + methods.append({"id": method, "name": labels[method]}) + elif method == "stars" and settings.STARS_ENABLED: + methods.append({"id": method, "name": labels[method]}) + elif method == "cryptopay" and _service_configured(app, "cryptopay_service"): + methods.append({"id": method, "name": labels[method]}) + return methods + + +def _service_configured(app: web.Application, key: str) -> bool: + service = app.get(key) + return bool(service and getattr(service, "configured", False)) + + +async def _create_subscription_payment( + *, + request: web.Request, + session: AsyncSession, + user_id: int, + method: str, + months: int, + price: float, + stars_price: Optional[int], + lang: str, +) -> web.Response: + settings: Settings = request.app["settings"] + description = _payment_description(months, lang) + + if method == "yookassa": + return await _create_yookassa_payment( + request, session, user_id, months, price, description + ) + if method == "freekassa": + return await _create_freekassa_payment( + request, session, user_id, months, price, description + ) + if method == "platega": + return await _create_platega_payment( + request, session, user_id, months, price, description + ) + if method == "severpay": + return await _create_severpay_payment( + request, session, user_id, months, price, description + ) + if method == "cryptopay": + service: CryptoPayService = request.app["cryptopay_service"] + if not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + url = await service.create_invoice( + session=session, + user_id=user_id, + months=months, + amount=price, + description=description, + sale_mode="subscription", + ) + if not url: + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + {"ok": True, "action": "open_link", "payment_url": url, "payment_id": None} + ) + if method == "stars": + if not settings.STARS_ENABLED or stars_price is None: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + return await _create_stars_payment( + request, session, user_id, months, int(stars_price), description + ) + + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + +async def _create_base_payment_record( + session: AsyncSession, + *, + user_id: int, + amount: float, + currency: str, + status: str, + description: str, + months: int, + provider: str, +) -> Payment: + payment = await payment_dal.create_payment_record( + session, + { + "user_id": user_id, + "amount": amount, + "currency": currency, + "status": status, + "description": description, + "subscription_duration_months": months, + "provider": provider, + }, + ) + await session.commit() + return payment + + +async def _create_yookassa_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: int, + price: float, + description: str, +) -> web.Response: + settings: Settings = request.app["settings"] + service: YooKassaService = request.app["yookassa_service"] + if not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + try: + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency="RUB", + status="pending_yookassa", + description=description, + months=months, + provider="yookassa", + ) + response = await service.create_payment( + amount=price, + currency="RUB", + description=description, + metadata={ + "user_id": str(user_id), + "subscription_months": str(months), + "payment_db_id": str(payment.payment_id), + "sale_mode": "subscription", + "source": "webapp", + }, + receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, + save_payment_method=bool( + settings.yookassa_autopayments_active + and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING + ), + ) + payment_url = response.get("confirmation_url") if response else None + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + + await payment_dal.update_payment_status_by_db_id( + session, + payment.payment_id, + response.get("status", "pending"), + yk_payment_id=response.get("id"), + ) + await session.commit() + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception as exc: + await session.rollback() + logger.error("YooKassa WebApp payment failed: %s", exc, exc_info=True) + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_freekassa_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: int, + price: float, + description: str, +) -> web.Response: + settings: Settings = request.app["settings"] + service: FreeKassaService = request.app["freekassa_service"] + if not service or not service.configured or not service.payment_method_id: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + try: + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency=service.default_currency, + status="pending_freekassa", + description=description, + months=months, + provider="freekassa", + ) + success, response_data = await service.create_order( + payment_db_id=payment.payment_id, + user_id=user_id, + months=months, + amount=price, + currency=service.default_currency, + payment_method_id=service.payment_method_id, + ip_address=service.server_ip, + extra_params={"us_method": service.payment_method_id}, + ) + payment_url = response_data.get("location") if success else None + provider_id = response_data.get("orderHash") or response_data.get("orderId") + if provider_id: + await payment_dal.update_provider_payment_and_status( + session, payment.payment_id, str(provider_id), payment.status + ) + await session.commit() + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception as exc: + await session.rollback() + logger.error("FreeKassa WebApp payment failed: %s", exc, exc_info=True) + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_platega_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: int, + price: float, + description: str, +) -> web.Response: + settings: Settings = request.app["settings"] + service: PlategaService = request.app["platega_service"] + if not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + try: + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + status="pending_platega", + description=description, + months=months, + provider="platega", + ) + payload = json.dumps( + { + "payment_db_id": payment.payment_id, + "user_id": user_id, + "months": months, + "sale_mode": "subscription", + "source": "webapp", + } + ) + success, response_data = await service.create_transaction( + payment_db_id=payment.payment_id, + user_id=user_id, + months=months, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + description=description, + payload=payload, + ) + payment_url = ( + response_data.get("redirect") + or response_data.get("url") + or response_data.get("paymentUrl") + ) if success else None + provider_id = response_data.get("transactionId") or response_data.get("id") + if provider_id: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + str(provider_id), + str(response_data.get("status", payment.status)), + ) + await session.commit() + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception as exc: + await session.rollback() + logger.error("Platega WebApp payment failed: %s", exc, exc_info=True) + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_severpay_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: int, + price: float, + description: str, +) -> web.Response: + settings: Settings = request.app["settings"] + service: SeverPayService = request.app["severpay_service"] + if not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + try: + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + status="pending_severpay", + description=description, + months=months, + provider="severpay", + ) + success, response_data = await service.create_payment( + payment_db_id=payment.payment_id, + user_id=user_id, + months=months, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + description=description, + ) + payment_url = ( + response_data.get("url") + or response_data.get("payment_url") + or response_data.get("paymentUrl") + ) if success else None + provider_id = response_data.get("id") or response_data.get("uid") + if provider_id: + await payment_dal.update_provider_payment_and_status( + session, payment.payment_id, str(provider_id), payment.status + ) + await session.commit() + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception as exc: + await session.rollback() + logger.error("SeverPay WebApp payment failed: %s", exc, exc_info=True) + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_stars_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: int, + stars_price: int, + description: str, +) -> web.Response: + bot: Bot = request.app["bot"] + try: + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=float(stars_price), + currency="XTR", + status="pending_stars", + description=description, + months=months, + provider="telegram_stars", + ) + payload = f"{payment.payment_id}:{months}:subscription" + prices = [LabeledPrice(label=description, amount=stars_price)] + create_invoice_link = getattr(bot, "create_invoice_link", None) + if callable(create_invoice_link): + invoice_url = await create_invoice_link( + title=description, + description=description, + payload=payload, + provider_token="", + currency="XTR", + prices=prices, + ) + return web.json_response( + { + "ok": True, + "action": "open_invoice", + "payment_url": invoice_url, + "payment_id": payment.payment_id, + } + ) + + await bot.send_invoice( + chat_id=user_id, + title=description, + description=description, + payload=payload, + provider_token="", + currency="XTR", + prices=prices, + ) + return web.json_response( + { + "ok": True, + "action": "invoice_sent", + "payment_id": payment.payment_id, + } + ) + except Exception as exc: + await session.rollback() + logger.error("Stars WebApp payment failed: %s", exc, exc_info=True) + return _json_error(502, "payment_failed", "Failed to create invoice") + + +def _format_remaining(seconds: int) -> str: + if seconds <= 0: + return "Подписка не активна" + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes = rem // 60 + if days > 0: + return f"{days} д. {hours} ч." + if hours > 0: + return f"{hours} ч. {minutes} мин." + return f"{max(1, minutes)} мин." + + +def _format_bytes(value: Optional[Any]) -> str: + if value is None: + return "N/A" + try: + size = float(value) + except (TypeError, ValueError): + return str(value) + if size <= 0: + return "∞" + units = ["B", "KB", "MB", "GB", "TB"] + index = 0 + while size >= 1024 and index < len(units) - 1: + size /= 1024 + index += 1 + return f"{size:.2f} {units[index]}" + + +def _format_months_title(months: int) -> str: + if months == 1: + return "1 месяц" + if 2 <= months <= 4: + return f"{months} месяца" + return f"{months} месяцев" + + +def _payment_description(months: int, lang: str) -> str: + if lang == "en": + return f"Subscription for {months} month(s)" + return f"Подписка на {_format_months_title(months)}" diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html new file mode 100644 index 0000000..1b982d1 --- /dev/null +++ b/bot/app/web/templates/subscription_webapp.html @@ -0,0 +1,791 @@ + + + + + + + + + Моя подписка + + + + + +
Загрузка...
+ +
+
+
+
R
+
Моя подписка
+
+
+ + +
+
+ +
+
+
+
Осталось
+
...
+
+
...
+
+ +
+
+
Окончание
+
...
+
+
+
Трафик
+
...
+
+
+ +
+ + +
+
+ +
Оплата подписки
+
+ + + +
+
+ + +
+ + +
+ + + + + + + diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index 54e2077..82f440a 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -8,18 +8,17 @@ from sqlalchemy.orm import sessionmaker from config.settings import Settings -async def build_and_start_web_app( +def _inject_shared_instances( + app: web.Application, dp: Dispatcher, bot: Bot, settings: Settings, async_session_factory: sessionmaker, -): - app = web.Application() +) -> None: app["bot"] = bot app["dp"] = dp app["settings"] = settings app["async_session_factory"] = async_session_factory - # Inject shared instances used by webhook handlers app["i18n"] = dp.get("i18n_instance") for key in ( "yookassa_service", @@ -34,10 +33,19 @@ async def build_and_start_web_app( "platega_service", "severpay_service", ): - # Access dispatcher workflow_data directly to avoid sequence protocol issues if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore app[key] = dp.workflow_data[key] # type: ignore + +async def build_and_start_web_app( + dp: Dispatcher, + bot: Bot, + settings: Settings, + async_session_factory: sessionmaker, +): + app = web.Application() + _inject_shared_instances(app, dp, bot, settings, async_session_factory) + setup_application(app, dp, bot=bot) telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL) @@ -87,10 +95,13 @@ async def build_and_start_web_app( app.router.add_post(panel_path, panel_webhook_route) logging.info(f"Panel webhook route configured at: [POST] {panel_path}") - web_app_runner = web.AppRunner(app) - await web_app_runner.setup() + runners = [] + + webhooks_runner = web.AppRunner(app) + await webhooks_runner.setup() + runners.append(webhooks_runner) site = web.TCPSite( - web_app_runner, + webhooks_runner, host=settings.WEB_SERVER_HOST, port=settings.WEB_SERVER_PORT, ) @@ -100,5 +111,35 @@ async def build_and_start_web_app( f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}" ) - # Run until cancelled - await asyncio.Event().wait() + if settings.WEBAPP_ENABLED: + from bot.app.web.subscription_webapp import create_subscription_webapp_application + + subscription_app = create_subscription_webapp_application( + dp, + bot, + settings, + async_session_factory, + ) + subscription_runner = web.AppRunner(subscription_app) + await subscription_runner.setup() + runners.append(subscription_runner) + subscription_site = web.TCPSite( + subscription_runner, + host=settings.WEBAPP_SERVER_HOST, + port=settings.WEBAPP_SERVER_PORT, + ) + await subscription_site.start() + logging.info( + "Subscription WebApp server started on http://%s:%s", + settings.WEBAPP_SERVER_HOST, + settings.WEBAPP_SERVER_PORT, + ) + + try: + await asyncio.Event().wait() + finally: + for runner in reversed(runners): + try: + await runner.cleanup() + except Exception as cleanup_error: + logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error) diff --git a/bot/app/web/webapp_auth.py b/bot/app/web/webapp_auth.py new file mode 100644 index 0000000..d7a80c6 --- /dev/null +++ b/bot/app/web/webapp_auth.py @@ -0,0 +1,173 @@ +import base64 +import hashlib +import hmac +import json +import logging +import secrets +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional +from urllib.parse import parse_qsl + +from config.settings import Settings + +logger = logging.getLogger(__name__) + + +@dataclass +class PendingWebAppAuth: + created_at: int + user_id: Optional[int] = None + + +_PENDING_AUTH_TOKENS: Dict[str, PendingWebAppAuth] = {} + + +def _urlsafe_b64encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _urlsafe_b64decode(raw: str) -> bytes: + padded = raw + ("=" * (-len(raw) % 4)) + return base64.urlsafe_b64decode(padded.encode("ascii")) + + +def _session_secret(settings: Settings) -> bytes: + return hmac.new( + settings.BOT_TOKEN.encode("utf-8"), + b"remnawave-tg-shop-webapp-session", + hashlib.sha256, + ).digest() + + +def create_webapp_session_token(settings: Settings, user_id: int) -> str: + now = int(time.time()) + payload = { + "sub": int(user_id), + "iat": now, + "exp": now + max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)), + } + payload_part = _urlsafe_b64encode( + json.dumps(payload, separators=(",", ":")).encode("utf-8") + ) + signature = hmac.new( + _session_secret(settings), + payload_part.encode("ascii"), + hashlib.sha256, + ).digest() + return f"{payload_part}.{_urlsafe_b64encode(signature)}" + + +def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]: + if not token or "." not in token: + return None + + try: + payload_part, signature_part = token.split(".", 1) + expected_signature = hmac.new( + _session_secret(settings), + payload_part.encode("ascii"), + hashlib.sha256, + ).digest() + received_signature = _urlsafe_b64decode(signature_part) + if not hmac.compare_digest(expected_signature, received_signature): + return None + + payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8")) + if int(payload.get("exp", 0)) < int(time.time()): + return None + return int(payload["sub"]) + except Exception as exc: + logger.debug("Failed to verify webapp session token: %s", exc) + return None + + +def validate_telegram_webapp_init_data( + init_data: str, + bot_token: str, + *, + max_age_seconds: int, +) -> Optional[Dict[str, Any]]: + """Validate Telegram Mini App initData and return the trusted user payload.""" + + try: + parsed_data = dict(parse_qsl(init_data or "", keep_blank_values=True)) + received_hash = parsed_data.pop("hash", None) + if not received_hash: + return None + + data_check_string = "\n".join( + f"{key}={value}" for key, value in sorted(parsed_data.items()) + ) + secret_key = hmac.new( + b"WebAppData", + bot_token.encode("utf-8"), + hashlib.sha256, + ).digest() + calculated_hash = hmac.new( + secret_key, + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(calculated_hash, received_hash): + logger.warning("Telegram WebApp initData hash mismatch.") + return None + + auth_date_raw = parsed_data.get("auth_date") + if auth_date_raw: + auth_date = int(auth_date_raw) + now = int(time.time()) + max_age = max(60, int(max_age_seconds)) + if auth_date > now + 300 or now - auth_date > max_age: + logger.warning("Telegram WebApp initData auth_date is stale.") + return None + + user_json = parsed_data.get("user") + if not user_json: + return None + user_data = json.loads(user_json) + if not user_data.get("id"): + return None + return user_data + except Exception as exc: + logger.warning("Failed to validate Telegram WebApp initData: %s", exc) + return None + + +def _cleanup_pending_auth(settings: Settings) -> None: + now = int(time.time()) + ttl = max(60, int(settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS)) + expired = [ + token + for token, value in _PENDING_AUTH_TOKENS.items() + if now - value.created_at > ttl + ] + for token in expired: + _PENDING_AUTH_TOKENS.pop(token, None) + + +def create_pending_webapp_auth_token(settings: Settings) -> str: + _cleanup_pending_auth(settings) + token = secrets.token_urlsafe(24) + _PENDING_AUTH_TOKENS[token] = PendingWebAppAuth(created_at=int(time.time())) + return token + + +def authorize_pending_webapp_auth_token(token: str, user_id: int) -> bool: + pending = _PENDING_AUTH_TOKENS.get(token) + if not pending: + return False + pending.user_id = int(user_id) + return True + + +def consume_authorized_webapp_auth_token( + settings: Settings, + token: str, +) -> Optional[int]: + _cleanup_pending_auth(settings) + pending = _PENDING_AUTH_TOKENS.get(token) + if not pending or pending.user_id is None: + return None + _PENDING_AUTH_TOKENS.pop(token, None) + return int(pending.user_id) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index f5f606e..29beba5 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -25,6 +25,7 @@ from bot.services.promo_code_service import PromoCodeService from config.settings import Settings from bot.middlewares.i18n import JsonI18n from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name +from bot.app.web.webapp_auth import authorize_pending_webapp_auth_token router = Router(name="user_start_router") @@ -307,7 +308,8 @@ async def ensure_required_channel_subscription( @router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match"))) -@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match"))) +@router.message(CommandStart(magic=F.args.regexp(r"^webapp_auth_([A-Za-z0-9_\-]{16,128})$").as_("webapp_auth_match"))) +@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match"))) async def start_command_handler(message: types.Message, state: FSMContext, settings: Settings, @@ -319,7 +321,8 @@ async def start_command_handler(message: types.Message, promo_match: Optional[re.Match] = None, page_ref_match: Optional[re.Match] = None, ad_param_match: Optional[re.Match] = None, - admin_user_match: Optional[re.Match] = None): + admin_user_match: Optional[re.Match] = None, + webapp_auth_match: Optional[re.Match] = None): await state.clear() current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -385,6 +388,7 @@ async def start_command_handler(message: types.Message, promo_code_to_apply: Optional[str] = None should_open_referral_from_start = False ad_start_param: Optional[str] = None + webapp_auth_token: Optional[str] = None if ref_match: raw_ref_value = ref_match.group(1) @@ -410,6 +414,9 @@ async def start_command_handler(message: types.Message, elif page_ref_match: should_open_referral_from_start = True logging.info(f"User {user_id} started with page_ref deep-link.") + elif webapp_auth_match: + webapp_auth_token = webapp_auth_match.group(1) + logging.info("User %s started WebApp auth flow.", user_id) elif ad_param_match: ad_start_param = ad_param_match.group(1) logging.info(f"User {user_id} started with ad start param: {ad_start_param}") @@ -565,6 +572,13 @@ async def start_command_handler(message: types.Message, db_user): return + if webapp_auth_token: + if authorize_pending_webapp_auth_token(webapp_auth_token, user_id): + await message.answer(_("webapp_auth_success")) + else: + await message.answer(_("webapp_auth_expired")) + return + open_referral_page_for_existing_user = ( should_open_referral_from_start and is_existing_user ) diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index ea9b505..4ad8f55 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -21,12 +21,20 @@ def get_main_menu_inline_keyboard( builder.row( InlineKeyboardButton(text=_(key="menu_subscribe_inline"), callback_data="main_action:subscribe")) - builder.row( - InlineKeyboardButton( - text=_(key="menu_my_subscription_inline"), - callback_data="main_action:my_subscription", + if settings.SUBSCRIPTION_MINI_APP_URL: + builder.row( + InlineKeyboardButton( + text=_(key="menu_my_subscription_inline"), + web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), + ) + ) + else: + builder.row( + InlineKeyboardButton( + text=_(key="menu_my_subscription_inline"), + callback_data="main_action:my_subscription", + ) ) - ) referral_button = InlineKeyboardButton( text=_(key="menu_referral_inline"), diff --git a/config/settings.py b/config/settings.py index cc927c3..b20c8db 100644 --- a/config/settings.py +++ b/config/settings.py @@ -201,6 +201,20 @@ class Settings(BaseSettings): WEB_SERVER_HOST: str = Field(default="0.0.0.0") WEB_SERVER_PORT: int = Field(default=8080) + + WEBAPP_ENABLED: bool = Field( + default=True, + description="Run the subscription Mini App in the same container on a separate port.", + ) + WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0") + WEBAPP_SERVER_PORT: int = Field(default=8081) + WEBAPP_TITLE: str = Field(default="Моя подписка") + WEBAPP_PRIMARY_COLOR: str = Field(default="#10b981") + WEBAPP_LOGO_URL: Optional[str] = Field(default=None) + WEBAPP_SESSION_TTL_SECONDS: int = Field(default=30 * 24 * 60 * 60) + WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60) + WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60) + LOGS_PAGE_SIZE: int = Field(default=10) SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None) @@ -552,6 +566,8 @@ class Settings(BaseSettings): 'CRYPT4_REDIRECT_URL', 'PRIVACY_POLICY_URL', 'USER_AGREEMENT_URL', + 'SUBSCRIPTION_MINI_APP_URL', + 'WEBAPP_LOGO_URL', mode='before', ) @classmethod diff --git a/docker-compose-remote-server.yml b/docker-compose-remote-server.yml index fe3eb13..4837fa3 100644 --- a/docker-compose-remote-server.yml +++ b/docker-compose-remote-server.yml @@ -7,6 +7,7 @@ services: - .env ports: - '127.0.0.1:8080:8080' + - '127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}' networks: - remnawave-network environment: diff --git a/docker-compose.yml b/docker-compose.yml index 1db9c6f..55cc2f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: condition: service_healthy ports: - 127.0.0.1:8080:8080 + - 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081} remnawave-tg-shop-db: image: postgres:17 diff --git a/locales/en.json b/locales/en.json index 95134d5..a03dfa1 100644 --- a/locales/en.json +++ b/locales/en.json @@ -5,6 +5,8 @@ "channel_subscription_verify_button": "Check subscription", "channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.", "channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.", + "webapp_auth_success": "✅ Login confirmed. Return to the Web App.", + "webapp_auth_expired": "The login link has expired. Return to the Web App and try again.", "main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?\n\nTo set up the connection on your device, go to \"My subscription\" and click \"Connect\". If needed, add access days in the \"Buy\" section.", "menu_activate_trial_button": "🆓 Free Trial", "menu_subscribe_inline": "🚀 Purchase", diff --git a/locales/ru.json b/locales/ru.json index c6062e2..61a78a7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,6 +5,8 @@ "channel_subscription_verify_button": "Проверить подписку", "channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.", "channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.", + "webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.", + "webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в Web App и попробуйте еще раз.", "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?\n\nДля настройки подключения на устройстве перейдите в раздел \"Моя подписка\" и нажмите кнопку \"Подключиться\". При необходимости добавьте дни доступа в разделе \"Купить\".", "menu_activate_trial_button": "🆓 Пробный период", "menu_subscribe_inline": "🚀 Купить",