feat: web app
This commit is contained in:
+12
-1
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
</details>
|
||||
@@ -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`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
**Отдельный порт Web App:**
|
||||
- `https://<домен_web_app>/` → `http://remnawave-tg-shop:<WEBAPP_SERVER_PORT>/`
|
||||
|
||||
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. Клонирование репозитория
|
||||
|
||||
@@ -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)}"
|
||||
@@ -0,0 +1,791 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#111827">
|
||||
<title>Моя подписка</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--accent: #10b981;
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #eef2f7;
|
||||
--text: #111827;
|
||||
--muted: #667085;
|
||||
--line: #d8dee8;
|
||||
--danger: #dc2626;
|
||||
--success: #059669;
|
||||
--shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
--bg: #101318;
|
||||
--panel: #171b22;
|
||||
--panel-soft: #202631;
|
||||
--text: #f4f7fb;
|
||||
--muted: #9aa4b2;
|
||||
--line: #2e3542;
|
||||
--shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: max(env(safe-area-inset-top), 10px) 14px max(env(safe-area-inset-bottom), 18px);
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(100%, 520px);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: var(--panel-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
min-width: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
margin-top: 3px;
|
||||
font-size: 28px;
|
||||
font-weight: 850;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--success);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
background: var(--panel-soft);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: #06110d;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 8px 2px 0;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plans,
|
||||
.methods {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan,
|
||||
.method {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.plan.active,
|
||||
.method:active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.plan-name,
|
||||
.method-name {
|
||||
font-weight: 850;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.plan-meta,
|
||||
.method-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.notice {
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.payment-panel {
|
||||
display: none;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.payment-panel.show {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.loader {
|
||||
min-height: 70vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.login {
|
||||
min-height: 76vh;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 360px);
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 22px;
|
||||
font-weight: 850;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.login-text {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
body {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loader" class="loader">Загрузка...</div>
|
||||
|
||||
<main id="app" class="app hidden">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div id="brand-mark" class="brand-mark">R</div>
|
||||
<div id="brand-title" class="brand-title">Моя подписка</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="icon-btn" type="button" title="Обновить" onclick="reloadData()">↻</button>
|
||||
<button class="icon-btn" type="button" title="Выйти" onclick="logout()">⎋</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel status-panel">
|
||||
<div class="status-row">
|
||||
<div>
|
||||
<div class="eyebrow">Осталось</div>
|
||||
<div id="remaining" class="status-title">...</div>
|
||||
</div>
|
||||
<div id="status-badge" class="badge">...</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-label">Окончание</div>
|
||||
<div id="end-date" class="metric-value">...</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Трафик</div>
|
||||
<div id="traffic" class="metric-value">...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connect-actions" class="actions">
|
||||
<button id="connect-btn" class="btn primary" type="button" onclick="openConnectLink()">Подключиться</button>
|
||||
<button id="copy-btn" class="btn" type="button" onclick="copyConfigLink()">Скопировать ссылку</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section-title">Оплата подписки</div>
|
||||
<section id="plans" class="plans"></section>
|
||||
|
||||
<section id="methods-wrap" class="hidden">
|
||||
<div class="section-title">Способ оплаты</div>
|
||||
<div id="methods" class="methods"></div>
|
||||
</section>
|
||||
|
||||
<section id="payment-panel" class="panel payment-panel">
|
||||
<div id="payment-message" class="notice"></div>
|
||||
<button id="payment-open-btn" class="btn primary" type="button" onclick="openPaymentUrl()">Открыть оплату</button>
|
||||
<button id="payment-check-btn" class="btn" type="button" onclick="checkPayment()">Проверить оплату</button>
|
||||
</section>
|
||||
|
||||
<a id="support-link" class="btn hidden" href="#" target="_blank" rel="noopener">Поддержка</a>
|
||||
</main>
|
||||
|
||||
<section id="login" class="login">
|
||||
<div class="panel login-card">
|
||||
<div class="login-title">Вход через Telegram</div>
|
||||
<div class="login-text">Откройте бота и подтвердите вход. После подтверждения эта страница обновится автоматически.</div>
|
||||
<a id="auth-link" class="btn primary" href="#" target="_blank" rel="noopener">Открыть Telegram</a>
|
||||
<button class="btn" type="button" onclick="startExternalAuth()">Обновить ссылку</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const CFG = __WEBAPP_CONFIG__;
|
||||
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
|
||||
const state = {
|
||||
token: localStorage.getItem('rw_webapp_token') || '',
|
||||
data: null,
|
||||
selectedPlan: null,
|
||||
payment: null,
|
||||
authPoll: null
|
||||
};
|
||||
|
||||
document.documentElement.classList.toggle('theme-dark', !tg || tg.colorScheme !== 'light');
|
||||
document.documentElement.style.setProperty('--accent', CFG.primaryColor || '#10b981');
|
||||
document.title = CFG.title || 'Моя подписка';
|
||||
document.getElementById('brand-title').textContent = CFG.title || 'Моя подписка';
|
||||
if (CFG.logoUrl) {
|
||||
document.getElementById('brand-mark').innerHTML = '<img src="' + escapeAttr(CFG.logoUrl) + '" alt="">';
|
||||
}
|
||||
if (CFG.supportUrl) {
|
||||
const support = document.getElementById('support-link');
|
||||
support.href = CFG.supportUrl;
|
||||
support.classList.remove('hidden');
|
||||
}
|
||||
|
||||
if (tg) {
|
||||
try {
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
tg.setHeaderColor(CFG.primaryColor || '#10b981');
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
async function boot() {
|
||||
showLoader();
|
||||
if (tg && tg.initData) {
|
||||
try {
|
||||
const auth = await fetch(CFG.apiBase + '/auth/token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({init_data: tg.initData})
|
||||
});
|
||||
const data = await auth.json();
|
||||
if (data.ok && data.token) {
|
||||
setToken(data.token);
|
||||
await loadData();
|
||||
return;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (state.token) {
|
||||
try {
|
||||
await loadData();
|
||||
return;
|
||||
} catch (e) {
|
||||
clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
await startExternalAuth();
|
||||
}
|
||||
|
||||
async function startExternalAuth() {
|
||||
showLogin();
|
||||
try {
|
||||
const response = await fetch(CFG.apiBase + '/auth/request-token');
|
||||
const data = await response.json();
|
||||
if (!data.ok) return;
|
||||
document.getElementById('auth-link').href = data.auth_url;
|
||||
if (state.authPoll) clearInterval(state.authPoll);
|
||||
state.authPoll = setInterval(() => checkExternalAuth(data.token), 1800);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
async function checkExternalAuth(token) {
|
||||
try {
|
||||
const response = await fetch(CFG.apiBase + '/auth/check-token/' + encodeURIComponent(token));
|
||||
const data = await response.json();
|
||||
if (data.authorized && data.token) {
|
||||
clearInterval(state.authPoll);
|
||||
setToken(data.token);
|
||||
await loadData();
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const data = await api('/me');
|
||||
if (!data.ok) throw new Error(data.error || 'load failed');
|
||||
state.data = data;
|
||||
state.selectedPlan = data.plans && data.plans.length ? data.plans[0] : null;
|
||||
render();
|
||||
showApp();
|
||||
}
|
||||
|
||||
async function reloadData() {
|
||||
showToast('Обновляю данные');
|
||||
await loadData();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSubscription(state.data.subscription);
|
||||
renderPlans(state.data.plans || []);
|
||||
renderMethods();
|
||||
}
|
||||
|
||||
function renderSubscription(sub) {
|
||||
const badge = document.getElementById('status-badge');
|
||||
badge.textContent = sub.active ? 'Активна' : 'Не активна';
|
||||
badge.classList.toggle('off', !sub.active);
|
||||
document.getElementById('remaining').textContent = sub.remaining_text || 'Нет активной подписки';
|
||||
document.getElementById('end-date').textContent = sub.end_date_text || 'N/A';
|
||||
document.getElementById('traffic').textContent = (sub.traffic_used || 'N/A') + ' / ' + (sub.traffic_limit || 'N/A');
|
||||
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
|
||||
}
|
||||
|
||||
function renderPlans(plans) {
|
||||
const wrap = document.getElementById('plans');
|
||||
if (!plans.length) {
|
||||
wrap.innerHTML = '<div class="empty">Тарифы не настроены.</div>';
|
||||
document.getElementById('methods-wrap').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.innerHTML = plans.map(plan => {
|
||||
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
|
||||
const stars = plan.stars_price ? ' · ' + plan.stars_price + ' ⭐' : '';
|
||||
return `
|
||||
<button class="plan ${isActive ? 'active' : ''}" type="button" onclick="selectPlan(${plan.months})">
|
||||
<span>
|
||||
<span class="plan-name">${escapeHtml(plan.title)}</span>
|
||||
<span class="plan-meta">Доступ на ${escapeHtml(plan.title)}</span>
|
||||
</span>
|
||||
<span class="plan-name">${formatMoney(plan.price, plan.currency)}${stars}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderMethods() {
|
||||
const wrap = document.getElementById('methods-wrap');
|
||||
const methods = document.getElementById('methods');
|
||||
if (!state.selectedPlan) {
|
||||
wrap.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const available = (state.data.payment_methods || []).filter(method => {
|
||||
if (method.id === 'stars') return Number.isFinite(Number(state.selectedPlan.stars_price));
|
||||
return true;
|
||||
});
|
||||
wrap.classList.remove('hidden');
|
||||
if (!available.length) {
|
||||
methods.innerHTML = '<div class="empty">Нет доступных способов оплаты.</div>';
|
||||
return;
|
||||
}
|
||||
methods.innerHTML = available.map(method => {
|
||||
const amount = method.id === 'stars'
|
||||
? state.selectedPlan.stars_price + ' ⭐'
|
||||
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
|
||||
return `
|
||||
<button class="method" type="button" onclick="createPayment('${escapeAttr(method.id)}')">
|
||||
<span>
|
||||
<span class="method-name">${escapeHtml(method.name)}</span>
|
||||
<span class="method-meta">${escapeHtml(state.selectedPlan.title)}</span>
|
||||
</span>
|
||||
<span class="method-name">${amount}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectPlan(months) {
|
||||
state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months);
|
||||
renderPlans(state.data.plans || []);
|
||||
renderMethods();
|
||||
document.getElementById('payment-panel').classList.remove('show');
|
||||
}
|
||||
|
||||
async function createPayment(method) {
|
||||
if (!state.selectedPlan) return;
|
||||
setMethodsDisabled(true);
|
||||
try {
|
||||
const data = await api('/payments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({months: state.selectedPlan.months, method})
|
||||
});
|
||||
if (!data.ok) throw new Error(data.message || 'Не удалось создать платеж');
|
||||
state.payment = data;
|
||||
showPaymentPanel(data);
|
||||
openPaymentUrl();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Ошибка оплаты');
|
||||
} finally {
|
||||
setMethodsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showPaymentPanel(payment) {
|
||||
const panel = document.getElementById('payment-panel');
|
||||
const msg = document.getElementById('payment-message');
|
||||
const openBtn = document.getElementById('payment-open-btn');
|
||||
const checkBtn = document.getElementById('payment-check-btn');
|
||||
panel.classList.add('show');
|
||||
if (payment.action === 'invoice_sent') {
|
||||
msg.textContent = 'Счет отправлен в чат с ботом.';
|
||||
openBtn.classList.add('hidden');
|
||||
} else {
|
||||
msg.textContent = 'Платеж создан. После оплаты нажмите проверку или обновите страницу.';
|
||||
openBtn.classList.remove('hidden');
|
||||
}
|
||||
checkBtn.classList.toggle('hidden', !payment.payment_id);
|
||||
}
|
||||
|
||||
function openPaymentUrl() {
|
||||
const payment = state.payment;
|
||||
if (!payment || !payment.payment_url) return;
|
||||
if (payment.action === 'open_invoice' && tg && tg.openInvoice) {
|
||||
tg.openInvoice(payment.payment_url, function(status) {
|
||||
if (status === 'paid') loadData();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tg && tg.openLink) {
|
||||
tg.openLink(payment.payment_url);
|
||||
} else {
|
||||
window.open(payment.payment_url, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPayment() {
|
||||
if (!state.payment || !state.payment.payment_id) return;
|
||||
const data = await api('/payments/' + state.payment.payment_id);
|
||||
if (data.paid) {
|
||||
showToast('Оплата подтверждена');
|
||||
await loadData();
|
||||
} else {
|
||||
showToast('Платеж пока не подтвержден');
|
||||
}
|
||||
}
|
||||
|
||||
function openConnectLink() {
|
||||
const sub = state.data && state.data.subscription;
|
||||
const url = sub && (sub.connect_url || sub.config_link);
|
||||
if (!url) return;
|
||||
if (tg && tg.openLink) tg.openLink(url);
|
||||
else window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
async function copyConfigLink() {
|
||||
const sub = state.data && state.data.subscription;
|
||||
const link = sub && sub.config_link;
|
||||
if (!link) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
showToast('Ссылка скопирована');
|
||||
} catch (e) {
|
||||
const area = document.createElement('textarea');
|
||||
area.value = link;
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand('copy');
|
||||
area.remove();
|
||||
showToast('Ссылка скопирована');
|
||||
}
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = Object.assign({'Authorization': 'Bearer ' + state.token}, options.headers || {});
|
||||
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||
const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers}));
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
await startExternalAuth();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
state.token = token;
|
||||
localStorage.setItem('rw_webapp_token', token);
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
state.token = '';
|
||||
localStorage.removeItem('rw_webapp_token');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken();
|
||||
startExternalAuth();
|
||||
}
|
||||
|
||||
function showLoader() {
|
||||
document.getElementById('loader').classList.remove('hidden');
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
document.getElementById('login').classList.remove('show');
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
document.getElementById('login').classList.remove('show');
|
||||
document.getElementById('app').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
document.getElementById('login').classList.add('show');
|
||||
}
|
||||
|
||||
function setMethodsDisabled(disabled) {
|
||||
document.querySelectorAll('.method').forEach(btn => btn.disabled = disabled);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
if (tg && tg.showAlert) {
|
||||
tg.showAlert(message);
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(value, currency) {
|
||||
const numeric = Number(value || 0);
|
||||
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
|
||||
return formatted + ' ' + (currency || 'RUB');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll('`', '`');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+51
-10
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "🚀 Купить",
|
||||
|
||||
Reference in New Issue
Block a user