Merge pull request #648 from Fr1ngg/dev2

Миниапка с подпиской
This commit is contained in:
Egor
2025-10-01 06:14:01 +03:00
committed by GitHub
11 changed files with 3480 additions and 0 deletions

View File

@@ -290,6 +290,10 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false

View File

@@ -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).
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |
@@ -531,6 +535,10 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false

View File

@@ -213,6 +213,11 @@ class Settings(BaseSettings):
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection"
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
@@ -518,6 +523,34 @@ class Settings(BaseSettings):
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]:
def _clean(value: Optional[str]) -> Optional[str]:
if value is None:
return None
value_str = str(value).strip()
return value_str or None
name_en = _clean(self.MINIAPP_SERVICE_NAME_EN)
name_ru = _clean(self.MINIAPP_SERVICE_NAME_RU)
desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN)
desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU)
default_name = name_en or name_ru or "RemnaWave VPN"
default_description = desc_en or desc_ru or "Secure & Fast Connection"
return {
"service_name": {
"default": default_name,
"en": name_en,
"ru": name_ru,
},
"service_description": {
"default": default_description,
"en": desc_en,
"ru": desc_ru,
},
}
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL

View File

@@ -89,6 +89,7 @@ class BotConfigurationService:
"HAPP": "🅷 Happ настройки",
"SKIP": "⚡ Быстрый старт",
"ADDITIONAL": "📱 Приложения и DeepLinks",
"MINIAPP": "📱 Mini App",
"DATABASE": "🗄️ Режим БД",
"POSTGRES": "🐘 PostgreSQL",
"SQLITE": "💾 SQLite",
@@ -189,6 +190,7 @@ class BotConfigurationService:
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
"SKIP_": "SKIP",
"MINIAPP_": "MINIAPP",
"MONITORING_": "MONITORING",
"NOTIFICATION_": "NOTIFICATIONS",
"SERVER_STATUS": "SERVER",

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,396 @@
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple, Union
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import Subscription, Transaction, User
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from app.services.subscription_service import SubscriptionService
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
from app.utils.telegram_webapp import (
TelegramWebAppAuthError,
parse_webapp_init_data,
)
from ..dependencies import get_db_session
from ..schemas.miniapp import (
MiniAppConnectedServer,
MiniAppDevice,
MiniAppPromoGroup,
MiniAppSubscriptionRequest,
MiniAppSubscriptionResponse,
MiniAppSubscriptionUser,
MiniAppTransaction,
)
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 _parse_datetime_string(value: Optional[str]) -> Optional[str]:
if not value:
return None
try:
cleaned = value.strip()
if cleaned.endswith("Z"):
cleaned = f"{cleaned[:-1]}+00:00"
# Normalize duplicated timezone suffixes like +00:00+00:00
if "+00:00+00:00" in cleaned:
cleaned = cleaned.replace("+00:00+00:00", "+00:00")
datetime.fromisoformat(cleaned)
return cleaned
except Exception: # pragma: no cover - defensive
return value
async def _resolve_connected_servers(
db: AsyncSession,
squad_uuids: List[str],
) -> List[MiniAppConnectedServer]:
if not squad_uuids:
return []
resolved: Dict[str, str] = {}
missing: List[str] = []
for squad_uuid in squad_uuids:
if squad_uuid in resolved:
continue
server = await get_server_squad_by_uuid(db, squad_uuid)
if server and server.display_name:
resolved[squad_uuid] = server.display_name
else:
missing.append(squad_uuid)
if missing:
try:
service = RemnaWaveService()
if service.is_configured:
squads = await service.get_all_squads()
for squad in squads:
uuid = squad.get("uuid")
name = squad.get("name")
if uuid in missing and name:
resolved[uuid] = name
except RemnaWaveConfigurationError:
logger.debug("RemnaWave is not configured; skipping server name enrichment")
except Exception as error: # pragma: no cover - defensive logging
logger.warning("Failed to resolve server names from RemnaWave: %s", error)
connected_servers: List[MiniAppConnectedServer] = []
for squad_uuid in squad_uuids:
name = resolved.get(squad_uuid, squad_uuid)
connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name))
return connected_servers
async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
remnawave_uuid = getattr(user, "remnawave_uuid", None)
if not remnawave_uuid:
return 0, []
try:
service = RemnaWaveService()
except Exception as error: # pragma: no cover - defensive logging
logger.warning("Failed to initialise RemnaWave service: %s", error)
return 0, []
if not service.is_configured:
return 0, []
try:
async with service.get_api_client() as api:
response = await api.get_user_devices(remnawave_uuid)
except RemnaWaveConfigurationError:
logger.debug("RemnaWave configuration missing while loading devices")
return 0, []
except Exception as error: # pragma: no cover - defensive logging
logger.warning("Failed to load devices from RemnaWave: %s", error)
return 0, []
total_devices = int(response.get("total") or 0)
devices_payload = response.get("devices") or []
devices: List[MiniAppDevice] = []
for device in devices_payload:
platform = device.get("platform") or device.get("platformType")
model = device.get("deviceModel") or device.get("model") or device.get("name")
app_version = device.get("appVersion") or device.get("version")
last_seen_raw = (
device.get("updatedAt")
or device.get("lastSeen")
or device.get("lastActiveAt")
or device.get("createdAt")
)
last_ip = device.get("ip") or device.get("ipAddress")
devices.append(
MiniAppDevice(
platform=platform,
device_model=model,
app_version=app_version,
last_seen=_parse_datetime_string(last_seen_raw),
last_ip=last_ip,
)
)
if total_devices == 0:
total_devices = len(devices)
return total_devices, devices
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"))
def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction:
return MiniAppTransaction(
id=transaction.id,
type=transaction.type,
amount_kopeks=transaction.amount_kopeks,
amount_rubles=round(transaction.amount_kopeks / 100, 2),
description=transaction.description,
payment_method=transaction.payment_method,
external_id=transaction.external_id,
is_completed=transaction.is_completed,
created_at=transaction.created_at,
completed_at=transaction.completed_at,
)
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)
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
if not user or not user.subscription:
detail: Union[str, Dict[str, str]] = "Subscription not found"
if purchase_url:
detail = {
"message": "Subscription not found",
"purchase_url": purchase_url,
}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)
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
)
happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link)
connected_squads: List[str] = list(subscription.connected_squads or [])
connected_servers = await _resolve_connected_servers(db, connected_squads)
devices_count, devices = await _load_devices_info(user)
links: List[str] = links_payload.get("links") or connected_squads
ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {}
transactions_query = (
select(Transaction)
.where(Transaction.user_id == user.id)
.order_by(Transaction.created_at.desc())
.limit(10)
)
transactions_result = await db.execute(transactions_query)
transactions = list(transactions_result.scalars().all())
balance_currency = getattr(user, "balance_currency", None)
if isinstance(balance_currency, str):
balance_currency = balance_currency.upper()
promo_group = getattr(user, "promo_group", None)
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,
subscription_purchase_url=purchase_url or None,
links=links,
ss_conf_links=ss_conf_links,
connected_squads=connected_squads,
connected_servers=connected_servers,
connected_devices_count=devices_count,
connected_devices=devices,
happ=links_payload.get("happ"),
happ_link=links_payload.get("happ_link"),
happ_crypto_link=links_payload.get("happ_crypto_link"),
happ_cryptolink_redirect_link=happ_redirect_link,
balance_kopeks=user.balance_kopeks,
balance_rubles=round(user.balance_rubles, 2),
balance_currency=balance_currency,
transactions=[_serialize_transaction(tx) for tx in transactions],
promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
if promo_group
else None,
subscription_type="trial" if subscription.is_trial else "paid",
autopay_enabled=bool(subscription.autopay_enabled),
branding=settings.get_miniapp_branding(),
)

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class MiniAppBranding(BaseModel):
service_name: Dict[str, Optional[str]] = Field(default_factory=dict)
service_description: Dict[str, Optional[str]] = Field(default_factory=dict)
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 MiniAppPromoGroup(BaseModel):
id: int
name: str
class MiniAppConnectedServer(BaseModel):
uuid: str
name: str
class MiniAppDevice(BaseModel):
platform: Optional[str] = None
device_model: Optional[str] = None
app_version: Optional[str] = None
last_seen: Optional[str] = None
last_ip: Optional[str] = None
class MiniAppTransaction(BaseModel):
id: int
type: str
amount_kopeks: int
amount_rubles: float
description: Optional[str] = None
payment_method: Optional[str] = None
external_id: Optional[str] = None
is_completed: bool
created_at: datetime
completed_at: Optional[datetime] = None
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
subscription_purchase_url: 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)
connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list)
connected_devices_count: int = 0
connected_devices: List[MiniAppDevice] = Field(default_factory=list)
happ: Optional[Dict[str, Any]] = None
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
happ_cryptolink_redirect_link: Optional[str] = None
balance_kopeks: int = 0
balance_rubles: float = 0.0
balance_currency: Optional[str] = None
transactions: List[MiniAppTransaction] = Field(default_factory=list)
promo_group: Optional[MiniAppPromoGroup] = None
subscription_type: str
autopay_enabled: bool = False
branding: Optional[MiniAppBranding] = None

127
docs/miniapp-setup.md Normal file
View File

@@ -0,0 +1,127 @@
# Настройка мини-приложения 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` и `miniapp/app-config.json`) и проксировать запросы `/miniapp/*` к боту.
## 2. Настройка окружения
1. Скопируйте пример конфигурации и включите веб-API:
2. Задайте как минимум следующие переменные:
```env
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
```
- `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` и `miniapp/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 = /miniapp/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}
}
}
```
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` и статус подписки пользователя. |
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.

2715
miniapp/index.html Normal file

File diff suppressed because it is too large Load Diff