From f919368d0b35c22798cbe31bc5eb44d9b496128e Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 02:32:36 +0300 Subject: [PATCH] Document miniapp deployment and reverse proxy setup --- README.md | 4 + app/utils/telegram_webapp.py | 91 ++++++++ app/webapi/app.py | 6 + app/webapi/routes/__init__.py | 2 + app/webapi/routes/miniapp.py | 213 +++++++++++++++++ app/webapi/schemas/miniapp.py | 47 ++++ docs/miniapp-setup.md | 136 +++++++++++ miniapp/index.html | 414 ++++++++++++++++++++++++++-------- 8 files changed, 818 insertions(+), 95 deletions(-) create mode 100644 app/utils/telegram_webapp.py create mode 100644 app/webapi/routes/miniapp.py create mode 100644 app/webapi/schemas/miniapp.py create mode 100644 docs/miniapp-setup.md diff --git a/README.md b/README.md index 189ad150..bbddbaa9 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ docker compose logs Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md). +### 📱 Telegram Mini App с подпиской + +Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md). + ### 📊 Статус серверов в главном меню | Переменная | Описание | Пример | diff --git a/app/utils/telegram_webapp.py b/app/utils/telegram_webapp.py new file mode 100644 index 00000000..43d74d5c --- /dev/null +++ b/app/utils/telegram_webapp.py @@ -0,0 +1,91 @@ +"""Utilities for validating Telegram WebApp initialization data.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from typing import Any, Dict +from urllib.parse import parse_qsl + + +class TelegramWebAppAuthError(Exception): + """Raised when Telegram WebApp init data fails validation.""" + + +def parse_webapp_init_data( + init_data: str, + bot_token: str, + *, + max_age_seconds: int = 86400, +) -> Dict[str, Any]: + """Validate and parse Telegram WebApp init data. + + Args: + init_data: Raw init data string provided by Telegram WebApp. + bot_token: Bot token used to verify the signature. + max_age_seconds: Maximum allowed age for the payload. Defaults to 24 hours. + + Returns: + Parsed init data as a dictionary. + + Raises: + TelegramWebAppAuthError: If validation fails. + """ + + if not init_data: + raise TelegramWebAppAuthError("Missing init data") + + if not bot_token: + raise TelegramWebAppAuthError("Bot token is not configured") + + parsed_pairs = parse_qsl(init_data, strict_parsing=True, keep_blank_values=True) + data: Dict[str, Any] = {key: value for key, value in parsed_pairs} + + received_hash = data.pop("hash", None) + if not received_hash: + raise TelegramWebAppAuthError("Missing init data signature") + + data_check_string = "\n".join( + f"{key}={value}" for key, value in sorted(data.items()) + ) + + secret_key = hmac.new( + key=b"WebAppData", + msg=bot_token.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + + computed_hash = hmac.new( + key=secret_key, + msg=data_check_string.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(computed_hash, received_hash): + raise TelegramWebAppAuthError("Invalid init data signature") + + auth_date_raw = data.get("auth_date") + if auth_date_raw is not None: + try: + auth_date = int(auth_date_raw) + except (TypeError, ValueError): + raise TelegramWebAppAuthError("Invalid auth_date value") from None + + if max_age_seconds and auth_date: + current_ts = int(time.time()) + if current_ts - auth_date > max_age_seconds: + raise TelegramWebAppAuthError("Init data is too old") + + data["auth_date"] = auth_date + + user_payload = data.get("user") + if user_payload is not None: + try: + data["user"] = json.loads(user_payload) + except json.JSONDecodeError as error: + raise TelegramWebAppAuthError("Invalid user payload") from error + + return data + diff --git a/app/webapi/app.py b/app/webapi/app.py index 0761ecad..06a10569 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -13,6 +13,7 @@ from .routes import ( config, health, promocodes, + miniapp, promo_groups, remnawave, stats, @@ -68,6 +69,10 @@ OPENAPI_TAGS = [ "данных между ботом и панелью." ), }, + { + "name": "miniapp", + "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", + }, ] @@ -110,5 +115,6 @@ def create_web_api_app() -> FastAPI: app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"]) app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) + app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 31aef685..22ed34ae 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -1,6 +1,7 @@ from . import ( config, health, + miniapp, promo_groups, remnawave, stats, @@ -14,6 +15,7 @@ from . import ( __all__ = [ "config", "health", + "miniapp", "promo_groups", "remnawave", "stats", diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py new file mode 100644 index 00000000..5c4d596c --- /dev/null +++ b/app/webapi/routes/miniapp.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import get_user_by_telegram_id +from app.database.models import Subscription +from app.services.subscription_service import SubscriptionService +from app.utils.telegram_webapp import ( + TelegramWebAppAuthError, + parse_webapp_init_data, +) + +from ..dependencies import get_db_session +from ..schemas.miniapp import ( + MiniAppSubscriptionRequest, + MiniAppSubscriptionResponse, + MiniAppSubscriptionUser, +) + + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _format_gb(value: Optional[float]) -> float: + if value is None: + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _format_gb_label(value: float) -> str: + absolute = abs(value) + if absolute >= 100: + return f"{value:.0f} GB" + if absolute >= 10: + return f"{value:.1f} GB" + return f"{value:.2f} GB" + + +def _format_limit_label(limit: Optional[int]) -> str: + if not limit: + return "Unlimited" + return f"{limit} GB" + + +def _bytes_to_gb(bytes_value: Optional[int]) -> float: + if not bytes_value: + return 0.0 + return round(bytes_value / (1024 ** 3), 2) + + +def _status_label(status: str) -> str: + mapping = { + "active": "Active", + "trial": "Trial", + "expired": "Expired", + "disabled": "Disabled", + } + return mapping.get(status, status.title()) + + +def _resolve_display_name(user_data: Dict[str, Any]) -> str: + username = user_data.get("username") + if username: + return username + + first = user_data.get("first_name") + last = user_data.get("last_name") + parts = [part for part in [first, last] if part] + if parts: + return " ".join(parts) + + telegram_id = user_data.get("telegram_id") + return f"User {telegram_id}" if telegram_id else "User" + + +def _is_remnawave_configured() -> bool: + params = settings.get_remnawave_auth_params() + return bool(params.get("base_url") and params.get("api_key")) + + +async def _load_subscription_links( + subscription: Subscription, +) -> Dict[str, Any]: + if not subscription.remnawave_short_uuid or not _is_remnawave_configured(): + return {} + + try: + service = SubscriptionService() + info = await service.get_subscription_info(subscription.remnawave_short_uuid) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load subscription info from RemnaWave: %s", error) + return {} + + if not info: + return {} + + payload: Dict[str, Any] = { + "links": list(info.links or []), + "ss_conf_links": dict(info.ss_conf_links or {}), + "subscription_url": info.subscription_url, + "happ": info.happ, + "happ_link": getattr(info, "happ_link", None), + "happ_crypto_link": getattr(info, "happ_crypto_link", None), + } + + return payload + + +@router.post("/subscription", response_model=MiniAppSubscriptionResponse) +async def get_subscription_details( + payload: MiniAppSubscriptionRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppSubscriptionResponse: + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(error), + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user payload", + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user identifier", + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user or not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subscription not found", + ) + + subscription = user.subscription + traffic_used = _format_gb(subscription.traffic_used_gb) + traffic_limit = subscription.traffic_limit_gb or 0 + lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0)) + + status_actual = subscription.actual_status + links_payload = await _load_subscription_links(subscription) + + subscription_url = links_payload.get("subscription_url") or subscription.subscription_url + subscription_crypto_link = ( + links_payload.get("happ_crypto_link") + or subscription.subscription_crypto_link + ) + + connected_squads: List[str] = list(subscription.connected_squads or []) + links: List[str] = links_payload.get("links") or connected_squads + ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} + + response_user = MiniAppSubscriptionUser( + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + display_name=_resolve_display_name( + { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "telegram_id": user.telegram_id, + } + ), + language=user.language, + status=user.status, + subscription_status=subscription.status, + subscription_actual_status=status_actual, + status_label=_status_label(status_actual), + expires_at=subscription.end_date, + device_limit=subscription.device_limit, + traffic_used_gb=round(traffic_used, 2), + traffic_used_label=_format_gb_label(traffic_used), + traffic_limit_gb=traffic_limit, + traffic_limit_label=_format_limit_label(traffic_limit), + lifetime_used_traffic_gb=lifetime_used, + has_active_subscription=status_actual in {"active", "trial"}, + ) + + return MiniAppSubscriptionResponse( + subscription_id=subscription.id, + remnawave_short_uuid=subscription.remnawave_short_uuid, + user=response_user, + subscription_url=subscription_url, + subscription_crypto_link=subscription_crypto_link, + links=links, + ss_conf_links=ss_conf_links, + connected_squads=connected_squads, + happ=links_payload.get("happ"), + happ_link=links_payload.get("happ_link"), + happ_crypto_link=links_payload.get("happ_crypto_link"), + ) + diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py new file mode 100644 index 00000000..9c5a4308 --- /dev/null +++ b/app/webapi/schemas/miniapp.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class MiniAppSubscriptionRequest(BaseModel): + init_data: str = Field(..., alias="initData") + + +class MiniAppSubscriptionUser(BaseModel): + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + display_name: str + language: Optional[str] = None + status: str + subscription_status: str + subscription_actual_status: str + status_label: str + expires_at: Optional[datetime] = None + device_limit: Optional[int] = None + traffic_used_gb: float = 0.0 + traffic_used_label: str + traffic_limit_gb: Optional[int] = None + traffic_limit_label: str + lifetime_used_traffic_gb: float = 0.0 + has_active_subscription: bool = False + + +class MiniAppSubscriptionResponse(BaseModel): + success: bool = True + subscription_id: int + remnawave_short_uuid: Optional[str] = None + user: MiniAppSubscriptionUser + subscription_url: Optional[str] = None + subscription_crypto_link: Optional[str] = None + links: List[str] = Field(default_factory=list) + ss_conf_links: Dict[str, str] = Field(default_factory=dict) + connected_squads: List[str] = Field(default_factory=list) + happ: Optional[Dict[str, Any]] = None + happ_link: Optional[str] = None + happ_crypto_link: Optional[str] = None + diff --git a/docs/miniapp-setup.md b/docs/miniapp-setup.md new file mode 100644 index 00000000..2ab7d0e0 --- /dev/null +++ b/docs/miniapp-setup.md @@ -0,0 +1,136 @@ +# Настройка мини-приложения Telegram + +Эта инструкция описывает, как запустить статическую страницу из каталога `miniapp/index.html`, подключить её к административному API бота и опубликовать через reverse-proxy (nginx или Caddy). Страница отображает текущую подписку пользователя и использует Telegram WebApp init data для авторизации. + +## 1. Требования + +- Развёрнутый бот Bedolaga c актуальной базой данных. +- Включённое административное API (`WEB_API_ENABLED=true`). +- Доменное имя с действующим TLS-сертификатом (Telegram открывает веб-приложения только по HTTPS). +- Возможность разместить статические файлы (`miniapp/index.html` и `app-config.json`) и проксировать запросы `/miniapp/*` к боту. + +## 2. Настройка окружения + +1. Скопируйте пример конфигурации и включите веб-API: + ```bash + cp .env.example .env + nano .env + ``` +2. Задайте как минимум следующие переменные: + ```env + BOT_TOKEN=123456:ABCDEF # токен вашего бота + WEB_API_ENABLED=true # включает FastAPI + WEB_API_HOST=0.0.0.0 + WEB_API_PORT=8080 + WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com + WEB_API_DEFAULT_TOKEN=super-secret-token + SERVER_STATUS_MODE=external_link_miniapp + SERVER_STATUS_EXTERNAL_URL=https://miniapp.example.com + ``` + - `WEB_API_ALLOWED_ORIGINS` должен содержать домен, с которого будет открываться мини-приложение. + - `WEB_API_DEFAULT_TOKEN` создаёт bootstrap-токен для запросов от страницы. Его можно заменить на токен, созданный через `POST /tokens`. +3. Если используете RemnaWave, убедитесь, что заданы `REMNAWAVE_API_URL` и `REMNAWAVE_API_KEY`, чтобы в мини-приложении отображались дополнительные ссылки подписки. + +## 3. Запуск административного API + +API можно запускать вместе с ботом (`python main.py`) или отдельно для тестов: +```bash +uvicorn app.webapi.app:create_web_api_app --host 0.0.0.0 --port 8080 +``` +После старта проверьте доступность: +```bash +curl -H "X-API-Key: super-secret-token" https://miniapp.example.com/miniapp/health || \ +curl -H "X-API-Key: super-secret-token" http://127.0.0.1:8080/health +``` + +## 4. Подготовка статических файлов + +1. Скопируйте `miniapp/index.html` и `app-config.json` на сервер, из которого nginx/Caddy будет отдавать статический контент. Например: + ```bash + sudo mkdir -p /var/www/remnawave-miniapp + sudo cp miniapp/index.html /var/www/remnawave-miniapp/ + sudo cp app-config.json /var/www/remnawave-miniapp/ + ``` +2. При необходимости отредактируйте `app-config.json`, чтобы настроить инструкции и ссылки на нужные клиенты. +3. Убедитесь, что файлы доступны для чтения пользователем веб-сервера. + +## 5. Настройка кнопки в Telegram + +1. В `.env` уже выставлен `SERVER_STATUS_MODE=external_link_miniapp` и `SERVER_STATUS_EXTERNAL_URL=https://miniapp.example.com`. +2. Перезапустите бота. В главном меню появится кнопка «Статус серверов», открывающая веб-приложение внутри Telegram. +3. При необходимости задайте кастомную кнопку через `@BotFather` (команда `/setmenu` -> Web App URL). + +## 6. Конфигурация nginx + +```nginx +server { + listen 80; + listen 443 ssl http2; + server_name miniapp.example.com; + + ssl_certificate /etc/letsencrypt/live/miniapp.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/miniapp.example.com/privkey.pem; + + # Статические файлы мини-приложения + root /var/www/remnawave-miniapp; + index index.html; + + location = /app-config.json { + add_header Access-Control-Allow-Origin "*"; + try_files $uri =404; + } + + location / { + try_files $uri /index.html =404; + } + + # Проксирование запросов к административному API + location /miniapp/ { + proxy_pass http://127.0.0.1:8080/miniapp/; + 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; + } + + # Если нужно проксировать другие эндпоинты API, добавьте аналогичные location-блоки. +} +``` + +## 7. Конфигурация Caddy + +```caddy +miniapp.example.com { + encode gzip zstd + root * /var/www/remnawave-miniapp + file_server + + @config path /app-config.json + header @config Access-Control-Allow-Origin "*" + + reverse_proxy /miniapp/* 127.0.0.1:8080 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} +``` +Caddy автоматически выпустит сертификаты через ACME. Убедитесь, что порт 443 проброшен и домен указывает на сервер. + +## 8. Проверка работы + +1. Откройте мини-приложение прямо в Telegram или через браузер: `https://miniapp.example.com`. +2. В консоли разработчика убедитесь, что запрос к `https://miniapp.example.com/miniapp/subscription` возвращает JSON с данными подписки. +3. Проверьте, что ссылки из блока «Подключить подписку» открываются и копируются без ошибок. + +## 9. Диагностика + +| Симптом | Возможная причина | Проверка | +|---------|------------------|----------| +| Белый экран и ошибка 401 | Неверный `X-API-Key` или `WEB_API_ALLOWED_ORIGINS`. | Проверьте токен и заголовки запроса, перегенерируйте токен через `/tokens`. | +| Ошибка 404 на `/miniapp/subscription` | Прокси не пробрасывает запросы или API не запущено. | Проверьте лог nginx/Caddy и убедитесь, что бот запущен с `WEB_API_ENABLED=true`. | +| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. | +| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. | + +После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую. diff --git a/miniapp/index.html b/miniapp/index.html index 5ea8ab7f..ba983c31 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -158,6 +158,21 @@ color: #842029; } + .status-trial { + background: #fff3cd; + color: #664d03; + } + + .status-disabled { + background: #e2e3e5; + color: #41464b; + } + + .status-unknown { + background: #e7eaf3; + color: #495057; + } + /* Stats Grid */ .stats-grid { display: grid; @@ -242,6 +257,12 @@ border: 1px solid var(--border-color); } + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } + .btn-icon { width: 20px; height: 20px; @@ -427,8 +448,8 @@ @@ -504,92 +525,160 @@