Compare commits

..
49 Commits
Author SHA1 Message Date
3252a8 19ed8916f4 fix: migration and docker image name/tag 2026-04-27 21:31:06 +03:00
3252a8 f7de08bfd7 docs: update readme 2026-04-27 21:06:21 +03:00
3252a8 6e42f67931 docs: donation wallet 2026-04-27 21:02:49 +03:00
3252a8 b87b6cd378 fix: webapp session 2026-04-27 20:50:24 +03:00
3252a8 b39fb73c30 docs: project name change, migration script 2026-04-27 16:11:06 +03:00
3252a8 3120884ed5 docs: update readme 2026-04-27 15:15:25 +03:00
3252a8 c927244b1a docs: update readme 2026-04-27 15:13:00 +03:00
3252a8 e049f38c7c fix: readme format 2026-04-27 15:08:29 +03:00
3252a8 ddc6ee0024 feat: build webapp when docker build 2026-04-27 14:59:35 +03:00
3252a8 fa5f1460ab fix: separate platega buttons not showed up in webapp 2026-04-27 14:57:49 +03:00
3252a8 9f556f3e04 fix: add platega debug 2026-04-27 14:48:58 +03:00
3252a8 70b618a94b feat: use web page for cryptopay in web app 2026-04-27 14:38:09 +03:00
3252a8 0610156f80 feat: add multiple platega buttons 2026-04-27 14:34:41 +03:00
3252a8 587b39de1d fix(bot): connect button opens subscription page instead of mini app
Previously the connect button in post-payment messages and in 'My subscription' section opened the mini app (personal cabinet) when SUBSCRIPTION_MINI_APP_URL was set, ignoring the actual subscription URL. Now the real subscription URL is preferred, with the mini app used only as a fallback.
2026-04-27 11:06:32 +03:00
3252a8 e35885b54b docs: update readme 2026-04-27 09:44:28 +03:00
3252a8 c1b7ace876 fix(webapp): merge bundled FALLBACK_I18N with server locales
The bot's locales/*.json files do not contain the webapp-specific keys
(connect, extend_subscription, loading, etc.), so applyI18n was
overwriting the localized HTML defaults with raw key names.

Merge the bundled FALLBACK_I18N table with whatever the server provides
per language, letting server values override but falling back to the
in-bundle translations for keys the bot does not ship.
2026-04-27 09:42:18 +03:00
3252a8 f416b0aed1 fix(webapp): allow oauth.telegram.org iframe in CSP
The Telegram Login Widget embeds oauth.telegram.org in an iframe; without
an explicit frame-src directive it fell back to default-src 'self' and was
blocked. Add frame-src https://oauth.telegram.org so the login flow loads.
2026-04-27 09:34:09 +03:00
3252a8 c8192e4427 fix(webapp): allow inline JSON config and Telegram widget eval under CSP
Add a per-request nonce to inline <script type="application/json"> blocks
(webapp-config, i18n) so they survive script-src 'self' and locales/config
actually load in the browser. Also add 'unsafe-eval' to script-src so the
vendored telegram-widget.js (which uses eval/new Function) can initialise.
2026-04-27 09:31:52 +03:00
3252a8 dae1a6889b feat(ops): add /healthz endpoint and container healthcheck
Expose a lightweight /healthz route on the main aiohttp server and
wire docker-compose healthcheck so orchestrators can detect failures.
2026-04-27 08:43:58 +03:00
3252a8 4740666d63 chore: annotate XTR provider_token and add missing return type 2026-04-27 08:43:54 +03:00
3252a8 d6b703debb refactor(logging): use logger.exception to preserve stack traces
Unify error logging across services: replace logger.error(f"...{e}")
and logger.error(..., exc_info=True) with logger.exception() so the
stack trace is consistently captured.
2026-04-27 08:43:48 +03:00
3252a8 f1113eb80a fix(webhooks): return 5xx on YooKassa processing errors
Replace 200-on-error with 500 so the payment provider retries transient
failures instead of treating them as successfully processed.
2026-04-27 08:43:44 +03:00
3252a8 604f0d9656 security: audit inline Mini App handlers 2026-04-26 20:34:02 +03:00
3252a8 8ce5a92625 chore: harden ignore rules 2026-04-26 20:33:59 +03:00
3252a8 72b6e93c94 docs: add Telegram clock-skew note for auth_date 2026-04-26 20:33:57 +03:00
3252a8 d60952718e security: harden Mini App auth, CSRF, and validation 2026-04-26 20:33:54 +03:00
3252a8 53dcc59770 security: double-check CryptoPay webhook signatures 2026-04-26 20:33:49 +03:00
3252a8 5ec179b6d6 webapp: add hashed minified asset pipeline 2026-04-26 20:04:30 +03:00
3252a8 250df445f0 docker: run app as non-root user 2026-04-26 19:47:56 +03:00
3252a8 12376e5070 db: add composite indexes and merge-user optimizations 2026-04-26 19:47:14 +03:00
3252a8 c2afc6107a webapp: harden mini app rendering and controls 2026-04-26 19:47:06 +03:00
3252a8 77370eb963 security: harden webhooks and session secrets 2026-04-26 19:46:57 +03:00
3252a8 94b0787cad feat: use i18n 2026-04-26 12:34:16 +03:00
3252a8 9c499fe3c2 feat: add caddy docker compose example 2026-04-24 23:16:31 +03:00
3252a8 7e26f9da9b feat: automatic merge two paid subs (email and tg) 2026-04-24 23:07:57 +03:00
3252a8 778615a97f feat: tune web app visual 2026-04-24 22:35:55 +03:00
3252a8 651572f15f refactor: show tg avatar in detached browser 2026-04-24 21:31:58 +03:00
3252a8 5ccb8ddabe refactor: promo and email bruteforce defence 2026-04-24 21:18:29 +03:00
3252a8 86f944e544 feat: tune web app visual 2026-04-24 21:04:53 +03:00
3252a8 bc29f5ebd6 feat: promocode and ref in web app 2026-04-24 13:52:53 +03:00
3252a8 9a84be85c6 feat: move tg bot interaction keyboard to separate command, show only web app and support buttons by default 2026-04-24 12:43:21 +03:00
3252a8 c08ac854b2 fix: get email from panel if exist, tune webapp visual 2026-04-24 11:14:50 +03:00
3252a8 807a8933b9 feat: email login, smtp codes 2026-04-23 21:20:32 +03:00
3252a8 b9cb1fec06 feat: tune webapp visual, use telegram widget for login 2026-04-23 14:06:21 +03:00
3252a8 eab803652b fix: tg bot wont start 2026-04-22 22:44:19 +03:00
3252a8 259d0646bc fix: tune webapp visual and fix some errors 2026-04-22 22:35:10 +03:00
3252a8 4f1b7d0832 feat: web app 2026-04-22 16:00:55 +03:00
3252a8 1aa529ab23 Remove GitHub Actions and stale GHCR defaults 2026-04-22 15:19:54 +03:00
3252a8 a46502380c Remove locales volume from bot 2026-04-03 20:49:14 +03:00
73 changed files with 16657 additions and 706 deletions
+12
View File
@@ -5,6 +5,13 @@
.gitattributes
LICENSE
README.md
.env
.env.*
!.env.example
scratch_*.py
*.local.*
node_modules/
.git/
# CI
@@ -17,6 +24,11 @@ docker-compose.yml
Dockerfile
.docker
.dockerignore
tmp/
# WebApp build artifacts (regenerated inside Docker)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.min.*.js
# Byte-compiled / optimized / DLL files
**/__pycache__/
+44 -17
View File
@@ -3,9 +3,9 @@ BOT_TOKEN=your_bot_token_here #
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
# PostgreSQL Database Connection Settings
POSTGRES_USER=postgres # Database user name
POSTGRES_PASSWORD=postgres # Database password
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
POSTGRES_USER= # Required: database user name
POSTGRES_PASSWORD= # Required: database password
POSTGRES_HOST=remnawave-minishop-db # Database container name
POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name
@@ -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
@@ -31,17 +31,48 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
# 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="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
# Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS
TRIBUTE_ENABLED=True # Turn on TRIBUTE
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
@@ -64,6 +95,7 @@ FREEKASSA_API_KEY=your_api_key #
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
@@ -75,7 +107,11 @@ CRYPTOPAY_ASSET=RUB #
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto)
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
@@ -86,31 +122,22 @@ SEVERPAY_TOKEN= #
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
# Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
TRIBUTE_LINK_1_MONTH=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS=
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS=
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
-59
View File
@@ -1,59 +0,0 @@
name: Build and Push Dev GHCR Image
on:
push:
branches:
- dev
pull_request:
branches:
- dev
env:
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
flavor: |
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
-56
View File
@@ -1,56 +0,0 @@
name: Build and Publish GHCR Docker Image
on:
push:
branches:
- main
tags:
- 'v*.*.*'
paths-ignore:
- 'README.md'
env:
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_IMAGE }}
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+10
View File
@@ -3,6 +3,16 @@ bot_database.sqlite3
# Игнорировать файлы окружения
.env
.env.*
!.env.example
scratch_*.py
*.local.*
node_modules/
.git/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.min.*.js
# Игнорировать кэш Python
__pycache__/
+10
View File
@@ -0,0 +1,10 @@
# Replace the example domains below with your real webhook and Mini App hostnames.
webhook.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
}
app.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
}
+34 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim AS builder
FROM python:3.12-slim AS python-builder
WORKDIR /app
@@ -7,14 +7,45 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM node:22-slim AS webapp-builder
WORKDIR /webapp
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY bot/app/web/templates ./bot/app/web/templates
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
RUN npm run build:webapp
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop"
RUN useradd -u 10001 -m appuser
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY . .
# Replace template assets with freshly built ones
RUN rm -f bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.min.*.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.css
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
bot/app/web/templates/
RUN rm -rf /root/.cache
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
USER appuser
CMD ["python", "main.py"]
+253 -61
View File
@@ -1,17 +1,21 @@
# Telegram-бот для продажи подписок Remnawave
# Remnawave Minishop
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, Telegram Login Widget и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей.
> 🍴 **Это глубоко переработанный форк [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop).** Здесь добавлены полноценный Web App / Mini App, вход по email и многое другое. Возможна миграция.
## ✨ Ключевые возможности
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
- **Вход по email:** вход и регистрация в Web App по коду из письма, а также привязка email и Telegram к одному аккаунту.
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars.
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
@@ -45,12 +49,12 @@
1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/3252a8/remnawave-tg-shop
cd remnawave-tg-shop
git clone https://github.com/3252a8/remnawave-minishop
cd remnawave-minishop
```
2. **Создайте и настройте файл `.env`:**
Скопируйте `env.example` в `.env` и заполните своими данными.
Скопируйте `.env.example` в `.env` и заполните своими данными.
```bash
cp .env.example .env
nano .env
@@ -66,8 +70,29 @@
| `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` |
| `PRIVACY_POLICY_URL` | (Опционально) Ссылка на политику конфиденциальности, показывается внизу Web App. | `https://example.com/privacy` |
| `USER_AGREEMENT_URL` | (Опционально) Ссылка на пользовательское соглашение, показывается внизу Web App. | `https://example.com/agreement` |
| `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. | `#00fe7a` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` |
| `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` |
| `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` |
| `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` |
| `SMTP_TIMEOUT_SECONDS` | Timeout для каждой SMTP-попытки подключения и отправки. | `30` |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | Логин и SMTP key/password из Brevo. Если не заданы вместе с `SMTP_FROM_EMAIL`, вход по email скрывается. | `user@smtp-brevo.com` |
| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | Подтвержденный отправитель и отображаемое имя отправителя для писем с кодом. | `no-reply@example.com` |
| `EMAIL_CODE_TTL_SECONDS` | Срок действия кода подтверждения email. | `600` |
| `EMAIL_CODE_RESEND_SECONDS` | Минимальная пауза между отправками кода на один email. | `60` |
| `EMAIL_CODE_MAX_ATTEMPTS` | Максимум попыток на один конкретный код. | `5` |
| `BRUTE_FORCE_MAX_FAILURES` | Максимум неудачных попыток в окне защиты от перебора. | `5` |
| `BRUTE_FORCE_WINDOW_SECONDS` | Длительность окна, в котором считаются неудачные попытки. | `900` |
| `BRUTE_FORCE_LOCK_SECONDS` | Время временной блокировки после превышения лимита. | `1800` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `WEBAPP_SESSION_SECRET` | (Опционально) HMAC-секрет для подписи сессий Web App. Если пусто — генерируется при старте, но тогда сессии станут невалидными после перезапуска контейнера. Для прода задайте явно. | `см. раздел «Генерация секретов»` |
| `WEBHOOK_SECRET_TOKEN` | (Опционально) Secret token для проверки подлинности вебхуков Telegram. Если пусто — генерируется при старте. Для прода задайте явно, чтобы значение пережило рестарт. | `см. раздел «Генерация секретов»` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
</details>
@@ -77,13 +102,15 @@
| Переменная | Описание |
| --- | --- |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `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. |
| `YOOKASSA_SECRET_KEY` | Секретный ключ магазина YooKassa. |
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
| `NALOGO_INN` | ИНН для авторизации в nalog.ru (самозанятый). |
@@ -98,12 +125,12 @@
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. |
| `PLATEGA_SECRET`| API секрет для запросов Platega. |
| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
| `PLATEGA_ENABLED` | Включить/выключить Platega (`true`/`false`). |
| `PLATEGA_MERCHANT_ID` | MerchantId из личного кабинета Platega. |
| `PLATEGA_SECRET` | API секрет для запросов Platega. |
| `PLATEGA_PAYMENT_METHOD` | ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). |
| `PLATEGA_RETURN_URL` | (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. |
| `PLATEGA_FAILED_URL` | (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. |
| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). |
| `SEVERPAY_MID` | MID магазина в SeverPay. |
| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. |
@@ -148,41 +175,155 @@
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details>
3. **Запустите контейнеры:**
3. **Сгенерируйте секреты (рекомендуется):**
Переменные `WEBAPP_SESSION_SECRET` и `WEBHOOK_SECRET_TOKEN` могут быть пустыми — тогда они автоматически сгенерируются при каждом старте контейнера. Однако в проде это означает, что после рестарта все сессии Web App станут невалидными, а Telegram придётся перерегистрировать webhook. Поэтому для боевого окружения задайте оба значения вручную.
Сгенерировать криптостойкие значения можно одной из команд:
```bash
# вариант 1 — Python (есть в любом окружении с Python 3)
python -c "import secrets; print(secrets.token_urlsafe(32))"
# вариант 2 — openssl
openssl rand -base64 32 | tr -d '=+/' | cut -c1-43
# вариант 3 — /dev/urandom (Linux/macOS)
head -c 32 /dev/urandom | base64 | tr -d '=+/' | cut -c1-43
```
Запустите команду дважды и подставьте полученные значения в `.env`:
```env
WEBAPP_SESSION_SECRET=<первое_значение>
WEBHOOK_SECRET_TOKEN=<второе_значение>
```
> ⚠️ Не используйте одно и то же значение для обеих переменных и не коммитьте `.env` в git.
4. **Запустите контейнеры:**
```bash
docker compose up -d
```
Эта команда скачает образ и запустит сервис в фоновом режиме.
Эта команда соберёт образ из `Dockerfile` (Python + сборка Web App на Node) и запустит сервис в фоновом режиме. Если нужен запуск из готового образа GHCR — используйте `docker-compose-remote-server.yml`.
4. **Настройка вебхуков (Обязательно):**
5. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/platega`
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/freekassa`
- `https://<ваш_домен>/webhook/platega` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/platega`
- `https://<ваш_домен>/webhook/severpay` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/severpay`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-minishop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
Где `remnawave-minishop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
5. **Просмотр логов:**
**Отдельный порт Web App:**
- `https://<домен_web_app>/` → `http://remnawave-minishop:<WEBAPP_SERVER_PORT>/`
Web App не должен проксироваться на `WEB_SERVER_PORT`: этот порт оставьте для Telegram, платежных и Remnawave webhooks.
6. **Просмотр логов:**
```bash
docker compose logs -f remnawave-tg-shop
docker compose logs -f remnawave-minishop
```
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
### Настройка Web App / Mini App
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
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="#00fe7a"
WEBAPP_LOGO_URL=
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_FALLBACK_PORTS=2525,465
SMTP_USERNAME=<brevo-smtp-login>
SMTP_PASSWORD=<brevo-smtp-key>
SMTP_FROM_EMAIL=no-reply@domain.com
```
Если основной порт не отвечает, отправка письма автоматически пробует fallback-порты из `SMTP_FALLBACK_PORTS`. Для Brevo типичная схема: `587` с STARTTLS, затем `2525`, затем `465` через SSL.
2. Убедитесь, что `docker-compose.yml` публикует порт Web App:
```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-minishop-webapp {
server remnawave-minishop: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-minishop-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`. Этот же домен используется и Telegram Login Widget.
5. Перезапустите контейнер:
```bash
docker compose up -d --build
```
После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами.
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям.
Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды:
```bash
python scripts/update_telegram_web_app_js.py
python scripts/update_telegram_widget_js.py
```
При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория
```bash
git clone https://github.com/3252a8/remnawave-tg-shop && cd remnawave-tg-shop
git clone https://github.com/3252a8/remnawave-minishop && cd remnawave-minishop
```
### 2. Настройка переменных окружения
@@ -211,8 +352,8 @@ cd /opt/remnawave/nginx && nano nginx.conf
Добавьте в `nginx.conf` следующую конфигурацию:
```nginx
upstream remnawave-tg-shop {
server remnawave-tg-shop:8080;
upstream remnawave-minishop {
server remnawave-minishop:8080;
}
map $http_upgrade $connection_upgrade {
@@ -244,7 +385,7 @@ server {
error_page 400 404 500 502 @redirect;
location / {
proxy_pass http://remnawave-tg-shop$request_uri;
proxy_pass http://remnawave-minishop$request_uri;
}
location @redirect {
@@ -314,7 +455,7 @@ networks:
Запустите бота:
```bash
cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t
cd /root/remnawave-minishop && docker compose up -d && docker compose logs -f -t
```
Перезапустите Nginx:
@@ -325,45 +466,96 @@ cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker
## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта. Если нужен готовый образ из GHCR, используйте `docker-compose-remote-server.yml` или переключите `docker-compose.yml` на строку `image:` вместо `build: .`.
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта.
Образ публикуется в GitHub Container Registry по пути `ghcr.io/3252a8/remnawave-tg-shop`. GitHub Actions выкладывают теги `latest` и `0.1.0`: `latest` обновляется из `main`, а `0.1.0` появляется при сборке тега `v0.1.0`.
Если нужен запуск из готового образа, используйте `docker-compose-remote-server.yml` как шаблон и укажите свой `image:` вместо локальной сборки. По умолчанию он тянет `ghcr.io/3252a8/remnawave-minishop:latest`, а для закрепления версии можно задать `IMAGE_TAG=3.1.0`.
Чтобы закрепить версию на сервере, можно запустить:
В GHCR доступны теги `3.1.0` и `latest`.
Чтобы использовать сохранённый образ, можно запустить:
```bash
IMAGE_TAG=0.1.0 docker compose -f docker-compose-remote-server.yml up -d
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
```
### Вариант с Caddy
Если нужен reverse proxy на Caddy, используйте `docker-compose-caddy.yml` вместе с `Caddyfile`. Это удобный вариант, когда хочется, чтобы Caddy сам выпускал TLS-сертификаты и проксировал и webhook'и, и Mini App без ручной настройки Nginx.
В этой схеме:
- Caddy публикует наружу `80` и `443`.
- Бот остается доступным только внутри docker-сети.
- `WEBHOOK_BASE_URL` должен указывать на домен вебхуков, а `SUBSCRIPTION_MINI_APP_URL` - на домен Mini App.
Пример `Caddyfile`:
```caddyfile
webhook.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
}
app.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
}
```
Что нужно поменять под себя:
- заменить `webhook.domain.com` и `app.domain.com` на свои домены;
- убедиться, что в `.env` заданы `WEBHOOK_BASE_URL=https://webhook.domain.com` и `SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/`;
- при необходимости скорректировать `WEB_SERVER_PORT` и `WEBAPP_SERVER_PORT`, если они отличаются от стандартных `8080` и `8081`.
- в BotFather укажите домен Mini App через `/setdomain`, чтобы он совпадал с `SUBSCRIPTION_MINI_APP_URL`.
Запуск:
```bash
docker compose -f docker-compose-caddy.yml up -d --build
```
После этого Caddy сам выпустит сертификаты и будет проксировать webhook'и на порт `8080`, а Mini App - на `8081`.
## 🔄 Миграция с `remnawave-tg-shop` на `remnawave-minishop`
Короткая инструкция, автоматический запуск helper'а из `raw` и ручной вариант переноса вынесены в отдельный документ: [docs/migration-to-minishop.md](docs/migration-to-minishop.md).
## 📁 Структура проекта
```
.
├── bot/
│ ├── filters/ # Пользовательские фильтры Aiogram
├── handlers/ # Обработчики сообщений и колбэков
├── keyboards/ # Клавиатуры
├── middlewares/ # Промежуточные слои (i18n, проверка бана)
│ ├── services/ # Бизнес-логика (платежи, API панели)
│ ├── states/ # Состояния FSM
── main_bot.py # Основная логика бота
│ ├── app/ # Сборка приложения (фабрики, контроллеры, Web App)
│ ├── controllers/ # Запуск Aiogram dispatcher
│ ├── factories/ # Фабрики сервисов (платежи, панель и т.д.)
│ └── web/ # Web App / Mini App (сервер, аутентификация, шаблоны)
│ ├── filters/ # Пользовательские фильтры Aiogram
│ ├── handlers/ # Обработчики сообщений и колбэков (admin/, user/)
── keyboards/ # Клавиатуры
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана и т.д.)
│ ├── services/ # Бизнес-логика (платёжные шлюзы, API панели, email и т.д.)
│ ├── states/ # Состояния FSM (admin/user)
│ ├── utils/ # Вспомогательные утилиты
│ ├── routers.py # Регистрация всех роутеров Aiogram
│ └── main_bot.py # Основная логика бота
├── config/
│ └── settings.py # Настройки Pydantic
│ └── settings.py # Настройки Pydantic
├── db/
│ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # Настройка БД
── models.py # Модели SQLAlchemy
├── locales/ # Файлы локализации (ru, en)
├── .env.example # Пример файла с переменными окружения
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Файл для оркестрации контейнеров
├── requirements.txt # Зависимости Python
── main.py # Точка входа в приложение
│ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # Настройка БД и подключения
── migrator.py # Миграции схемы при старте
│ └── models.py # Модели SQLAlchemy
├── locales/ # Файлы локализации (ru.json, en.json)
├── scripts/ # Сборка JS Web App, обновление копий Telegram JS и миграционный helper
├── tests/ # Pytest-тесты
├── .env.example # Пример файла с переменными окружения
── Caddyfile # Пример конфигурации Caddy
├── Dockerfile # Multi-stage сборка (Python + Node для Web App)
├── docker-compose.yml # Локальная сборка и запуск
├── docker-compose-caddy.yml # Запуск с Caddy в качестве reverse proxy
├── docker-compose-remote-server.yml # Запуск из готового образа GHCR
├── package.json # Frontend-зависимости (Tailwind, esbuild) и сборка Web App
├── requirements.txt # Зависимости Python
└── main.py # Точка входа в приложение
```
## 🔮 Планы на будущее
- Расширенные типы промокодов (например, скидки в процентах).
## ❤️ Поддержка
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,296 @@
<!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, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#05070a">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>Моя подписка</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
src: url('https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
</style>
<script src="./telegram-web-app.js"></script>
<link rel="stylesheet" href="./subscription_webapp.css">
</head>
<body>
<div id="loader" class="app-loader" data-i18n="loading">Загрузка...</div>
<main id="app" class="app hidden">
<header class="app-header">
<div class="app-header-title">
<div class="brand-logo-shell hidden" data-brand-logo-shell aria-hidden="true">
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
<img id="brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
</div>
<div id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
</div>
<div class="app-header-actions">
<div class="lang-menu">
<button id="lang-chip" class="lang-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="lang-dropdown" aria-label="Language" data-action="toggle-lang-menu">
<span id="lang-chip-flag" class="lang-chip-flag" aria-hidden="true">🇷🇺</span>
<span id="lang-chip-label" class="lang-chip-label">RU</span>
<svg class="lang-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
</svg>
</button>
<div id="lang-dropdown" class="lang-dropdown hidden" role="menu"></div>
</div>
<div class="user-menu">
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" data-action="toggle-user-menu">
<img id="user-chip-avatar" class="user-chip-avatar" alt="" aria-hidden="true">
<span id="user-chip-name" class="user-chip-name">...</span>
<svg class="user-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
</svg>
</button>
<div id="user-dropdown" class="user-dropdown hidden" role="menu">
<div class="user-dropdown-head">
<img id="user-dropdown-avatar" class="user-dropdown-avatar" alt="" aria-hidden="true">
<div class="user-dropdown-head-text">
<div id="user-dropdown-name" class="user-dropdown-name">...</div>
<div id="user-dropdown-sub" class="user-dropdown-sub"></div>
</div>
</div>
<div class="user-dropdown-rows">
<div class="user-dropdown-row" role="menuitem">
<span class="user-dropdown-row-label" data-i18n="email_label">Email</span>
<span id="user-dropdown-email-status" class="user-dropdown-row-status">...</span>
</div>
<div class="user-dropdown-row" role="menuitem">
<span class="user-dropdown-row-label" data-i18n="telegram_label">Telegram</span>
<span id="user-dropdown-telegram-status" class="user-dropdown-row-status">...</span>
</div>
</div>
<button class="user-dropdown-logout" type="button" role="menuitem" data-action="logout">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
<path d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
</svg>
<span data-i18n="logout">Выйти</span>
</button>
</div>
</div>
</div>
</header>
<section class="panel grid gap-[17px] p-[18px]">
<div id="sub-status" class="sub-status">
<span class="sub-status-dot" aria-hidden="true"></span>
<span id="sub-status-text" class="sub-status-text">...</span>
</div>
<div class="sub-countdown">
<div class="sub-countdown-row">
<span id="remaining" class="sub-countdown-value">...</span>
<span id="end-date-caption" class="sub-countdown-caption"></span>
</div>
</div>
<div id="traffic-bar" class="traffic-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<div id="traffic-bar-fill" class="traffic-bar-fill"></div>
<span id="traffic" class="traffic-bar-value">...</span>
</div>
<div id="connect-actions">
<button id="connect-btn" class="btn-primary w-full" type="button" data-action="open-connect-link" data-i18n="connect">Подключиться</button>
</div>
<div class="mt-0.5 border-t border-[var(--border)] pt-3.5">
<button id="extend-btn" class="btn-ghost w-full" type="button" data-action="toggle-payment-flow" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
</div>
</section>
<section id="link-panel" class="panel hidden grid gap-3.5 p-[17px]">
<div class="panel-head">
<div>
<div id="link-panel-title" class="section-title" data-i18n="link_panel_title">Привяжите способ входа</div>
<div id="link-panel-caption" class="section-caption" data-i18n="link_panel_caption">Чтобы не терять доступ к кабинету</div>
</div>
</div>
<div id="email-link-box" class="hidden grid gap-2">
<div class="link-cta-head">
<span class="link-cta-title" data-i18n="link_email_title">Привяжите email</span>
<span class="link-cta-caption" data-i18n="link_email_caption">Получайте код для входа на почту</span>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
<input id="email-link-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-link-send-btn" class="btn" type="button" data-action="request-email-link-code" data-i18n="send_code">Отправить код</button>
</div>
<div id="email-link-code-row" class="hidden grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
<input id="email-link-code-input" class="input input-code w-full" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
<button id="email-link-verify-btn" class="btn-primary" type="button" data-action="verify-email-link-code" data-i18n="confirm">Подтвердить</button>
</div>
</div>
<div id="telegram-link-box" class="hidden grid gap-2">
<div class="link-cta-head">
<span class="link-cta-title" data-i18n="link_telegram_title">Привяжите Telegram</span>
<span class="link-cta-caption" data-i18n="link_telegram_caption">Входите одним нажатием через Telegram</span>
</div>
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
<div id="telegram-link-status" class="status-text hidden" aria-live="polite"></div>
</div>
</section>
<section class="grid min-w-0 grid-cols-2 gap-2">
<button class="btn w-full" type="button" data-action="open-referral-modal" data-i18n="referral_title">Пригласить друга</button>
<button class="btn w-full" type="button" data-action="open-promo-modal" data-i18n="promo_title">Промокод</button>
</section>
<a id="support-link" class="hidden mt-0.5 btn" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
<div id="legal-links-app" data-legal-links class="legal-links hidden">
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
</div>
</main>
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow"></button>
<section id="payment-flow" class="panel-modal modal-card grid gap-4 p-4">
<div class="panel-head">
<div>
<div id="payment-title" class="section-title text-[var(--accent)]" data-i18n="payment_title">Оплата подписки</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow">×</button>
</div>
<div class="grid gap-2.5">
<div class="section-label" data-i18n="select_period">Выберите период</div>
<div id="plans" class="grid grid-cols-2 gap-2 sm:grid-cols-4"></div>
</div>
<div class="notice grid gap-1.5">
<div class="section-label" data-i18n="payment_amount_label">Стоимость выбранного периода</div>
<div id="selected-plan-price" class="text-[20px] font-extrabold leading-tight text-[var(--text-primary)]">...</div>
</div>
<div class="grid gap-2.5">
<div class="section-label" data-i18n="choose_payment_method">Способ оплаты</div>
<div id="payment-methods" class="grid gap-2"></div>
</div>
</section>
</div>
<div id="promo-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="promo-title">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal"></button>
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
<div class="panel-head">
<div>
<div id="promo-title" class="section-title text-[var(--accent)]" data-i18n="promo_title">Промокод</div>
<div class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal">×</button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
<input id="promo-code-input" class="input input-code w-full" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
<button id="promo-apply-btn" class="btn-primary" type="button" data-action="apply-promo-code" data-i18n="apply_promo">Применить</button>
</div>
<div id="promo-status" class="status-text hidden" aria-live="polite"></div>
</section>
</div>
<div id="referral-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="referral-title">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-referral-modal"></button>
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
</div>
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-account-merge-modal"></button>
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
</div>
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal"></button>
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
<section class="panel-modal modal-card grid gap-3.5 p-[18px]">
<div class="panel-head">
<div>
<div id="email-code-title" class="section-title" data-i18n="email_code_title">Подтвердите вход</div>
<div id="email-code-caption" class="section-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal">×</button>
</div>
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.6fr)_minmax(0,1.4fr)]">
<div class="metric-label" data-i18n="email_label">Email</div>
<div id="email-code-address" class="metric-value">...</div>
</div>
<label class="relative block min-h-14 cursor-text">
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
<span class="otp-slots" aria-hidden="true">
<span id="email-code-slot-0"></span>
<span id="email-code-slot-1"></span>
<span id="email-code-slot-2"></span>
<span id="email-code-slot-3"></span>
<span id="email-code-slot-4"></span>
<span id="email-code-slot-5"></span>
</span>
</label>
<div id="email-code-status" class="status-text hidden" aria-live="polite"></div>
<div class="grid gap-2">
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" data-action="verify-email-login-code" data-i18n="login">Войти</button>
</div>
</section>
<button id="email-login-resend-btn" class="code-modal-resend" type="button" data-action="resend-email-login-code" data-i18n="resend_code">Отправить еще раз</button>
</div>
</div>
<section id="login" class="login login-layout gap-5">
<div class="grid justify-items-center gap-3 text-center">
<div class="brand-logo-shell brand-logo-shell--lg hidden" data-brand-logo-shell aria-hidden="true">
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
<img id="login-brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
</div>
<div id="login-brand-title" class="max-w-[320px] break-words font-[family-name:var(--font-mono)] text-[28px] font-extrabold leading-[1.05] tracking-[0] text-[var(--accent)] max-[460px]:max-w-[240px] max-[460px]:text-[24px]" data-brand-title>Моя подписка</div>
</div>
<div class="panel-modal w-full grid gap-5 p-6 text-center">
<h1 class="section-title text-[22px] leading-[1.1]" data-i18n="login_title">Войдите или зарегистрируйтесь</h1>
<div class="grid gap-2.5">
<div class="auth-tabs-wrapper" role="tablist">
<button id="email-auth-tab" class="auth-tab active" type="button" data-action="set-auth-mode" data-mode="email" data-i18n="email_login_tab">Email</button>
<button id="telegram-auth-tab" class="auth-tab" type="button" data-action="set-auth-mode" data-mode="telegram" data-i18n="telegram_login_tab">Telegram</button>
</div>
<div class="login-auth-body">
<div id="email-login-pane" class="grid gap-2.5">
<div class="grid gap-2.5">
<input id="email-login-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-login-send-btn" class="btn-primary w-full" type="button" data-action="request-email-login-code" data-i18n="login_continue">Продолжить</button>
</div>
</div>
<div id="telegram-login-pane" class="hidden grid gap-2.5">
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
</div>
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
</div>
</div>
</div>
<div id="legal-links-login" data-legal-links class="legal-links hidden">
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
</div>
</section>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,907 @@
@import "tailwindcss";
@source "./subscription_webapp.html";
@source "./subscription_webapp.js";
:root {
color-scheme: dark;
--bg-primary: #05070a;
--bg-card: #0f1521;
--bg-card-hover: #161e2e;
--border: #1e2940;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #00fe7a;
--accent-hover: #5aff9f;
--success: #10b981;
--danger: #ef4444;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 8px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--app-safe-top-extra: 10px;
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
}
@layer base {
html,
body {
@apply m-0 min-h-full w-full overflow-x-hidden tracking-[0];
}
body {
@apply overflow-x-hidden bg-[var(--bg-primary)] font-[family-name:var(--font-sans)] text-[var(--text-primary)] antialiased;
-webkit-tap-highlight-color: transparent;
}
body.modal-open {
@apply overflow-hidden;
}
button,
a {
font: inherit;
}
button {
@apply cursor-pointer border-0;
}
button:disabled {
@apply cursor-progress opacity-[0.58];
}
}
@layer components {
.btn-sm {
@apply inline-flex min-h-[38px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[11px] py-2 text-center font-extrabold text-[13px] leading-none text-[var(--text-secondary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.metrics {
@apply grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.018)];
}
.metric {
@apply grid min-h-12 grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] items-center gap-2.5 border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2.5;
}
.auth-tab {
@apply min-h-9 rounded-[var(--radius-sm)] bg-transparent font-[850] text-[var(--text-secondary)] transition-colors;
}
.login-layout {
@apply hidden mx-auto min-h-screen w-[min(100vw,560px)] flex-col items-center justify-center gap-4 px-[14px];
padding-top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
padding-bottom: max(var(--app-safe-bottom), 18px);
}
.section-label {
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold text-[var(--text-muted)];
}
.app-loader {
@apply grid min-h-[78vh] place-items-center font-[family-name:var(--font-mono)] font-extrabold text-[var(--text-muted)];
}
.auth-tabs-wrapper {
@apply grid min-h-[46px] grid-cols-2 gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.38)] p-1;
}
.panel-head {
@apply flex items-start justify-between gap-3;
}
.section-title {
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
}
.section-caption {
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
}
.metric-label {
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
}
.metric-value {
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
}
.referral-link-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.referral-link-value {
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
}
.bonus-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.empty-state {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.plan-card {
@apply inline-flex min-h-[44px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-center font-extrabold text-[14px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
transition-duration: 200ms;
}
.plan-card-active {
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] text-[var(--accent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
}
.payment-method-card {
@apply inline-flex min-h-[52px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-4 py-[12px] text-center font-extrabold text-[15px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
transition-duration: 200ms;
}
.payment-method-card--platega {
border-color: rgba(0, 254, 122, 0.36);
background: rgba(0, 254, 122, 0.08);
}
.payment-method-card--cryptopay {
border-color: rgba(34, 211, 238, 0.36);
background: rgba(34, 211, 238, 0.08);
}
.plan-name {
@apply block text-[15px] font-extrabold leading-tight;
}
.plan-meta {
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
}
.plan-price {
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
}
.notice {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.step-num {
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
}
.step-name {
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
}
.main-value {
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
}
.main-caption {
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.status-text {
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.legal-links {
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-center;
}
.app-header {
@apply flex min-h-12 items-center justify-between gap-3;
}
.app-header-title {
@apply flex min-w-0 items-center gap-2.5;
}
.app-header-actions {
@apply flex min-w-0 items-center gap-2;
}
.panel-head-sm {
@apply flex min-w-0 items-center justify-between gap-2.5;
}
.user-menu {
@apply relative inline-flex;
}
.lang-menu {
@apply relative inline-flex;
}
.lang-chip {
@apply inline-flex min-h-[38px] cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-2 text-left font-extrabold text-[12px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.lang-chip[aria-expanded="true"] {
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
}
.lang-chip-flag {
@apply text-[14px] leading-none;
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.lang-chip-label {
@apply font-[family-name:var(--font-mono)] text-[11px] tracking-wide;
}
.lang-chip-caret {
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
}
.lang-chip[aria-expanded="true"] .lang-chip-caret {
transform: rotate(180deg);
}
.lang-dropdown {
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[160px] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
backdrop-filter: blur(14px);
will-change: opacity, transform;
}
.lang-dropdown.show {
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
}
.lang-dropdown-item {
@apply flex cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-2.5 text-left text-[13px] font-bold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
}
.lang-dropdown-item + .lang-dropdown-item {
@apply border-t border-[var(--border)];
}
.lang-dropdown-item.is-active {
@apply bg-[var(--bg-card-hover)] text-[var(--text-primary)];
}
.lang-dropdown-flag {
@apply text-[16px] leading-none;
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.lang-dropdown-label {
@apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
}
.user-chip {
@apply inline-flex min-h-[38px] min-w-0 max-w-[200px] cursor-pointer items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] py-1 pl-1 pr-2.5 text-left font-extrabold text-[13px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.user-chip[aria-expanded="true"] {
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
}
.user-chip-avatar {
@apply h-[28px] w-[28px] flex-none rounded-[var(--radius-sm)] object-cover;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
image-rendering: pixelated;
}
.user-chip-name {
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
}
.user-chip-caret {
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
}
.user-chip[aria-expanded="true"] .user-chip-caret {
transform: rotate(180deg);
}
.user-dropdown {
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[min(calc(100vw-28px),280px)] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
backdrop-filter: blur(14px);
will-change: opacity, transform;
}
.user-dropdown.show {
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
}
.user-dropdown-head {
@apply flex items-center gap-2.5 border-b border-[var(--border)] px-3 py-2.5;
}
.user-dropdown-avatar {
@apply h-[40px] w-[40px] flex-none rounded-[var(--radius-md)] object-cover;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
image-rendering: pixelated;
}
.user-dropdown-head-text {
@apply grid min-w-0 gap-0.5;
}
.user-dropdown-name {
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm font-extrabold text-[var(--text-primary)];
}
.user-dropdown-sub {
@apply overflow-hidden text-ellipsis whitespace-nowrap font-[family-name:var(--font-mono)] text-[11px] text-[var(--text-muted)];
}
.user-dropdown-rows {
@apply grid;
}
.user-dropdown-row {
@apply flex items-center justify-between gap-3 px-3 py-2.5;
}
.user-dropdown-row + .user-dropdown-row {
@apply border-t border-[var(--border)];
}
.user-dropdown-row-label {
@apply font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
}
.user-dropdown-row-status {
@apply overflow-hidden text-ellipsis whitespace-nowrap text-right text-[13px] font-bold text-[var(--text-primary)];
}
.user-dropdown-row-status.linked {
@apply text-[#6ee7b7];
}
.user-dropdown-row-status.unlinked {
@apply text-[var(--text-muted)];
}
.user-dropdown-logout {
@apply flex min-h-[42px] w-full cursor-pointer items-center justify-center gap-2 rounded-none border-0 border-t border-[var(--border)] bg-transparent px-3 py-2.5 text-center text-[13px] font-extrabold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
}
.user-dropdown-logout svg {
@apply h-4 w-4 fill-current;
}
.sub-status {
@apply flex min-h-[28px] items-center gap-2.5 text-[var(--danger)];
}
.sub-status.active {
@apply text-[var(--success)];
}
.sub-status-dot {
@apply relative inline-block h-[10px] w-[10px] flex-none rounded-full;
background: currentColor;
box-shadow: 0 0 12px currentColor;
}
.sub-status-dot::after {
content: '';
position: absolute;
inset: 0;
border-radius: 9999px;
background: currentColor;
opacity: 0.55;
animation: sub-status-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.sub-status-text {
@apply font-extrabold text-[15px] leading-tight;
letter-spacing: 0.01em;
}
@keyframes brand-logo-spin {
to {
transform: rotate(360deg);
}
}
.sub-countdown {
@apply grid gap-1;
}
.sub-countdown-row {
@apply flex flex-wrap items-baseline gap-x-2 gap-y-0.5;
}
.sub-countdown-value {
@apply font-extrabold text-[22px] leading-[1.1] text-[var(--text-primary)];
}
.sub-countdown-caption {
@apply font-[family-name:var(--font-mono)] text-[12px] leading-[1.3] text-[var(--text-muted)];
}
.traffic-bar {
@apply relative flex min-h-[24px] w-full items-center justify-center overflow-hidden rounded-full border border-[var(--border)];
background: rgba(255, 255, 255, 0.02);
}
.traffic-bar-fill {
@apply absolute inset-y-0 left-0;
width: 0%;
background: color-mix(in srgb, var(--accent) 28%, transparent);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
}
.traffic-bar.over .traffic-bar-fill {
background: color-mix(in srgb, var(--danger) 32%, transparent);
}
.traffic-bar.unlimited .traffic-bar-fill {
width: 100%;
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.traffic-bar-value {
@apply relative z-[1] min-w-0 px-3 font-[family-name:var(--font-mono)] text-[12px] font-bold text-center whitespace-nowrap;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.traffic-bar.unlimited .traffic-bar-value {
animation: traffic-infinity-pulse 1.8s ease-in-out infinite;
}
@keyframes traffic-infinity-pulse {
0%, 100% {
opacity: 1;
text-shadow: 0 0 0 color-mix(in srgb, var(--success) 0%, transparent);
}
50% {
opacity: 0.7;
text-shadow: 0 0 10px color-mix(in srgb, var(--success) 70%, transparent);
}
}
.link-cta-head {
@apply grid gap-1;
}
.link-cta-title {
@apply font-extrabold text-[15px] leading-tight text-[var(--text-primary)];
}
.link-cta-caption {
@apply text-[13px] leading-[1.4] text-[var(--text-secondary)];
}
@keyframes sub-status-pulse {
0% {
transform: scale(1);
opacity: 0.55;
}
70% {
transform: scale(2.2);
opacity: 0;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
.login-head {
@apply flex min-w-0 items-center justify-center gap-3.5;
}
.btn {
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[13px] py-[11px] text-center font-extrabold text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
overflow-wrap: anywhere;
}
.btn-primary {
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_80%,var(--border))] bg-[var(--accent)] px-[13px] py-[11px] text-center font-extrabold text-[#04110a] no-underline shadow-[0_10px_26px_color-mix(in_srgb,var(--accent)_18%,transparent)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[var(--accent-hover)] hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--accent)_24%,transparent)];
overflow-wrap: anywhere;
}
.btn-ghost {
@apply inline-flex min-h-[46px] min-w-0 w-full cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_35%,var(--border))] bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-[13px] py-[11px] text-center font-extrabold text-[var(--accent)] no-underline shadow-none transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:shadow-[0_10px_24px_color-mix(in_srgb,var(--accent)_10%,transparent)];
overflow-wrap: anywhere;
}
.icon-btn {
@apply inline-flex h-[38px] w-[38px] flex-none cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] text-[var(--text-secondary)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.input {
@apply min-h-[46px] w-full rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.42)] px-3 py-[11px] font-bold text-[var(--text-primary)] outline-none transition-[border-color,background,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] placeholder:text-[var(--text-muted)] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:bg-[rgba(7,12,19,0.68)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_12%,transparent),0_12px_28px_rgba(0,0,0,0.2)] aria-[invalid=true]:border-[color-mix(in_srgb,var(--danger)_72%,var(--border))] aria-[invalid=true]:focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--danger)_14%,transparent)];
}
.input-code {
@apply text-center font-[family-name:var(--font-mono)] tracking-[0];
}
.panel {
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_20px_40px_rgba(0,0,0,0.18)];
}
.panel-modal {
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_30px_60px_rgba(0,0,0,0.28)];
}
.step-box {
@apply min-h-[58px] rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[9px] text-[var(--text-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)];
}
.modal {
@apply fixed inset-0 z-20 grid place-items-center opacity-0 transition-opacity pointer-events-none;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
}
.modal.show {
@apply opacity-100 pointer-events-auto;
}
.modal-backdrop,
.tools-modal-backdrop {
@apply absolute inset-0 border-0 p-0;
background: rgba(3, 6, 10, 0.72);
backdrop-filter: blur(7px);
}
.tools-modal-backdrop {
@apply fixed z-25;
}
.modal-card {
@apply relative max-h-[min(86vh,760px)] w-[min(100%,560px)] overflow-auto transition-transform duration-[0.22s] ease-[cubic-bezier(0.2,0.8,0.2,1)];
transform: translateY(18px) scale(0.98);
}
.modal.show .modal-card {
transform: translateY(0) scale(1);
}
.app {
@apply mx-auto flex w-[min(100vw,560px)] max-w-[100vw] flex-col gap-4;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
}
@media (min-width: 561px) {
.app {
@apply min-h-screen justify-center;
}
}
.telegram-login-widget {
@apply flex min-w-0 items-center;
}
.brand-logo-shell {
@apply relative grid h-[38px] w-[38px] flex-none place-items-center overflow-hidden;
}
.brand-logo-shell--lg {
@apply h-[96px] w-[96px];
}
.brand-logo {
@apply absolute inset-0 h-full w-full object-contain;
}
.brand-logo-spinner {
@apply h-5 w-5 flex-none rounded-full border-2;
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
border-top-color: var(--accent);
animation: brand-logo-spin 0.85s linear infinite;
}
.brand-logo--lg {
@apply h-[84px] w-[84px];
}
.plans,
.methods,
.metrics,
.referral-link-list,
.bonus-list {
@apply grid min-w-0;
}
.actions,
.otp-slots,
.metric {
@apply grid min-w-0 gap-2;
}
.actions {
grid-template-columns: minmax(0, 1fr) 48px;
}
.metric {
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
}
.metrics {
@apply gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)];
background: rgba(255, 255, 255, 0.018);
}
.metric,
.notice,
.otp-slots span {
@apply border border-[var(--border)];
background: rgba(255, 255, 255, 0.02);
}
.metric {
@apply min-h-[46px] items-center gap-2.5 px-3 py-2.5;
}
.metric {
@apply min-h-12;
}
.metric + .metric {
@apply border-t border-[var(--border)];
}
.brand-title,
.login-brand-title {
@apply max-w-[260px] font-[family-name:var(--font-mono)] font-extrabold tracking-[0] text-[var(--accent)];
}
.brand-title {
@apply overflow-hidden text-ellipsis whitespace-nowrap text-[17px] leading-[1.15];
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 18%, transparent);
}
.login-brand-title {
@apply text-left text-2xl leading-[1.1];
overflow-wrap: anywhere;
}
.login-auth-body {
@apply grid w-full min-h-[140px] gap-2.5 content-start;
}
.login-auth-body--telegram {
@apply content-center;
}
.login-text,
.promo-status {
@apply text-[var(--text-secondary)];
}
.section-label {
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
overflow-wrap: anywhere;
}
.section-label {
@apply font-extrabold;
}
.mono-pill,
.badge {
@apply inline-flex min-h-7 items-center rounded-[var(--radius-md)] px-2.5 font-[family-name:var(--font-mono)] text-[11px] font-bold whitespace-nowrap;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.mono-pill {
@apply text-[var(--accent)];
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.badge {
@apply min-w-0 overflow-hidden text-ellipsis text-[#6ee7b7];
border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border));
background: rgba(16, 185, 129, 0.12);
}
.badge.off,
.promo-status.error,
.login-status.error {
@apply text-[#fca5a5];
}
.badge.off {
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
background: rgba(239, 68, 68, 0.12);
}
.promo-status.success {
@apply text-[#6ee7b7];
}
#legal-links-login {
@apply w-[min(100vw,560px)];
}
.legal-link,
.code-modal-resend {
@apply border-0 bg-transparent p-0 text-[13px] font-bold text-[var(--text-secondary)] underline underline-offset-[3px] transition-colors;
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
}
.legal-link {
@apply inline flex-initial;
}
.otp-input {
@apply absolute inset-0 z-2 h-full w-full border-0 bg-transparent text-transparent opacity-[0.01] outline-none;
caret-color: transparent;
}
.otp-slots {
@apply grid-cols-6;
}
.otp-slots span {
@apply grid aspect-square min-w-0 place-items-center rounded-[var(--radius-md)] font-[family-name:var(--font-mono)] text-xl font-[850] leading-none text-[var(--text-primary)] transition-[border-color,background,box-shadow];
background: rgba(5, 8, 13, 0.42);
}
.otp-slots span.filled {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
}
.otp-slots span.active {
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.step-num,
.step-name {
@apply block text-current;
}
.step-num {
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1];
}
.step-name {
@apply mt-1 text-xs font-extrabold leading-[1.15];
}
.step.active,
.auth-tab.active {
@apply border-[var(--accent)] text-[var(--accent)];
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.step.done {
@apply text-[var(--text-secondary)];
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.notice {
@apply rounded-[var(--radius-md)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.login-layout.show {
@apply flex;
}
.telegram-login-widget {
@apply min-h-14 justify-center;
}
.login-text,
.promo-status {
@apply m-0 text-sm leading-[1.45];
}
.promo-status {
@apply mt-0.5 min-h-[22px];
overflow-wrap: anywhere;
}
.login-status {
@apply m-0 min-h-5;
}
.toast {
@apply fixed left-1/2 z-40 w-max max-w-[calc(100vw-28px)] rounded-[var(--radius-md)] px-[13px] py-[11px] text-[13px] font-bold text-[var(--text-primary)] opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)];
bottom: max(var(--app-safe-bottom), 22px);
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background: rgba(15, 21, 33, 0.82);
backdrop-filter: blur(14px);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.45), 0 0 15px color-mix(in srgb, var(--accent) 15%, transparent);
pointer-events: none;
transform: translate(-50%, 14px) scale(0.94);
}
.toast.show {
@apply opacity-100;
transform: translate(-50%, 0) scale(1);
}
}
.hidden {
display: none !important;
}
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
.mono-pill,
.step.active,
.auth-tab.active {
border-color: var(--accent);
}
.mono-pill,
.step.active,
.auth-tab.active {
background: rgba(0, 254, 122, 0.08);
}
}
@media (hover: hover) and (pointer: fine) {
.legal-link:hover:not(:disabled),
.code-modal-resend:hover:not(:disabled) {
@apply text-[var(--accent)];
}
}
@media (max-width: 460px) {
.app {
@apply px-2.5;
}
.user-chip {
@apply max-w-[150px];
}
.user-chip-name {
@apply hidden;
}
.brand-title {
@apply max-w-[164px];
}
.metric {
@apply grid-cols-1;
}
.badge,
.mono-pill {
@apply max-w-full;
}
.otp-slots {
@apply gap-1.5;
}
.otp-slots span {
@apply text-lg;
}
.brand-logo--lg {
@apply h-[68px] w-[68px];
}
.brand-logo-shell--lg {
@apply h-[96px] w-[96px];
}
.login-auth-body {
@apply min-h-[128px];
}
.login-brand-title {
@apply max-w-[200px] text-[21px];
}
}
@media (max-width: 380px) {
.brand-title {
@apply max-w-[150px];
}
}
@media (max-width: 560px) {
.step-box {
@apply min-h-[54px] px-[7px] py-2;
}
.modal {
@apply place-items-center p-0;
top: 0;
bottom: 0;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
}
.modal--page {
@apply place-items-stretch p-0;
padding: 0 !important;
}
.modal--page .modal-backdrop {
@apply bg-[var(--bg-primary)] opacity-100;
backdrop-filter: none;
}
.modal-card {
@apply h-auto max-h-[min(92vh,760px)] w-full rounded-[var(--radius-lg)];
transform: translateY(12px) scale(0.98);
}
.modal--page .modal-card {
@apply h-full w-full max-h-none max-w-none rounded-none border-0 shadow-none;
transform: none !important;
}
.modal.show .modal-card {
transform: translateY(0) scale(1);
}
}
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
(function(window) {
(function(window){
window.__parseFunction = function(__func, __attrs) {
__attrs = __attrs || [];
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
return window.execScript ? window.execScript(__func) : eval(__func);
}
}(window));
(function(window){
function addEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.addEventListener) {
el.addEventListener(events[i], handler);
} else {
el.attachEvent('on' + events[i], handler);
}
}
}
function removeEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.removeEventListener) {
el.removeEventListener(events[i], handler);
} else {
el.detachEvent('on' + events[i], handler);
}
}
}
function getCssProperty(el, prop) {
if (window.getComputedStyle) {
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
} else if (el.currentStyle) {
return el.currentStyle[prop] || null;
}
return null;
}
function geById(el_or_id) {
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
return document.getElementById(el_or_id);
} else if (el_or_id instanceof HTMLElement) {
return el_or_id;
}
return null;
}
var getWidgetsOrigin = function(default_origin, dev_origin) {
var link = document.createElement('A'), origin;
link.href = document.currentScript && document.currentScript.src || default_origin;
origin = link.origin || link.protocol + '//' + link.hostname;
if (origin == 'https://telegram.org') {
origin = default_origin;
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
origin = dev_origin;
} else {
origin = default_origin;
}
return origin;
};
var getPageCanonical = function() {
var a = document.createElement('A'), link, href;
if (document.querySelector) {
link = document.querySelector('link[rel="canonical"]');
if (link && (href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
} else {
var links = document.getElementsByTagName('LINK');
for (var i = 0; i < links.length; i++) {
if ((link = links[i]) &&
(link.getAttribute('rel') == 'canonical') &&
(href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
}
}
return false;
};
function haveTgAuthResult() {
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
try {
locationHash = location.hash.toString();
if (match = locationHash.match(re)) {
location.hash = locationHash.replace(re, '');
var data = match[1] || '';
data = data.replace(/-/g, '+').replace(/_/g, '/');
var pad = data.length % 4;
if (pad > 1) {
data += new Array(5 - pad).join('=');
}
return JSON.parse(window.atob(data));
}
} catch (e) {}
return false;
}
function getXHR() {
if (navigator.appName == "Microsoft Internet Explorer"){
return new ActiveXObject("Microsoft.XMLHTTP");
} else {
return new XMLHttpRequest();
}
}
if (!window.Telegram) {
window.Telegram = {};
}
if (!window.Telegram.__WidgetUuid) {
window.Telegram.__WidgetUuid = 0;
}
if (!window.Telegram.__WidgetLastId) {
window.Telegram.__WidgetLastId = 0;
}
if (!window.Telegram.__WidgetCallbacks) {
window.Telegram.__WidgetCallbacks = {};
}
function postMessageToIframe(iframe, event, data, callback) {
if (!iframe._ready) {
if (!iframe._readyQueue) iframe._readyQueue = [];
iframe._readyQueue.push([event, data, callback]);
return;
}
try {
data = data || {};
data.event = event;
if (callback) {
data._cb = ++window.Telegram.__WidgetLastId;
window.Telegram.__WidgetCallbacks[data._cb] = {
iframe: iframe,
callback: callback
};
}
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
} catch(e) {}
}
function initWidget(widgetEl) {
var widgetId, widgetElId, widgetsOrigin, existsEl,
src, styles = {}, allowedAttrs = [],
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
if (!widgetEl.tagName ||
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
widgetEl.classList.contains('telegram-post'))) {
return null;
}
if (widgetEl._iframe) {
return widgetEl._iframe;
}
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
var comment = widgetEl.getAttribute('data-comment') || '';
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
src = widgetsOrigin + '/' + widgetId + '?embed=1';
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = '';
styles.minWidth = '320px';
}
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
var websitePageUrl = widgetEl.getAttribute('data-page-url');
if (!websitePageUrl) {
websitePageUrl = getPageCanonical();
}
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = widgetEl.getAttribute('data-height') || 0;
styles.minWidth = '320px';
if (defHeight > 0) {
scrollable = true;
}
}
else if (widgetEl.hasAttribute('data-telegram-login')) {
widgetId = widgetEl.getAttribute('data-telegram-login');
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
defWidth = 186;
defHeight = 28;
if (widgetEl.hasAttribute('data-size')) {
var size = widgetEl.getAttribute('data-size');
if (size == 'small') defWidth = 148, defHeight = 20;
else if (size == 'large') defWidth = 238, defHeight = 40;
}
if (widgetEl.hasAttribute('data-onauth')) {
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
}
else if (widgetEl.hasAttribute('data-auth-url')) {
var a = document.createElement('A');
a.href = widgetEl.getAttribute('data-auth-url');
onAuthUser = function(user) {
var authUrl = a.href;
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
var params = [];
for (var key in user) {
params.push(key + '=' + encodeURIComponent(user[key]));
}
authUrl += params.join('&');
location.href = authUrl;
};
}
if (widgetEl.hasAttribute('data-onunauth')) {
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
}
var auth_result = haveTgAuthResult();
if (auth_result && onAuthUser) {
onAuthUser(auth_result);
}
}
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-share-' + window.btoa(widgetId);
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
defWidth = 60;
defHeight = 20;
if (widgetEl.getAttribute('data-size') == 'large') {
defWidth = 76;
defHeight = 28;
}
}
else {
return null;
}
existsEl = document.getElementById(widgetElId);
if (existsEl) {
return existsEl;
}
for (var i = 0; i < allowedAttrs.length; i++) {
var attr = allowedAttrs[i];
var novalue = attr.substr(-1) == '?';
if (novalue) {
attr = attr.slice(0, -1);
}
var data_attr = 'data-' + attr.replace(/_/g, '-');
if (widgetEl.hasAttribute(data_attr)) {
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
src += '&' + attr + '=' + attr_value;
}
}
function getCurCoords(iframe) {
var docEl = document.documentElement;
var frect = iframe.getBoundingClientRect();
return {
frameTop: frect.top,
frameBottom: frect.bottom,
frameLeft: frect.left,
frameRight: frect.right,
frameWidth: frect.width,
frameHeight: frect.height,
scrollTop: window.pageYOffset,
scrollLeft: window.pageXOffset,
clientWidth: docEl.clientWidth,
clientHeight: docEl.clientHeight
};
}
function visibilityHandler() {
if (isVisible(iframe, 50)) {
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
}
}
function focusHandler() {
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
}
function postMessageHandler(event) {
if (event.source !== iframe.contentWindow ||
event.origin != widgetsOrigin) {
return;
}
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (data.event == 'resize') {
if (data.height) {
iframe.style.height = data.height + 'px';
}
if (data.width) {
iframe.style.width = data.width + 'px';
}
}
else if (data.event == 'ready') {
iframe._ready = true;
focusHandler();
for (var i = 0; i < iframe._readyQueue.length; i++) {
var queue_item = iframe._readyQueue[i];
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
}
iframe._readyQueue = [];
}
else if (data.event == 'visible_off') {
removeEvent(window, 'scroll', visibilityHandler);
removeEvent(window, 'resize', visibilityHandler);
}
else if (data.event == 'get_coords') {
postMessageToIframe(iframe, 'callback', {
_cb: data._cb,
value: getCurCoords(iframe)
});
}
else if (data.event == 'scroll_to') {
try {
window.scrollTo(data.x || 0, data.y || 0);
} catch(e) {}
}
else if (data.event == 'auth_user') {
if (data.init) {
onInitAuthUser && onInitAuthUser(data.auth_data);
} else {
onAuthUser && onAuthUser(data.auth_data);
}
}
else if (data.event == 'unauthorized') {
onUnauth && onUnauth();
}
else if (data.event == 'callback') {
var cb_data = null;
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
if (cb_data.iframe === iframe) {
cb_data.callback(data.value);
delete window.Telegram.__WidgetCallbacks[data._cb];
}
} else {
console.warn('Callback #' + data._cb + ' not found');
}
}
}
var iframe = document.createElement('iframe');
iframe.id = widgetElId;
iframe.src = src;
iframe.width = defWidth;
iframe.height = defHeight;
iframe.setAttribute('frameborder', '0');
if (!scrollable) {
iframe.setAttribute('scrolling', 'no');
iframe.style.overflow = 'hidden';
}
iframe.style.colorScheme = 'light dark';
iframe.style.border = 'none';
for (var prop in styles) {
iframe.style[prop] = styles[prop];
}
if (widgetEl.parentNode) {
widgetEl.parentNode.insertBefore(iframe, widgetEl);
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
widgetEl.parentNode.removeChild(widgetEl);
}
}
iframe._ready = false;
iframe._readyQueue = [];
widgetEl._iframe = iframe;
addEvent(iframe, 'load', function() {
removeEvent(iframe, 'load', visibilityHandler);
addEvent(window, 'scroll', visibilityHandler);
addEvent(window, 'resize', visibilityHandler);
visibilityHandler();
});
addEvent(window, 'focus blur', focusHandler);
addEvent(window, 'message', postMessageHandler);
return iframe;
}
function isVisible(el, padding) {
var node = el, val;
var visibility = getCssProperty(node, 'visibility');
if (visibility == 'hidden') return false;
while (node) {
if (node === document.documentElement) break;
var display = getCssProperty(node, 'display');
if (display == 'none') return false;
var opacity = getCssProperty(node, 'opacity');
if (opacity !== null && opacity < 0.1) return false;
node = node.parentNode;
}
if (el.getBoundingClientRect) {
padding = +padding || 0;
var rect = el.getBoundingClientRect();
var html = document.documentElement;
if (rect.bottom < padding ||
rect.right < padding ||
rect.top > (window.innerHeight || html.clientHeight) - padding ||
rect.left > (window.innerWidth || html.clientWidth) - padding) {
return false;
}
}
return true;
}
function getAllWidgets() {
var widgets = [];
if (document.querySelectorAll) {
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
} else {
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
}
return widgets;
}
function getWidgetInfo(el_or_id, callback) {
var e = null, iframe = null;
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe && callback) {
postMessageToIframe(iframe, 'get_info', {}, callback);
}
}
}
function setWidgetOptions(options, el_or_id) {
var e = null, iframe = null;
if (typeof el_or_id === 'undefined') {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
if (iframe = widgets[i]._iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
} else {
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
}
}
if (!document.currentScript ||
!initWidget(document.currentScript)) {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
initWidget(widgets[i]);
}
}
var TelegramLogin = {
popups: {},
options: null,
auth_callback: null,
_init: function(options, auth_callback) {
TelegramLogin.options = options;
TelegramLogin.auth_callback = auth_callback;
var auth_result = haveTgAuthResult();
if (auth_result && auth_callback) {
auth_callback(auth_result);
}
},
_open: function(callback) {
TelegramLogin._auth(TelegramLogin.options, function(authData) {
if (TelegramLogin.auth_callback) {
TelegramLogin.auth_callback(authData);
}
if (callback) {
callback(authData);
}
});
},
_auth: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var width = 550;
var height = 470;
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
var onMessage = function (event) {
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (!TelegramLogin.popups[bot_id]) return;
if (event.source !== TelegramLogin.popups[bot_id].window) return;
if (data.event == 'auth_result') {
onAuthDone(data.result);
}
};
var onAuthDone = function (authData) {
if (!TelegramLogin.popups[bot_id]) return;
if (TelegramLogin.popups[bot_id].authFinished) return;
callback && callback(authData);
TelegramLogin.popups[bot_id].authFinished = true;
removeEvent(window, 'message', onMessage);
};
var checkClose = function(bot_id) {
if (!TelegramLogin.popups[bot_id]) return;
if (!TelegramLogin.popups[bot_id].window ||
TelegramLogin.popups[bot_id].window.closed) {
return TelegramLogin.getAuthData(options, function(origin, authData) {
onAuthDone(authData);
});
}
setTimeout(checkClose, 100, bot_id);
}
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
TelegramLogin.popups[bot_id] = {
window: popup,
authFinished: false
};
if (popup) {
addEvent(window, 'message', onMessage);
popup.focus();
checkClose(bot_id);
}
},
getAuthData: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var xhr = getXHR();
var url = Telegram.Login.widgetsOrigin + '/auth/get';
xhr.open('POST', url);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
try {
var result = JSON.parse(xhr.responseText);
} catch(e) {
var result = {};
}
if (result.user) {
callback(result.origin, result.user);
} else {
callback(result.origin, false);
}
} else {
callback('*', false);
}
}
};
xhr.onerror = function() {
callback('*', false);
};
xhr.withCredentials = true;
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
}
};
window.Telegram.getWidgetInfo = getWidgetInfo;
window.Telegram.setWidgetOptions = setWidgetOptions;
window.Telegram.Login = {
init: TelegramLogin._init,
open: TelegramLogin._open,
auth: TelegramLogin._auth,
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
};
}(window));
})(window);
+95 -12
View File
@@ -1,5 +1,8 @@
import hmac
import asyncio
import logging
from contextlib import suppress
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
@@ -8,18 +11,26 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings
async def build_and_start_web_app(
class SecureSimpleRequestHandler(SimpleRequestHandler):
def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool:
if not self.secret_token:
return False
return hmac.compare_digest(telegram_secret_token, self.secret_token)
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
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,17 +45,35 @@ 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)
async def _healthcheck(request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
app.router.add_get("/healthz", _healthcheck)
setup_application(app, dp, bot=bot)
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
if telegram_uses_webhook_mode:
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
telegram_webhook_path = settings.telegram_webhook_path
SecureSimpleRequestHandler(
dispatcher=dp,
bot=bot,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
).register(app, path=telegram_webhook_path)
logging.info(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
)
@@ -87,10 +116,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 +132,56 @@ 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()
telegram_web_app_sdk_refresh_task = None
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import (
create_subscription_webapp_application,
refresh_telegram_login_widget_sdk,
refresh_telegram_web_app_sdk,
)
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,
)
async def _refresh_telegram_web_assets_forever() -> None:
while True:
await refresh_telegram_web_app_sdk()
await refresh_telegram_login_widget_sdk()
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
telegram_web_app_sdk_refresh_task = asyncio.create_task(
_refresh_telegram_web_assets_forever(),
name="TelegramWebAssetsRefreshTask",
)
try:
await asyncio.Event().wait()
finally:
if telegram_web_app_sdk_refresh_task is not None:
telegram_web_app_sdk_refresh_task.cancel()
with suppress(asyncio.CancelledError):
await telegram_web_app_sdk_refresh_task
for runner in reversed(runners):
try:
await runner.cleanup()
except Exception as cleanup_error:
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
+188
View File
@@ -0,0 +1,188 @@
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl
from config.settings import Settings
logger = logging.getLogger(__name__)
# 5 minutes clock skew tolerance for Telegram clients
TELEGRAM_CLOCK_SKEW_SECONDS = 300
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.WEBAPP_SESSION_SECRET.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 + TELEGRAM_CLOCK_SKEW_SECONDS 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
if parsed_data.get("start_param"):
user_data["start_param"] = parsed_data.get("start_param")
return user_data
except Exception as exc:
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
return None
def validate_telegram_login_widget_data(
auth_data: Any,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Login Widget data and return the trusted user payload."""
try:
if isinstance(auth_data, str):
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
elif isinstance(auth_data, dict):
parsed_data = {
str(key): str(value)
for key, value in auth_data.items()
if value is not None
}
else:
return None
received_hash = str(parsed_data.pop("hash", "") or "")
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hashlib.sha256(bot_token.encode("utf-8")).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 Login Widget 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 + TELEGRAM_CLOCK_SKEW_SECONDS or now - auth_date > max_age:
logger.warning("Telegram Login Widget auth_date is stale.")
return None
user_id_raw = parsed_data.get("id")
if not user_id_raw:
return None
int(user_id_raw)
if not parsed_data.get("first_name"):
return None
return parsed_data
except Exception as exc:
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
return None
+6 -3
View File
@@ -23,6 +23,7 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_logs_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
@@ -225,12 +226,14 @@ async def process_user_id_for_logs_handler(message: types.Message,
input_text = message.text.strip() if message.text else ""
user_model_for_logs: Optional[User] = None
if input_text.isdigit():
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
try:
user_model_for_logs = await user_dal.get_user_by_id(
session, int(input_text))
except ValueError:
pass
elif EMAIL_REGEX.match(input_text):
user_model_for_logs = await user_dal.get_user_by_email(session, input_text)
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model_for_logs = await user_dal.get_user_by_username(
session, input_text[1:])
@@ -245,7 +248,7 @@ async def process_user_id_for_logs_handler(message: types.Message,
target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username else f"ID {target_user_id}")
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
@@ -292,7 +295,7 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username else f"ID {target_user_id}")
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE,
+152 -5
View File
@@ -18,6 +18,11 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_sync_router")
def _normalize_panel_email(value: Optional[str]) -> Optional[str]:
email = (value or "").strip().lower()
return email or None
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
user_traffic = panel_user_data.get("userTraffic") or {}
raw_value = (
@@ -36,6 +41,85 @@ def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]
return None
async def _bind_panel_email_to_user(
session: AsyncSession,
*,
existing_user,
email_from_panel: Optional[str],
panel_uuid: str,
) -> tuple[object, bool]:
"""Bind panel email to a local user without violating the unique email index.
Panel email is treated as verified because it comes from the operator-managed
panel. If the same email already belongs to an email-only local account for
this panel user, merge that account into the Telegram/local user.
"""
if not email_from_panel:
return existing_user, False
if existing_user.email == email_from_panel:
if not existing_user.email_verified_at:
existing_user.email_verified_at = datetime.now(timezone.utc)
return existing_user, True
return existing_user, False
user_with_email = await user_dal.get_user_by_email(session, email_from_panel)
if user_with_email and user_with_email.user_id != existing_user.user_id:
can_merge_email_identity = (
not user_with_email.telegram_id
and user_with_email.panel_user_uuid in (None, panel_uuid)
and (not existing_user.email or existing_user.email == email_from_panel)
)
if can_merge_email_identity:
try:
merged_user = await user_dal.merge_users(
session,
source_user_id=user_with_email.user_id,
target_user_id=existing_user.user_id,
)
if not merged_user.email:
merged_user.email = email_from_panel
if not merged_user.email_verified_at:
merged_user.email_verified_at = datetime.now(timezone.utc)
logging.info(
"Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.",
user_with_email.user_id,
merged_user.user_id,
email_from_panel,
panel_uuid,
)
return merged_user, True
except Exception as merge_error:
logging.warning(
"Could not merge email-only user %s into user %s for panel email %s: %s",
user_with_email.user_id,
existing_user.user_id,
email_from_panel,
merge_error,
)
return existing_user, False
logging.warning(
"Panel email %s for panel UUID %s is already linked to local user %s; "
"skipping email binding for user %s.",
email_from_panel,
panel_uuid,
user_with_email.user_id,
existing_user.user_id,
)
return existing_user, False
existing_user.email = email_from_panel
existing_user.email_verified_at = datetime.now(timezone.utc)
logging.info(
"Bound panel email %s to local user %s for panel UUID %s.",
email_from_panel,
existing_user.user_id,
panel_uuid,
)
return existing_user, True
async def perform_sync(
panel_service: PanelApiService,
session: AsyncSession,
@@ -94,6 +178,7 @@ async def perform_sync(
"shortUuid"
)
telegram_id_from_panel = panel_user_dict.get("telegramId")
email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
if not panel_uuid:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
@@ -111,15 +196,20 @@ async def perform_sync(
# First, try to find by telegram ID if available
if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_id(
existing_user = await user_dal.get_user_by_telegram_id(
session, telegram_id_from_panel
)
if not existing_user:
existing_user = await user_dal.get_user_by_id(
session, telegram_id_from_panel
)
if existing_user:
logging.debug(
f"Found user by telegramId {telegram_id_from_panel}"
)
# If not found by telegram ID, try to find by panel UUID
# If not found by telegram ID, try to find by panel UUID.
# The panel UUID is the strongest local link for subscription sync.
if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(
session, panel_uuid
@@ -137,6 +227,15 @@ async def perform_sync(
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
)
# Finally, fall back to email. This mainly catches panel users that
# were first imported as email-only identities.
if not existing_user and email_from_panel:
existing_user = await user_dal.get_user_by_email(
session, email_from_panel
)
if existing_user:
logging.debug(f"Found user by email {email_from_panel}")
if not existing_user:
users_not_found_in_db += 1
if telegram_id_from_panel:
@@ -144,6 +243,13 @@ async def perform_sync(
try:
user_data = {
"user_id": telegram_id_from_panel,
"telegram_id": telegram_id_from_panel,
"email": email_from_panel,
"email_verified_at": (
datetime.now(timezone.utc)
if email_from_panel
else None
),
"username": None, # Username will be updated when user interacts with bot
"first_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info
@@ -172,6 +278,28 @@ async def perform_sync(
f"Error creating user {telegram_id_from_panel}: {e_create}"
)
continue
elif email_from_panel:
try:
new_user, was_created = await user_dal.create_email_user(
session,
email=email_from_panel,
language_code="ru",
)
new_user.panel_user_uuid = panel_uuid
if was_created:
users_created += 1
logging.info(
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}"
)
existing_user = new_user
except Exception as e_create_email:
sync_errors.append(
f"Error creating email user {email_from_panel}: {str(e_create_email)}"
)
logging.error(
f"Error creating email user {email_from_panel}: {e_create_email}"
)
continue
else:
logging.debug(
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
@@ -193,6 +321,20 @@ async def perform_sync(
logging.info(
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
)
existing_user, email_was_bound = await _bind_panel_email_to_user(
session,
existing_user=existing_user,
email_from_panel=email_from_panel,
panel_uuid=panel_uuid,
)
if email_was_bound:
user_was_updated = True
if (
telegram_id_from_panel
and existing_user.telegram_id != telegram_id_from_panel
):
existing_user.telegram_id = telegram_id_from_panel
user_was_updated = True
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
if (
@@ -206,11 +348,12 @@ async def perform_sync(
try:
if panel_uuid and existing_user:
description_text = "\n".join(
[
line for line in [
existing_user.email or "",
existing_user.username or "",
existing_user.first_name or "",
existing_user.last_name or "",
]
] if line
)
# Update description only when it differs from the current one on panel
current_panel_description = (
@@ -222,7 +365,11 @@ async def perform_sync(
and desired_description != current_panel_description
):
await panel_service.update_user_details_on_panel(
panel_uuid, {"description": description_text}
panel_uuid, {
"description": description_text,
**({"email": existing_user.email} if existing_user.email else {}),
**({"telegramId": existing_user.telegram_id} if existing_user.telegram_id else {}),
}
)
except Exception as e_desc:
logging.warning(
+26 -36
View File
@@ -31,6 +31,7 @@ from bot.utils.telegram_markup import (
router = Router(name="admin_user_management_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
@@ -53,6 +54,24 @@ def _format_used_with_period(get_text: Callable[..., str], used_display: str, pe
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
async def _find_user_by_admin_input(
session: AsyncSession,
input_text: str,
) -> Optional[User]:
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
try:
return await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
return None
if EMAIL_REGEX.match(input_text):
return await user_dal.get_user_by_email(session, input_text)
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
return await user_dal.get_user_by_username(session, input_text[1:])
if USERNAME_REGEX.match(input_text):
return await user_dal.get_user_by_username(session, input_text)
return None
async def users_list_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession, page: int = 0):
@@ -250,6 +269,10 @@ async def format_user_card(user: User, session: AsyncSession,
card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}")
card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}")
card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}")
if user.email:
card_parts.append(f"{_('admin_user_email_label')} {hcode(user.email)}")
if user.telegram_id and int(user.telegram_id) != int(user.user_id):
card_parts.append(f"{_('admin_user_telegram_id_label')} {hcode(str(user.telegram_id))}")
card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}")
card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}")
@@ -361,18 +384,7 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
user_model = await _find_user_by_admin_input(session, input_text)
if not user_model:
await message.answer(_(
@@ -1177,18 +1189,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
user_model = await _find_user_by_admin_input(session, input_text)
if not user_model:
await message.answer(_(
@@ -1244,18 +1245,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else ""
user_model: Optional[User] = None
# Try to find user by ID or username
if input_text.isdigit():
try:
user_model = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model = await user_dal.get_user_by_username(session, input_text)
user_model = await _find_user_by_admin_input(session, input_text)
if not user_model:
await message.answer(_(
+27 -16
View File
@@ -25,12 +25,22 @@ from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
"77.75.153.0/25",
"77.75.156.11",
"77.75.156.35",
"77.75.154.128/25",
"2a02:5180::/32",
]
async def process_successful_payment(session: AsyncSession, bot: Bot,
@@ -476,14 +486,18 @@ async def yookassa_webhook_route(request: web.Request):
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
async_session_factory: sessionmaker = request.app[
'async_session_factory']
except KeyError as e_app_ctx:
logging.error(
f"KeyError accessing app context in yookassa_webhook_route: {e_app_ctx}.",
exc_info=True)
except KeyError:
logging.exception(
"KeyError accessing app context in yookassa_webhook_route.")
return web.Response(
status=500,
text="Internal Server Error: Missing app context component")
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
logging.warning("YooKassa webhook denied from unauthorized IP source.")
return web.Response(status=403)
try:
event_json = await request.json()
@@ -658,23 +672,20 @@ async def yookassa_webhook_route(request: web.Request):
logging.exception("Failed to cancel bind-only payment auth")
except Exception:
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
except Exception as e_webhook_db_processing:
except Exception:
await session.rollback()
logging.error(
f"Error processing YooKassa webhook event '{notification_object.event}' "
f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
exc_info=True)
logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.",
notification_object.event,
payment_dict_for_processing.get('id'))
return web.Response(
status=200, text="ok_internal_processing_error_logged")
status=500, text="internal_processing_error")
return web.Response(status=200, text="ok")
except json.JSONDecodeError:
logging.error("YooKassa Webhook: Invalid JSON received.")
return web.Response(status=400, text="bad_request_invalid_json")
except Exception as e_general_webhook:
logging.error(
f"YooKassa Webhook general processing error: {e_general_webhook}",
exc_info=True)
return web.Response(status=200,
text="ok_general_internal_error_logged")
except Exception:
logging.exception("YooKassa Webhook general processing error.")
return web.Response(status=500, text="internal_error")
+48 -47
View File
@@ -4,7 +4,6 @@ from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram.utils.markdown import hcode
from config.settings import Settings
from bot.states.user_states import UserPromoStates
@@ -16,6 +15,7 @@ from bot.keyboards.inline.user_keyboards import (
)
from datetime import datetime
from bot.middlewares.i18n import JsonI18n
from bot.utils.callback_answer import safe_answer_callback
from .start import send_main_menu
@@ -35,15 +35,18 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language service error.", show_alert=True)
await safe_answer_callback(callback, "Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
logging.error(
"CallbackQuery has no message in prompt_promo_code_input")
await callback.answer(_("error_occurred_processing_request"),
show_alert=True)
await safe_answer_callback(
callback,
_("error_occurred_processing_request"),
show_alert=True,
)
return
try:
@@ -58,7 +61,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
await callback.answer()
await safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info(
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
@@ -119,46 +122,41 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}")
response_to_user_text = _("promo_code_not_found",
code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
if success:
await session.commit()
logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}."
)
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
response_to_user_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
)
else:
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
if success:
await session.commit()
logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}."
)
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
response_to_user_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
)
else:
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await session.commit()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await message.answer(
response_to_user_text,
@@ -181,7 +179,7 @@ async def cancel_promo_input_via_button(
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in cancel_promo_input_via_button")
await callback.answer("Language error", show_alert=True)
await safe_answer_callback(callback, "Language error", show_alert=True)
return
logging.info(
@@ -200,5 +198,8 @@ async def cancel_promo_input_via_button(
else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_("promo_input_cancelled_short"),
show_alert=False)
await safe_answer_callback(
callback,
_("promo_input_cancelled_short"),
show_alert=False,
)
+29
View File
@@ -2,9 +2,11 @@ import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from typing import Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal
from bot.services.referral_service import ReferralService
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
@@ -103,6 +105,16 @@ async def referral_command_handler(event: Union[types.Message,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"])
if settings.SUBSCRIPTION_MINI_APP_URL:
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
webapp_referral_link = _build_webapp_referral_link(
settings.SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
if webapp_referral_link:
webapp_label = "Web App ссылка" if current_lang == "ru" else "Web App link"
text += f"\n\n🔗 {webapp_label}:\n<code>{webapp_referral_link}</code>"
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
@@ -167,3 +179,20 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
await callback.answer("Произошла ошибка", show_alert=True)
await callback.answer()
def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional[str]) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
return urlunsplit(
(
parts.scheme,
parts.netloc,
parts.path or "/",
urlencode(query),
parts.fragment,
)
)
+171 -44
View File
@@ -14,6 +14,7 @@ from db.models import User
from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard,
get_bot_interface_inline_keyboard,
get_language_selection_keyboard,
get_channel_subscription_keyboard,
get_information_links_keyboard,
@@ -25,10 +26,29 @@ 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.utils.callback_answer import safe_answer_callback
router = Router(name="user_start_router")
async def should_show_trial_button(
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int) -> bool:
if not settings.TRIAL_ENABLED:
return False
if hasattr(subscription_service, 'has_had_any_subscription') and callable(
getattr(subscription_service, 'has_had_any_subscription')):
return not await subscription_service.has_had_any_subscription(
session, user_id)
logging.error(
"Method has_had_any_subscription is missing in SubscriptionService!"
)
return False
async def send_main_menu(target_event: Union[types.Message,
types.CallbackQuery],
settings: Settings,
@@ -61,18 +81,8 @@ async def send_main_menu(target_event: Union[types.Message,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
show_trial_button_in_menu = False
if settings.TRIAL_ENABLED:
if hasattr(
subscription_service, 'has_had_any_subscription') and callable(
getattr(subscription_service, 'has_had_any_subscription')):
if not await subscription_service.has_had_any_subscription(
session, user_id):
show_trial_button_in_menu = True
else:
logging.error(
"Method has_had_any_subscription is missing in SubscriptionService for send_main_menu!"
)
show_trial_button_in_menu = await should_show_trial_button(
settings, subscription_service, session, user_id)
text = _(key="main_menu_greeting", user_name=user_full_name)
reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
@@ -90,8 +100,11 @@ async def send_main_menu(target_event: Union[types.Message,
f"send_main_menu: target_message_obj is None for event from user {user_id}."
)
if isinstance(target_event, types.CallbackQuery):
await target_event.answer(_("error_displaying_menu"),
show_alert=True)
await safe_answer_callback(
target_event,
_("error_displaying_menu"),
show_alert=True,
)
return
try:
@@ -101,10 +114,7 @@ async def send_main_menu(target_event: Union[types.Message,
await target_message_obj.answer(text, reply_markup=reply_markup)
if isinstance(target_event, types.CallbackQuery):
try:
await target_event.answer()
except Exception:
pass
await safe_answer_callback(target_event)
except Exception as e_send_edit:
logging.warning(
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
@@ -117,11 +127,72 @@ async def send_main_menu(target_event: Union[types.Message,
f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
)
if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback(
target_event,
_("error_occurred_try_again") if is_edit else None,
)
async def send_bot_interface_menu(
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n_instance missing in send_bot_interface_menu.")
return
user_id = target_event.from_user.id
show_trial_button_in_menu = await should_show_trial_button(
settings, subscription_service, session, user_id)
text = i18n.gettext(current_lang, "bot_interface_menu_title")
reply_markup = get_bot_interface_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu)
target_message_obj: Optional[types.Message] = None
if isinstance(target_event, types.Message):
target_message_obj = target_event
elif isinstance(target_event, types.CallbackQuery) and target_event.message:
target_message_obj = target_event.message
if not target_message_obj:
logging.error(
"send_bot_interface_menu: target_message_obj is None for user %s.",
user_id,
)
return
try:
if is_edit:
await target_message_obj.edit_text(text, reply_markup=reply_markup)
else:
await target_message_obj.answer(text, reply_markup=reply_markup)
if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback(target_event)
except Exception as e_send_edit:
logging.warning(
"Failed to send/edit bot interface menu (user: %s, is_edit: %s): %s - %s.",
user_id,
is_edit,
type(e_send_edit).__name__,
e_send_edit,
)
if is_edit:
try:
await target_event.answer(
_("error_occurred_try_again") if is_edit else None)
except Exception:
pass
await target_message_obj.answer(text, reply_markup=reply_markup)
except Exception as e_send_new:
logging.error(
"Also failed to send new bot interface menu for user %s: %s",
user_id,
e_send_new,
)
async def ensure_required_channel_subscription(
@@ -307,7 +378,7 @@ 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"^(?!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,
@@ -617,7 +688,7 @@ async def start_command_handler(message: types.Message,
# Don't show main menu if promo was successfully applied
return
else:
await session.rollback()
await session.commit()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
await message.answer(str(result), parse_mode="HTML")
# Continue to show main menu if promo failed
@@ -641,6 +712,31 @@ async def start_command_handler(message: types.Message,
is_edit=False)
@router.message(Command("tg"))
async def tg_interface_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
return
await send_bot_interface_menu(message,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
@router.callback_query(F.data == "channel_subscription:verify")
async def verify_channel_subscription_callback(
callback: types.CallbackQuery,
@@ -679,8 +775,11 @@ async def verify_channel_subscription_callback(
welcome_text)
try:
await callback.answer(_(key="channel_subscription_verified_success"),
show_alert=True)
await safe_answer_callback(
callback,
_(key="channel_subscription_verified_success"),
show_alert=True,
)
except Exception:
pass
@@ -711,7 +810,11 @@ async def language_command_handler(
event, types.CallbackQuery) else event
if not target_message_obj:
if isinstance(event, types.CallbackQuery):
await event.answer(_("error_occurred_try_again"), show_alert=True)
await safe_answer_callback(
event,
_("error_occurred_try_again"),
show_alert=True,
)
return
if isinstance(event, types.CallbackQuery):
@@ -722,7 +825,7 @@ async def language_command_handler(
except Exception:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
await event.answer()
await safe_answer_callback(event)
else:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
@@ -734,15 +837,21 @@ async def select_language_callback_handler(
subscription_service: SubscriptionService, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Service error or message context lost.",
show_alert=True)
await safe_answer_callback(
callback,
"Service error or message context lost.",
show_alert=True,
)
return
try:
lang_code = callback.data.split("_")[2]
except IndexError:
await callback.answer("Error processing language selection.",
show_alert=True)
await safe_answer_callback(
callback,
"Error processing language selection.",
show_alert=True,
)
return
user_id = callback.from_user.id
@@ -753,18 +862,22 @@ async def select_language_callback_handler(
i18n_data["current_language"] = lang_code
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
await callback.answer(_(key="language_set_alert"))
await safe_answer_callback(callback, _(key="language_set_alert"))
logging.info(
f"User {user_id} language updated to {lang_code} in session.")
else:
await callback.answer("Could not set language.", show_alert=True)
await safe_answer_callback(
callback,
"Could not set language.",
show_alert=True,
)
return
except Exception as e_lang_update:
logging.error(
f"Error updating lang for user {user_id}: {e_lang_update}",
exc_info=True)
await callback.answer("Error setting language.", show_alert=True)
await safe_answer_callback(callback, "Error setting language.", show_alert=True)
return
await send_main_menu(callback,
settings,
@@ -789,7 +902,11 @@ async def main_action_callback_handler(
from . import trial_handler as user_trial_handlers
if not callback.message:
await callback.answer("Error: message context lost.", show_alert=True)
await safe_answer_callback(
callback,
"Error: message context lost.",
show_alert=True,
)
return
if action == "subscribe":
@@ -820,8 +937,11 @@ async def main_action_callback_handler(
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
if not i18n:
await callback.answer("Language service error.",
show_alert=True)
await safe_answer_callback(
callback,
"Language service error.",
show_alert=True,
)
return
_ = lambda key, **kwargs: i18n.gettext(
current_lang, key, **kwargs) if i18n else key
@@ -830,8 +950,11 @@ async def main_action_callback_handler(
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if not privacy_url and not user_agreement_url:
await callback.answer(_("error_occurred_try_again"),
show_alert=True)
await safe_answer_callback(
callback,
_("error_occurred_try_again"),
show_alert=True,
)
return
reply_markup = get_information_links_keyboard(
@@ -846,7 +969,7 @@ async def main_action_callback_handler(
except Exception:
await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.answer()
await safe_answer_callback(callback)
elif action == "back_to_main":
await send_main_menu(callback,
settings,
@@ -864,5 +987,9 @@ async def main_action_callback_handler(
else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(
i18n_data.get("current_language"), key, **kw) if i18n else key
await callback.answer(_("main_menu_unknown_action"), show_alert=True)
i18n_data.get("current_language"), key, **kwargs) if i18n else key
await safe_answer_callback(
callback,
_("main_menu_unknown_action"),
show_alert=True,
)
+10 -11
View File
@@ -238,23 +238,22 @@ async def my_subscription_command_handler(
# Build rows to prepend above the base "back" markup
prepend_rows = []
# 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
if settings.SUBSCRIPTION_MINI_APP_URL:
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
])
elif settings.SUBSCRIPTION_MINI_APP_URL:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
])
else:
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
])
if settings.MY_DEVICES_SECTION_ENABLED:
max_devices_value = active.get("max_devices")
@@ -14,7 +14,11 @@ from db.dal import payment_dal
router = Router(name="user_subscription_payments_platega_router")
@router.callback_query(F.data.startswith("pay_platega:"))
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
@@ -22,6 +26,29 @@ async def pay_platega_callback_handler(
platega_service: PlategaService,
session: AsyncSession,
):
callback_prefix, _, _ = (callback.data or "").partition(":")
if callback_prefix == "pay_platega_crypto":
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
platega_variant = "crypto"
if not settings.PLATEGA_CRYPTO_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
elif callback_prefix == "pay_platega_sbp":
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
if not settings.PLATEGA_SBP_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
else:
# Legacy callback (pre-split): keep working as SBP
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -103,6 +130,7 @@ async def pay_platega_callback_handler(
"user_id": user_id,
"months": months,
"sale_mode": sale_mode,
"platega_variant": platega_variant,
}
)
@@ -114,6 +142,7 @@ async def pay_platega_callback_handler(
currency=currency_code,
description=payment_description,
payload=payload_meta,
payment_method=platega_method_id,
)
if success:
+4
View File
@@ -268,6 +268,8 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
user_display_parts.append(user_row.first_name)
if user_row.username:
user_display_parts.append(f"(@{user_row.username})")
elif user_row.email:
user_display_parts.append(f"({user_row.email})")
if not user_display_parts:
user_display_parts.append(f"ID: {user_row.user_id}")
@@ -321,6 +323,8 @@ def get_users_list_keyboard(users: List[User], current_page: int,
user_display_parts = []
if user.username:
user_display_parts.append(f"@{user.username}")
elif user.email:
user_display_parts.append(user.email)
user_display_parts.append(f"ID: {user.user_id}")
if user.first_name:
user_display_parts.append(f"- {user.first_name}")
+60 -8
View File
@@ -13,11 +13,50 @@ def get_main_menu_inline_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_personal_account_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
else:
builder.row(
InlineKeyboardButton(
text=_(key="menu_personal_account_button"),
callback_data="main_action:my_subscription",
)
)
if settings.SUPPORT_LINK:
builder.row(
InlineKeyboardButton(text=_(key="menu_support_button"),
url=settings.SUPPORT_LINK))
return builder.as_markup()
def get_bot_interface_inline_keyboard(
lang: str,
i18n_instance,
settings: Settings,
show_trial_button: bool = False) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED:
builder.row(
InlineKeyboardButton(text=_(key="menu_activate_trial_button"),
callback_data="main_action:request_trial"))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_personal_account_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
builder.row(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe"))
@@ -156,6 +195,14 @@ def get_payment_method_keyboard(months: int, price: float,
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
mode_suffix = f":{sale_mode}"
import logging as _kbd_logging
_kbd_logging.info(
"payment_method_keyboard build: order=%s | platega_enabled=%s sbp=%s crypto=%s",
settings.payment_methods_order,
settings.PLATEGA_ENABLED,
settings.PLATEGA_SBP_ENABLED,
settings.PLATEGA_CRYPTO_ENABLED,
)
for method in settings.payment_methods_order:
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
builder.button(
@@ -167,10 +214,15 @@ def get_payment_method_keyboard(months: int, price: float,
text=_("pay_with_sbp_button"),
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
)
elif method == "platega" and settings.PLATEGA_ENABLED:
elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED:
builder.button(
text=_("pay_with_platega_button"),
callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}",
text=_("pay_with_platega_sbp_button"),
callback_data=f"pay_platega_sbp:{value_str}:{price}{mode_suffix}",
)
elif method == "platega_crypto" and settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED:
builder.button(
text=_("pay_with_platega_crypto_button"),
callback_data=f"pay_platega_crypto:{value_str}:{price}{mode_suffix}",
)
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
builder.button(
@@ -405,17 +457,17 @@ def get_connect_and_main_keyboard(
builder = InlineKeyboardBuilder()
button_target = connect_button_url or config_link
if settings.SUBSCRIPTION_MINI_APP_URL:
if button_target:
builder.row(
InlineKeyboardButton(text=_("connect_button"), url=button_target)
)
elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
elif button_target:
builder.row(
InlineKeyboardButton(text=_("connect_button"), url=button_target)
)
else:
builder.row(
InlineKeyboardButton(
+71 -59
View File
@@ -40,6 +40,12 @@ from bot.handlers.admin.sync_admin import perform_sync
from bot.utils.message_queue import init_queue_manager
def redact_token(value: str, token: Optional[str]) -> str:
if not value or not token:
return value
return value.replace(token, "***")
async def register_all_routers(dp: Dispatcher, settings: Settings):
dp.include_router(build_root_router(settings))
logging.info("All application routers registered.")
@@ -59,52 +65,48 @@ async def on_startup_configured(dispatcher: Dispatcher):
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
if telegram_webhook_url_to_set:
full_telegram_webhook_url = (
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
f"{str(telegram_webhook_url_to_set).rstrip('/')}{settings.telegram_webhook_path}"
)
logging.info(
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
"STARTUP: Attempting to set Telegram webhook to: %s",
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
)
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
try:
current_webhook_info = await bot.get_webhook_info()
logging.info(
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
set_success = await bot.set_webhook(
url=full_telegram_webhook_url,
drop_pending_updates=True,
allowed_updates=dispatcher.resolve_used_update_types(),
)
if set_success:
logging.info(
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
)
else:
logging.error(
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
)
new_webhook_info = await bot.get_webhook_info()
logging.info(
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
if not new_webhook_info.url:
logging.error(
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
)
except Exception as e_setwebhook:
logging.error(
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
exc_info=True,
)
else:
logging.error(
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
try:
current_webhook_info = await bot.get_webhook_info()
logging.info(
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
set_success = await bot.set_webhook(
url=full_telegram_webhook_url,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
drop_pending_updates=True,
allowed_updates=dispatcher.resolve_used_update_types(),
)
if set_success:
logging.info(
"STARTUP: bot.set_webhook to %s returned SUCCESS (True).",
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
)
else:
logging.error(
"STARTUP: bot.set_webhook to %s returned FAILURE (False).",
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
)
new_webhook_info = await bot.get_webhook_info()
logging.info(
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
if not new_webhook_info.url:
logging.error(
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
)
except Exception:
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.")
else:
logging.error(
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
@@ -115,7 +117,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
try:
menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE,
"menu_my_subscription_inline",
"menu_personal_account_button",
)
await bot.set_chat_menu_button(
menu_button=MenuButtonWebApp(
@@ -127,27 +129,31 @@ async def on_startup_configured(dispatcher: Dispatcher):
logging.info(
"STARTUP: Mini app domain registered and default menu button restored."
)
except Exception as e:
logging.error(
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
)
except Exception:
logging.exception("STARTUP: Failed to register mini app domain.")
if settings.START_COMMAND_DESCRIPTION:
try:
await bot.set_my_commands([
BotCommand(command="start", description=settings.START_COMMAND_DESCRIPTION)
])
logging.info("STARTUP: /start command description set.")
except Exception as e:
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
try:
bot_commands = [
BotCommand(command="tg", description="Интерфейс в боте"),
]
if settings.START_COMMAND_DESCRIPTION:
bot_commands.insert(
0,
BotCommand(command="start",
description=settings.START_COMMAND_DESCRIPTION),
)
await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.")
except Exception:
logging.exception("STARTUP: Failed to set bot commands.")
# Initialize message queue manager
try:
queue_manager = init_queue_manager(bot)
dispatcher["queue_manager"] = queue_manager
logging.info("STARTUP: Message queue manager initialized")
except Exception as e:
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
except Exception:
logging.exception("STARTUP: Failed to initialize message queue manager.")
# Automatic sync on startup
try:
@@ -166,8 +172,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
else:
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
except Exception as e:
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
except Exception:
logging.exception("STARTUP: Failed to run automatic sync.")
logging.info("STARTUP: Bot on_startup_configured completed.")
@@ -243,8 +249,14 @@ async def run_bot(settings_param: Settings):
actual_bot_username = "your_bot_username"
try:
bot_info = await bot.get_me()
actual_bot_username = bot_info.username
logging.info(f"Bot username resolved: @{actual_bot_username}")
if bot_info.username:
actual_bot_username = bot_info.username
dp["bot_username"] = actual_bot_username
logging.info(f"Bot username resolved: @{actual_bot_username}")
else:
logging.warning(
"Bot username is empty; Telegram Login Widget will be unavailable."
)
except Exception as e:
logging.error(
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
+14 -3
View File
@@ -22,13 +22,17 @@ class ProfileSyncMiddleware(BaseMiddleware):
if session and tg_user:
try:
db_user = await user_dal.get_user_by_id(session, tg_user.id)
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, tg_user.id)
if db_user:
update_payload: Dict[str, Any] = {}
sanitized_username = sanitize_username(tg_user.username)
sanitized_first_name = sanitize_display_name(tg_user.first_name)
sanitized_last_name = sanitize_display_name(tg_user.last_name)
if db_user.telegram_id != tg_user.id:
update_payload["telegram_id"] = tg_user.id
if db_user.username != sanitized_username:
update_payload["username"] = sanitized_username
if db_user.first_name != sanitized_first_name:
@@ -37,7 +41,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
update_payload["last_name"] = sanitized_last_name
if update_payload:
await user_dal.update_user(session, tg_user.id, update_payload)
await user_dal.update_user(session, db_user.user_id, update_payload)
logging.info(
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
)
@@ -47,13 +51,20 @@ class ProfileSyncMiddleware(BaseMiddleware):
panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid:
description_text = "\n".join([
db_user.email or "",
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
sanitized_first_name or "",
sanitized_last_name or "",
]).strip()
panel_payload = {
"description": description_text,
"telegramId": tg_user.id,
}
if db_user.email:
panel_payload["email"] = db_user.email
await panel_service.update_user_details_on_panel(
db_user.panel_user_uuid,
{"description": description_text},
panel_payload,
)
except Exception as e_upd_desc:
logging.warning(
+44 -14
View File
@@ -1,5 +1,7 @@
import hashlib
import logging
import json
import hmac
from typing import Optional
from aiogram import Bot
@@ -19,6 +21,8 @@ from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
logger = logging.getLogger(__name__)
class CryptoPayService:
def __init__(
@@ -38,6 +42,7 @@ class CryptoPayService:
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.token = token
if token:
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
self.client = AioCryptoPay(token=token, network=net)
@@ -65,6 +70,7 @@ class CryptoPayService:
amount: float,
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
@@ -116,16 +122,22 @@ class CryptoPayService:
str(invoice.status),
)
await session.commit()
except Exception as e_db_update:
except Exception:
await session.rollback()
logging.error(
f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
exc_info=True,
logging.exception(
"Failed to update cryptopay payment record %s.",
payment_record.payment_id,
)
return None
if url_kind == "web":
return (
getattr(invoice, "web_app_invoice_url", None)
or getattr(invoice, "mini_app_invoice_url", None)
or invoice.bot_invoice_url
)
return invoice.bot_invoice_url
except Exception as e:
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
except Exception:
logging.exception("CryptoPay invoice creation failed.")
return None
async def _invoice_paid_handler(self, update: Update, app: web.Application):
@@ -140,8 +152,8 @@ class CryptoPayService:
payment_db_id = int(meta["payment_db_id"])
sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription")
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception as e:
logging.error(f"Failed to parse CryptoPay payload: {e}")
except Exception:
logging.exception("Failed to parse CryptoPay payload.")
return
async_session_factory: sessionmaker = app["async_session_factory"]
@@ -179,9 +191,9 @@ class CryptoPayService:
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as e:
except Exception:
await session.rollback()
logging.error(f"Failed to process CryptoPay invoice: {e}", exc_info=True)
logging.exception("Failed to process CryptoPay invoice.")
return
db_user = await user_dal.get_user_by_id(session, user_id)
@@ -242,8 +254,8 @@ class CryptoPayService:
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(f"Failed to send CryptoPay success message: {e}")
except Exception:
logging.exception("Failed to send CryptoPay success message.")
# Send notification about payment
try:
@@ -258,12 +270,30 @@ class CryptoPayService:
payment_provider="crypto_pay",
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send crypto_pay payment notification: {e}")
except Exception:
logging.exception("Failed to send crypto_pay payment notification.")
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
if not self.token:
return False
expected_signature = hmac.new(
hashlib.sha256(self.token.encode("utf-8")).digest(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_signature, signature or ""):
logger.error("CryptoPay signature mismatch")
return False
return True
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured or not self.client:
return web.Response(status=503, text="cryptopay_disabled")
raw_body = await request.read()
signature = request.headers.get("crypto-pay-api-signature", "")
if not self._validate_webhook_signature(raw_body, signature):
return web.Response(status=401)
return await self.client.get_updates(request)
+497
View File
@@ -0,0 +1,497 @@
import asyncio
import hashlib
import hmac
import logging
import re
import secrets
import smtplib
import ssl
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from email.message import EmailMessage
from email.utils import formataddr
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import security_dal
from db.models import EmailVerificationCode
logger = logging.getLogger(__name__)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass(frozen=True)
class SmtpAttempt:
port: int
use_ssl: bool
starttls: bool
@dataclass(frozen=True)
class EmailCodeRequestResult:
ok: bool
error: Optional[str] = None
retry_after: Optional[int] = None
@dataclass(frozen=True)
class EmailCodeVerifyResult:
ok: bool
error: Optional[str] = None
retry_after: Optional[int] = None
def normalize_email(value: str) -> str:
return (value or "").strip().lower()
def is_valid_email(value: str) -> bool:
email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}"
class EmailAuthService:
def __init__(self, settings: Settings):
self.settings = settings
def _smtp_attempts(self) -> list[SmtpAttempt]:
attempts: list[SmtpAttempt] = []
primary_port = int(self.settings.SMTP_PORT)
for port in self.settings.smtp_ports_to_try:
if port == primary_port:
use_ssl = bool(self.settings.SMTP_USE_SSL or port == 465)
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
else:
use_ssl = port == 465
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
attempts.append(SmtpAttempt(port=port, use_ssl=use_ssl, starttls=starttls))
return attempts or [
SmtpAttempt(
port=primary_port,
use_ssl=bool(self.settings.SMTP_USE_SSL or primary_port == 465),
starttls=bool(
self.settings.SMTP_STARTTLS
and not self.settings.SMTP_USE_SSL
and primary_port != 465
),
)
]
def _hash_code(self, email: str, purpose: str, code: str) -> str:
secret = hmac.new(
self.settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-email-code",
hashlib.sha256,
).digest()
payload = f"{purpose}:{email}:{code}".encode("utf-8")
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
async def request_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
language_code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeRequestResult:
normalized_email = normalize_email(email)
if not self.settings.email_auth_configured:
return EmailCodeRequestResult(ok=False, error="email_auth_not_configured")
if not is_valid_email(normalized_email):
return EmailCodeRequestResult(ok=False, error="invalid_email")
now = datetime.now(timezone.utc)
throttle = await security_dal.check_throttle(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=_email_throttle_identifier(normalized_email, purpose, target_user_id),
now=now,
)
if throttle.locked:
return EmailCodeRequestResult(
ok=False,
error="rate_limited",
retry_after=throttle.retry_after,
)
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if latest_code and latest_code.created_at:
created_at = latest_code.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
resend_after = max(1, int(self.settings.EMAIL_CODE_RESEND_SECONDS))
elapsed = int((now - created_at).total_seconds())
if elapsed < resend_after and latest_code.consumed_at is None:
return EmailCodeRequestResult(
ok=False,
error="rate_limited",
retry_after=resend_after - elapsed,
)
await session.execute(
update(EmailVerificationCode)
.where(
EmailVerificationCode.email == normalized_email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.values(status="superseded")
)
code = f"{secrets.randbelow(1_000_000):06d}"
code_model = EmailVerificationCode(
email=normalized_email,
code_hash=self._hash_code(normalized_email, purpose, code),
purpose=purpose,
target_user_id=target_user_id,
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
status="active",
)
session.add(code_model)
await session.flush()
await self._send_code_email(
email=normalized_email,
code=code,
language_code=language_code,
)
return EmailCodeRequestResult(ok=True)
async def verify_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
code: str,
target_user_id: Optional[int] = None,
) -> EmailCodeVerifyResult:
normalized_email = normalize_email(email)
normalized_code = re.sub(r"\D", "", code or "")
if not is_valid_email(normalized_email):
return EmailCodeVerifyResult(ok=False, error="invalid_code")
now = datetime.now(timezone.utc)
throttle_identifier = _email_throttle_identifier(
normalized_email,
purpose,
target_user_id,
)
throttle = await security_dal.check_throttle(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
now=now,
)
if throttle.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle.retry_after,
)
latest_code = await self._get_latest_code(
session,
email=normalized_email,
purpose=purpose,
target_user_id=target_user_id,
)
if not latest_code or latest_code.consumed_at is not None:
return EmailCodeVerifyResult(ok=False, error="invalid_code")
expires_at = latest_code.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
return EmailCodeVerifyResult(ok=False, error="expired_code")
max_attempts = max(1, int(self.settings.EMAIL_CODE_MAX_ATTEMPTS))
if int(latest_code.attempts or 0) >= max_attempts:
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
if len(normalized_code) != 6:
latest_code.attempts = int(latest_code.attempts or 0) + 1
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
now=now,
)
await session.flush()
if throttle_result.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle_result.retry_after,
)
if int(latest_code.attempts or 0) >= max_attempts:
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
return EmailCodeVerifyResult(ok=False, error="invalid_code")
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
latest_code.attempts = int(latest_code.attempts or 0) + 1
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
now=now,
)
await session.flush()
if throttle_result.locked:
return EmailCodeVerifyResult(
ok=False,
error="rate_limited",
retry_after=throttle_result.retry_after,
)
return EmailCodeVerifyResult(ok=False, error="invalid_code")
latest_code.consumed_at = now
await security_dal.clear_throttle_state(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
)
await session.flush()
return EmailCodeVerifyResult(ok=True)
async def _get_latest_code(
self,
session: AsyncSession,
*,
email: str,
purpose: str,
target_user_id: Optional[int],
) -> Optional[EmailVerificationCode]:
stmt = (
select(EmailVerificationCode)
.where(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.order_by(EmailVerificationCode.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def _send_code_email(
self,
*,
email: str,
code: str,
language_code: str,
) -> None:
await asyncio.to_thread(
self._send_code_email_sync,
email=email,
code=code,
language_code=language_code,
)
async def send_custom_email(
self,
*,
email: str,
subject: str,
body: str,
) -> None:
await asyncio.to_thread(
self._send_custom_email_sync,
email=email,
subject=subject,
body=body,
)
def _send_code_email_sync(
self,
*,
email: str,
code: str,
language_code: str,
) -> None:
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
if lang == "en":
subject = "Your login code"
body = (
f"Your verification code: {code}\n\n"
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
)
else:
subject = "Код подтверждения"
body = (
f"Ваш код подтверждения: {code}\n\n"
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
)
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(body)
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
attempts = self._smtp_attempts()
last_error: Optional[BaseException] = None
for attempt_number, attempt in enumerate(attempts, start=1):
try:
self._send_message_via_smtp(
message=message,
smtp_host=smtp_host,
smtp_port=attempt.port,
timeout=timeout,
context=context,
use_ssl=attempt.use_ssl,
starttls=attempt.starttls,
)
logger.info(
"Email verification code sent to %s via %s:%s",
email,
smtp_host,
attempt.port,
)
return
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
last_error = exc
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed via %s:%s (ssl=%s, starttls=%s): %s",
attempt_number,
len(attempts),
smtp_host,
attempt.port,
attempt.use_ssl,
attempt.starttls,
exc,
)
if last_error:
raise last_error
def _send_custom_email_sync(
self,
*,
email: str,
subject: str,
body: str,
) -> None:
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
self.settings.SMTP_FROM_EMAIL or "",
)
)
message["To"] = email
message.set_content(body)
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
attempts = self._smtp_attempts()
last_error: Optional[BaseException] = None
for attempt_number, attempt in enumerate(attempts, start=1):
try:
self._send_message_via_smtp(
message=message,
smtp_host=smtp_host,
smtp_port=attempt.port,
timeout=timeout,
context=context,
use_ssl=attempt.use_ssl,
starttls=attempt.starttls,
)
logger.info(
"Custom email sent to %s via %s:%s",
email,
smtp_host,
attempt.port,
)
return
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
last_error = exc
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log(
log_level,
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s",
attempt_number,
len(attempts),
smtp_host,
attempt.port,
attempt.use_ssl,
attempt.starttls,
exc,
)
if last_error:
raise last_error
def _send_message_via_smtp(
self,
*,
message: EmailMessage,
smtp_host: str,
smtp_port: int,
timeout: int,
context: ssl.SSLContext,
use_ssl: bool,
starttls: bool,
) -> None:
if use_ssl:
with smtplib.SMTP_SSL(
smtp_host,
smtp_port,
context=context,
timeout=timeout,
) as smtp:
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
return
with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as smtp:
smtp.ehlo()
if starttls:
smtp.starttls(context=context)
smtp.ehlo()
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
smtp.send_message(message)
+38 -48
View File
@@ -7,6 +7,7 @@ import logging
import time
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple
from urllib.parse import parse_qsl
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
@@ -21,6 +22,7 @@ from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
class FreeKassaService:
@@ -135,7 +137,7 @@ class FreeKassaService:
return True, response_data
except Exception as exc:
logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
logging.exception("FreeKassa create_order: request failed.")
return False, {"message": str(exc)}
async def _get_session(self) -> ClientSession:
@@ -169,69 +171,59 @@ class FreeKassaService:
def _validate_signature(
self,
merchant_order_id: str,
amount: str,
raw_body: bytes,
provided_signature: str,
payload: Optional[Dict[str, Any]] = None,
) -> bool:
if not provided_signature:
return False
if not self.second_secret:
return False
if self.shop_id and self.second_secret:
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
if expected_signature.lower() == provided_signature.lower():
return True
if self.api_key and payload:
items = [
(key, value)
for key, value in payload.items()
if key not in {"signature", "SIGN"} and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
if alt_signature.lower() == provided_signature.lower():
return True
return False
expected_signature = hmac.new(
self.second_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_signature, provided_signature)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="freekassa_disabled")
try:
data = await request.post()
except Exception as e:
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
return web.Response(status=403)
raw_body = await request.read()
except Exception:
logging.exception("FreeKassa webhook: failed to read request body.")
return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any]
if data:
payload_dict = {str(k): v for k, v in data.items()}
else:
payload_dict: Dict[str, Any] = {}
if raw_body:
try:
json_payload = await request.json()
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
data = json_payload
if request.content_type.startswith("application/json"):
decoded_json = json.loads(raw_body.decode("utf-8"))
if isinstance(decoded_json, dict):
payload_dict = {str(k): v for k, v in decoded_json.items()}
else:
payload_dict = {
str(key): value
for key, value in parse_qsl(raw_body.decode("utf-8"), keep_blank_values=True)
}
except Exception:
payload_dict = {}
data = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
if isinstance(data, dict):
return data.get(key) or data.get(key.lower()) or default
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id:
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
return web.Response(status=403, text="merchant_mismatch")
return web.Response(status=403)
signature = _get("SIGN") or _get("signature")
if not signature:
logging.error("FreeKassa webhook: missing signature")
return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
@@ -239,11 +231,9 @@ class FreeKassaService:
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str:
logging.error("FreeKassa webhook: missing order_id or amount")
return web.Response(status=400, text="missing_data")
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
logging.error("FreeKassa webhook: invalid signature")
if not self._validate_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
@@ -309,9 +299,9 @@ class FreeKassaService:
)
await session.commit()
except Exception as e:
except Exception:
await session.rollback()
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
logging.exception("FreeKassa webhook: failed to process payment %s.", payment_db_id)
return web.Response(status=500, text="processing_error")
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
@@ -395,8 +385,8 @@ class FreeKassaService:
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
except Exception:
logging.exception("FreeKassa notification: failed to send message to user %s.", payment.user_id)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -409,8 +399,8 @@ class FreeKassaService:
payment_provider="freekassa",
username=db_user.username if db_user else None,
)
except Exception as e:
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
except Exception:
logging.exception("FreeKassa notification: failed to notify admins.")
return web.Response(text="YES")
+8 -8
View File
@@ -120,8 +120,8 @@ class NotificationService:
logging.error(
f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}"
)
except Exception as e:
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
except Exception:
logging.exception("Failed to send notification to log channel %s.", self.settings.LOG_CHAT_ID)
return
try:
@@ -143,8 +143,8 @@ class NotificationService:
# Queue message for sending (groups are rate limited to 15/minute)
await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs)
except Exception as e:
logging.error(f"Failed to queue notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
except Exception:
logging.exception("Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID)
async def _send_to_admins(self, message: str):
"""Send message to all admin users using message queue"""
@@ -162,8 +162,8 @@ class NotificationService:
parse_mode="HTML",
disable_web_page_preview=True
)
except Exception as e:
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
except Exception:
logging.exception("Failed to send notification to admin %s.", admin_id)
return
for admin_id in self.settings.ADMIN_IDS:
@@ -174,8 +174,8 @@ class NotificationService:
parse_mode="HTML",
disable_web_page_preview=True
)
except Exception as e:
logging.error(f"Failed to queue notification to admin {admin_id}: {e}")
except Exception:
logging.exception("Failed to queue notification to admin %s.", admin_id)
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
first_name: Optional[str] = None,
+1 -1
View File
@@ -174,7 +174,7 @@ class PanelApiService:
"message": f"Connection error: {str(e)}"
}
except aiohttp.ClientError as e:
logging.error(f"Panel API ClientError to {url_for_request}: {e}")
logging.exception("Panel API ClientError to %s.", url_for_request)
return {
"error": True,
"status_code": -2,
+25 -15
View File
@@ -26,6 +26,10 @@ class PanelWebhookService:
self.i18n = i18n
self.async_session_factory = async_session_factory
self.panel_service = panel_service
if not self.settings.PANEL_WEBHOOK_SECRET:
logging.error(
"PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected."
)
async def _send_message(
self,
@@ -40,8 +44,8 @@ class PanelWebhookService:
await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup
)
except Exception as e:
logging.error(f"Failed to send notification to {user_id}: {e}")
except Exception:
logging.exception("Failed to send notification to %s", user_id)
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
@@ -54,7 +58,10 @@ class PanelWebhookService:
return
async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
internal_user_id = db_user.user_id if db_user else user_id
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
@@ -69,7 +76,7 @@ class PanelWebhookService:
if subscription_service:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
if sub and sub.auto_renew_enabled and sub.provider == 'yookassa':
try:
ok = await subscription_service.charge_subscription_renewal(session, sub)
@@ -89,7 +96,7 @@ class PanelWebhookService:
if days_left == 2:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
sub = await subscription_dal.get_active_subscription_by_user_id(session, internal_user_id)
logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id,
@@ -136,16 +143,19 @@ class PanelWebhookService:
)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if self.settings.PANEL_WEBHOOK_SECRET:
if not signature_header:
return web.Response(status=403, text="no_signature")
expected_sig = hmac.new(
self.settings.PANEL_WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=403, text="invalid_signature")
if not self.settings.PANEL_WEBHOOK_SECRET:
return web.Response(status=401, text="unauthorized")
if not signature_header:
return web.Response(status=401, text="unauthorized")
expected_sig = hmac.new(
self.settings.PANEL_WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=401, text="unauthorized")
try:
payload = json.loads(raw_body.decode())
+29 -13
View File
@@ -1,3 +1,4 @@
import hmac
import json
import logging
from decimal import Decimal, ROUND_HALF_UP
@@ -41,6 +42,8 @@ class PlategaService:
self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
self.sbp_method = settings.platega_sbp_method_resolved
self.crypto_method = settings.PLATEGA_CRYPTO_METHOD
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
@@ -56,6 +59,14 @@ class PlategaService:
)
if not self.configured:
logging.warning("PlategaService initialized but not fully configured. Payments disabled.")
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)",
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
self.sbp_method,
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
self.crypto_method,
)
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
@@ -76,6 +87,7 @@ class PlategaService:
currency: Optional[str],
description: str,
payload: Optional[str] = None,
payment_method: Optional[int] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
@@ -84,9 +96,10 @@ class PlategaService:
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
"paymentMethod": int(self.payment_method),
"paymentMethod": method_id,
"paymentDetails": {"amount": float(amount), "currency": currency_code},
"description": description,
"return": self.return_url,
@@ -126,7 +139,7 @@ class PlategaService:
return True, response_data
except Exception as exc:
logging.error("Platega create_transaction: request failed: %s", exc, exc_info=True)
logging.exception("Platega create_transaction: request failed.")
return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response:
@@ -135,13 +148,16 @@ class PlategaService:
try:
data = await request.json()
except Exception as exc:
logging.error("Platega webhook: failed to parse JSON: %s", exc)
except Exception:
logging.exception("Platega webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret")
if header_merchant != self.merchant_id or header_secret != self.secret:
if not (
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
):
logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden")
@@ -211,9 +227,9 @@ class PlategaService:
)
await session.commit()
except Exception as exc:
except Exception:
await session.rollback()
logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, exc_info=True)
logging.exception("Platega webhook: failed to process payment %s.", transaction_id)
return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id)
@@ -292,8 +308,8 @@ class PlategaService:
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc)
except Exception:
logging.exception("Platega webhook: failed to notify user %s.", payment.user_id)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -306,8 +322,8 @@ class PlategaService:
payment_provider="platega",
username=db_user.username if db_user else None,
)
except Exception as exc:
logging.error("Platega webhook: failed to notify admins: %s", exc)
except Exception:
logging.exception("Platega webhook: failed to notify admins.")
return web.Response(text="ok")
@@ -320,9 +336,9 @@ class PlategaService:
"canceled",
)
await session.commit()
except Exception as exc:
except Exception:
await session.rollback()
logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc)
logging.exception("Platega webhook: failed to cancel payment %s.", transaction_id)
return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id)
+39 -3
View File
@@ -1,10 +1,12 @@
import logging
from html import escape as html_escape
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from aiogram import Bot
from config.settings import Settings
from db.dal import security_dal
from db.dal import promo_code_dal, user_dal
from db.models import PromoCode, User
@@ -24,6 +26,9 @@ class PromoCodeService:
self.bot = bot
self.i18n = i18n
def _throttle_identifier(self, user_id: int) -> str:
return f"user:{int(user_id)}"
async def apply_promo_code(
self,
session: AsyncSession,
@@ -32,19 +37,45 @@ class PromoCodeService:
user_lang: str,
) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
code_input_upper = (code_input or "").strip().upper()[:100]
code_display = html_escape(code_input_upper[:100], quote=False)
throttle_identifier = self._throttle_identifier(user_id)
throttle = await security_dal.check_throttle(
session,
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
identifier=throttle_identifier,
)
if throttle.locked:
return False, _(
"promo_code_too_many_attempts",
seconds=throttle.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
)
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
session, code_input_upper)
if not promo_data:
return False, _("promo_code_not_found", code=code_input_upper)
throttle_result = await security_dal.record_throttle_failure(
session,
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
identifier=throttle_identifier,
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
)
if throttle_result.locked:
return False, _(
"promo_code_too_many_attempts",
seconds=throttle_result.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
)
return False, _("promo_code_not_found", code=code_display)
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id)
if existing_activation:
return False, _("promo_code_already_used_by_user",
code=code_input_upper)
code=code_display)
bonus_days = promo_data.bonus_days
@@ -61,6 +92,11 @@ class PromoCodeService:
session, promo_data.promo_code_id)
if activation_recorded and promo_incremented:
await security_dal.clear_throttle_state(
session,
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
identifier=throttle_identifier,
)
# Send notification about promo activation
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
+13 -13
View File
@@ -142,7 +142,7 @@ class SeverPayService:
return True, response_data.get("data") or response_data
except Exception as exc:
logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True)
logging.exception("SeverPay create_payment: request failed.")
return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response:
@@ -151,8 +151,8 @@ class SeverPayService:
try:
payload = await request.json()
except Exception as exc:
logging.error("SeverPay webhook: failed to parse JSON: %s", exc)
except Exception:
logging.exception("SeverPay webhook: failed to parse JSON.")
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict) or not self._validate_signature(payload):
@@ -223,9 +223,9 @@ class SeverPayService:
)
await session.commit()
except Exception as exc:
except Exception:
await session.rollback()
logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True)
logging.exception("SeverPay webhook: failed to process payment %s.", provider_payment_id)
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
@@ -304,8 +304,8 @@ class SeverPayService:
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc)
except Exception:
logging.exception("SeverPay webhook: failed to notify user %s.", payment.user_id)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
@@ -318,8 +318,8 @@ class SeverPayService:
payment_provider="severpay",
username=db_user.username if db_user else None,
)
except Exception as exc:
logging.error("SeverPay webhook: failed to notify admins: %s", exc)
except Exception:
logging.exception("SeverPay webhook: failed to notify admins.")
return web.json_response({"status": True})
@@ -332,9 +332,9 @@ class SeverPayService:
"failed",
)
await session.commit()
except Exception as exc:
except Exception:
await session.rollback()
logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc)
logging.exception("SeverPay webhook: failed to mark payment %s as failed.", provider_payment_id)
return web.json_response({"status": False, "msg": "processing_error"}, status=500)
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
@@ -355,9 +355,9 @@ class SeverPayService:
"pending_severpay",
)
await session.commit()
except Exception as exc:
except Exception:
await session.rollback()
logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc)
logging.exception("SeverPay webhook: failed to update pending status for %s.", provider_payment_id)
return web.json_response({"status": True})
logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id)
+1 -1
View File
@@ -55,7 +55,7 @@ class StarsService:
title=description,
description=description,
payload=payload,
provider_token="",
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
currency="XTR",
prices=prices,
)
+75 -55
View File
@@ -96,6 +96,42 @@ class SubscriptionService:
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
)
def _telegram_id_for_panel(self, db_user: User) -> Optional[int]:
if db_user.telegram_id:
return int(db_user.telegram_id)
if db_user.user_id and int(db_user.user_id) > 0:
return int(db_user.user_id)
return None
async def _panel_username_for_user(
self, session: AsyncSession, db_user: User
) -> str:
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id and int(db_user.user_id) == telegram_id:
return f"tg_{telegram_id}"
referral_code = await user_dal.ensure_referral_code(session, db_user)
return f"em_{referral_code}"
def _panel_description_for_user(self, db_user: User) -> str:
lines = [
db_user.email or "",
db_user.username or "",
db_user.first_name or "",
db_user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"description": self._panel_description_for_user(db_user),
}
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id:
payload["telegramId"] = telegram_id
if db_user.email:
payload["email"] = db_user.email
return payload
async def _get_or_create_panel_user_link_details(
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
@@ -109,25 +145,45 @@ class SubscriptionService:
return None, None, None, False
current_local_panel_uuid = db_user.panel_user_uuid
panel_username_on_panel_standard = f"tg_{user_id}"
panel_username_on_panel_standard = await self._panel_username_for_user(
session, db_user
)
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
panel_user_obj_from_api = None
panel_user_created_or_linked_now = False
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
telegram_id=user_id
)
panel_users_by_tg_id_list = None
if telegram_id_for_panel:
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
telegram_id=telegram_id_for_panel
)
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
logging.info(
f"Found panel user by telegramId {user_id}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
)
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for telegramId {user_id}. Manual intervention needed."
f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed."
)
return None, None, None, False
if not panel_user_obj_from_api and db_user.email:
panel_users_by_email_list = await self.panel_service.get_users_by_filter(
email=db_user.email
)
if panel_users_by_email_list and len(panel_users_by_email_list) == 1:
panel_user_obj_from_api = panel_users_by_email_list[0]
logging.info(
f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
)
elif panel_users_by_email_list and len(panel_users_by_email_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed."
)
return None, None, None, False
if not panel_user_obj_from_api:
if current_local_panel_uuid:
@@ -146,12 +202,9 @@ class SubscriptionService:
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id,
description="\n".join([
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]),
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
@@ -175,12 +228,9 @@ class SubscriptionService:
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id,
description="\n".join([
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]),
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
@@ -226,7 +276,6 @@ class SubscriptionService:
)
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
if not actual_panel_uuid_from_api:
@@ -293,24 +342,15 @@ class SubscriptionService:
if (
panel_user_obj_from_api
and current_local_panel_uuid
and panel_telegram_id_int != user_id
and telegram_id_for_panel
and panel_telegram_id_int != telegram_id_for_panel
):
logging.info(
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'."
)
# Also set readable description with Telegram fields
await self.panel_service.update_user_details_on_panel(
current_local_panel_uuid,
{
"telegramId": user_id,
"description": "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
),
},
self._panel_identity_payload_for_user(db_user),
)
panel_sub_link_id = panel_user_obj_from_api.get(
@@ -408,14 +448,7 @@ class SubscriptionService:
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
)
# Add user description based on Telegram profile
panel_update_payload["description"] = "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
@@ -525,13 +558,7 @@ class SubscriptionService:
traffic_limit_strategy="NO_RESET",
)
panel_update_payload["description"] = "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
@@ -695,14 +722,7 @@ class SubscriptionService:
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
)
# Add user description based on Telegram profile
panel_update_payload["description"] = "\n".join(
[
(db_user.username or "") if db_user else "",
(db_user.first_name or "") if db_user else "",
(db_user.last_name or "") if db_user else "",
]
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
+19 -21
View File
@@ -35,9 +35,8 @@ class YooKassaService:
self.configured = True
logging.info(
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
except Exception as e:
logging.error(f"Failed to configure YooKassa SDK: {e}",
exc_info=True)
except Exception:
logging.exception("Failed to configure YooKassa SDK.")
self.configured = False
if configured_return_url:
@@ -161,10 +160,11 @@ class YooKassaService:
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
)
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request,
idempotence_key))
response = await asyncio.to_thread(
YooKassaPayment.create,
payment_request,
idempotence_key,
)
logging.info(
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
@@ -200,9 +200,8 @@ class YooKassaService:
response.test if hasattr(response, 'test') else None,
"payment_method": getattr(response, 'payment_method', None),
}
except Exception as e:
logging.error(f"YooKassa payment creation failed: {e}",
exc_info=True)
except Exception:
logging.exception("YooKassa payment creation failed.")
return None
async def get_payment_info(
@@ -216,9 +215,10 @@ class YooKassaService:
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
)
loop = asyncio.get_running_loop()
payment_info_yk = await loop.run_in_executor(
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
payment_info_yk = await asyncio.to_thread(
YooKassaPayment.find_one,
payment_id_in_yookassa,
)
if payment_info_yk:
logging.info(
@@ -264,10 +264,9 @@ class YooKassaService:
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
)
return None
except Exception as e:
logging.error(
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
exc_info=True)
except Exception:
logging.exception(
"YooKassa get payment info for %s failed.", payment_id_in_yookassa)
return None
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
@@ -275,10 +274,9 @@ class YooKassaService:
logging.error("YooKassa is not configured. Cannot cancel payment.")
return False
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
return True
except Exception as e:
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
except Exception:
logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa)
return False
+52
View File
@@ -0,0 +1,52 @@
import logging
from typing import Any
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
from aiogram.types import CallbackQuery
_EXPIRED_CALLBACK_MARKERS = (
"query is too old",
"response timeout expired",
"query id is invalid",
)
def is_expired_callback_answer_error(error: BaseException) -> bool:
if not isinstance(error, TelegramBadRequest):
return False
message = str(error).lower()
return any(marker in message for marker in _EXPIRED_CALLBACK_MARKERS)
async def safe_answer_callback(
callback: CallbackQuery,
*args: Any,
**kwargs: Any,
) -> bool:
try:
await callback.answer(*args, **kwargs)
return True
except TelegramBadRequest as error:
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
if is_expired_callback_answer_error(error):
logging.info(
"Ignored expired callback answer for user %s: %s",
user_id,
error,
)
return False
logging.warning(
"Failed to answer callback query for user %s: %s",
user_id,
error,
)
return False
except TelegramAPIError as error:
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
logging.warning(
"Telegram API error while answering callback query for user %s: %s",
user_id,
error,
)
return False
+2 -2
View File
@@ -82,9 +82,9 @@ class MessageQueue:
self.total_failed += 1
logging.error(f"Failed to send queued message to {message.chat_id}: {exc}")
except Exception as e:
except Exception:
self.total_failed += 1
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
logging.exception("Failed to send queued message to %s.", message.chat_id)
finally:
self.is_processing = False
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import ipaddress
from typing import Optional, Sequence
from aiohttp import web
def parse_ip_entries(raw_values: Optional[Sequence[str] | str]) -> list[ipaddress._BaseNetwork]:
if raw_values is None:
return []
if isinstance(raw_values, str):
values = [item.strip() for item in raw_values.split(",")]
else:
values = [str(item).strip() for item in raw_values]
parsed: list[ipaddress._BaseNetwork] = []
for value in values:
if not value:
continue
try:
parsed.append(ipaddress.ip_network(value, strict=False))
except ValueError:
continue
return parsed
def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
if not value:
return None
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def _last_forwarded_ip(header_value: str) -> Optional[str]:
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
if not candidates:
return None
candidate = candidates[-1]
return candidate if _parse_ip(candidate) is not None else None
def request_client_ip(
request: web.Request,
*,
trusted_proxies: Optional[Sequence[str] | str] = None,
) -> Optional[str]:
remote_ip = _parse_ip(request.remote or "")
forwarded_for = request.headers.get("X-Forwarded-For", "")
if remote_ip and forwarded_for:
trusted_networks = parse_ip_entries(trusted_proxies)
if any(remote_ip in network for network in trusted_networks):
forwarded_ip = _last_forwarded_ip(forwarded_for)
if forwarded_ip:
return forwarded_ip
if remote_ip:
return str(remote_ip)
forwarded_ip = _last_forwarded_ip(forwarded_for)
return forwarded_ip
def ip_in_allowlist(ip_value: Optional[str], allowed_entries: Optional[Sequence[str] | str]) -> bool:
parsed_ip = _parse_ip(ip_value)
if parsed_ip is None:
return False
allowed_networks = parse_ip_entries(allowed_entries)
return any(parsed_ip in network for network in allowed_networks)
+361 -9
View File
@@ -1,8 +1,101 @@
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, ValidationError, computed_field, field_validator
import os
import secrets
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def _split_csv(value: Optional[str]) -> List[str]:
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
class DBSettings(BaseModel):
user: str
password: str
host: str
port: int
database: str
class PaymentSettings(BaseModel):
yookassa_enabled: bool
yookassa_shop_id: Optional[str]
yookassa_secret_key: Optional[str]
yookassa_return_url: Optional[str]
yookassa_default_receipt_email: Optional[str]
yookassa_vat_code: int
yookassa_payment_mode: str
yookassa_payment_subject: str
yookassa_autopayments_enabled: bool
yookassa_autopayments_require_card_binding: bool
freekassa_enabled: bool
freekassa_merchant_id: Optional[str]
freekassa_second_secret: Optional[str]
freekassa_api_key: Optional[str]
freekassa_payment_ip: Optional[str]
freekassa_payment_method_id: Optional[int]
freekassa_trusted_ips: List[str]
platega_enabled: bool
platega_base_url: str
platega_merchant_id: Optional[str]
platega_secret: Optional[str]
platega_payment_method: int
platega_sbp_enabled: bool
platega_crypto_enabled: bool
platega_sbp_method: int
platega_crypto_method: int
platega_return_url: Optional[str]
platega_failed_url: Optional[str]
severpay_enabled: bool
severpay_mid: Optional[int]
severpay_token: Optional[str]
severpay_return_url: Optional[str]
severpay_base_url: str
severpay_lifetime_minutes: Optional[int]
cryptopay_enabled: bool
cryptopay_token: Optional[str]
cryptopay_network: str
cryptopay_currency_type: str
cryptopay_asset: str
class EmailSettings(BaseModel):
smtp_host: str
smtp_port: int
smtp_fallback_ports: Optional[str]
smtp_timeout_seconds: int
smtp_username: Optional[str]
smtp_password: Optional[str]
smtp_from_email: Optional[str]
smtp_from_name: Optional[str]
smtp_starttls: bool
smtp_use_ssl: bool
email_code_ttl_seconds: int
email_code_resend_seconds: int
email_code_max_attempts: int
brute_force_max_failures: int
brute_force_window_seconds: int
brute_force_lock_seconds: int
class WebAppSettings(BaseModel):
title: str
primary_color: str
logo_url: Optional[str]
session_ttl_seconds: int
session_secret: str
webhook_secret_token: str
auth_max_age_seconds: int
login_token_ttl_seconds: int
server_host: str
server_port: int
enabled: bool
trusted_proxies: List[str]
class Settings(BaseSettings):
BOT_TOKEN: str
@@ -11,8 +104,8 @@ class Settings(BaseSettings):
alias="ADMIN_IDS",
description="Comma-separated list of admin Telegram User IDs")
POSTGRES_USER: str = Field(default="user")
POSTGRES_PASSWORD: str = Field(default="password")
POSTGRES_USER: str = Field(...)
POSTGRES_PASSWORD: str = Field(...)
POSTGRES_HOST: str = Field(default="localhost")
POSTGRES_PORT: int = Field(default=5432)
POSTGRES_DB: str = Field(default="vpn_shop_db")
@@ -75,6 +168,10 @@ class Settings(BaseSettings):
)
WEBHOOK_BASE_URL: Optional[str] = None
TRUSTED_PROXIES: Optional[str] = Field(
default="127.0.0.1,::1",
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.",
)
CRYPTOPAY_TOKEN: Optional[str] = None
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
@@ -86,7 +183,24 @@ class Settings(BaseSettings):
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_PAYMENT_METHOD: int = Field(
default=2, description="Platega payment method ID (e.g., 2 for SBP QR)"
default=2,
description="Legacy Platega payment method ID. Used as fallback for PLATEGA_SBP_METHOD when the new field is unset.",
)
PLATEGA_SBP_ENABLED: bool = Field(
default=False,
description="Show a separate Platega SBP payment button.",
)
PLATEGA_CRYPTO_ENABLED: bool = Field(
default=False,
description="Show a separate Platega crypto payment button.",
)
PLATEGA_SBP_METHOD: int = Field(
default=2,
description="Platega method ID for SBP QR (default 2).",
)
PLATEGA_CRYPTO_METHOD: int = Field(
default=13,
description="Platega method ID for crypto (default 13).",
)
PLATEGA_RETURN_URL: Optional[str] = Field(default=None)
PLATEGA_FAILED_URL: Optional[str] = Field(default=None)
@@ -99,6 +213,10 @@ class Settings(BaseSettings):
FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_PAYMENT_IP: Optional[str] = None
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
FREEKASSA_TRUSTED_IPS: str = Field(
default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238",
description="Comma-separated FreeKassa webhook IP allowlist.",
)
SEVERPAY_ENABLED: bool = Field(default=False)
SEVERPAY_MID: Optional[int] = None
@@ -201,6 +319,48 @@ 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="#00fe7a")
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
SMTP_PORT: int = Field(default=587)
SMTP_FALLBACK_PORTS: Optional[str] = Field(default="2525,465")
SMTP_TIMEOUT_SECONDS: int = Field(default=30)
SMTP_USERNAME: Optional[str] = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None)
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
SMTP_FROM_NAME: Optional[str] = Field(default=None)
SMTP_STARTTLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False)
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
BRUTE_FORCE_MAX_FAILURES: int = Field(
default=5,
description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.",
)
BRUTE_FORCE_WINDOW_SECONDS: int = Field(
default=15 * 60,
description="Rolling window used to count failed email and promo code attempts.",
)
BRUTE_FORCE_LOCK_SECONDS: int = Field(
default=30 * 60,
description="Temporary lockout duration applied after too many failed code attempts.",
)
LOGS_PAGE_SIZE: int = Field(default=10)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
@@ -228,6 +388,102 @@ class Settings(BaseSettings):
def DATABASE_URL(self) -> str:
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
@computed_field
@property
def db_settings(self) -> DBSettings:
return DBSettings(
user=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=self.POSTGRES_HOST,
port=self.POSTGRES_PORT,
database=self.POSTGRES_DB,
)
@computed_field
@property
def payment_settings(self) -> PaymentSettings:
return PaymentSettings(
yookassa_enabled=self.YOOKASSA_ENABLED,
yookassa_shop_id=self.YOOKASSA_SHOP_ID,
yookassa_secret_key=self.YOOKASSA_SECRET_KEY,
yookassa_return_url=self.YOOKASSA_RETURN_URL,
yookassa_default_receipt_email=self.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
yookassa_vat_code=self.YOOKASSA_VAT_CODE,
yookassa_payment_mode=self.YOOKASSA_PAYMENT_MODE,
yookassa_payment_subject=self.YOOKASSA_PAYMENT_SUBJECT,
yookassa_autopayments_enabled=self.YOOKASSA_AUTOPAYMENTS_ENABLED,
yookassa_autopayments_require_card_binding=self.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING,
freekassa_enabled=self.FREEKASSA_ENABLED,
freekassa_merchant_id=self.FREEKASSA_MERCHANT_ID,
freekassa_second_secret=self.FREEKASSA_SECOND_SECRET,
freekassa_api_key=self.FREEKASSA_API_KEY,
freekassa_payment_ip=self.FREEKASSA_PAYMENT_IP,
freekassa_payment_method_id=self.FREEKASSA_PAYMENT_METHOD_ID,
freekassa_trusted_ips=self.freekassa_trusted_ips,
platega_enabled=self.PLATEGA_ENABLED,
platega_base_url=self.PLATEGA_BASE_URL,
platega_merchant_id=self.PLATEGA_MERCHANT_ID,
platega_secret=self.PLATEGA_SECRET,
platega_payment_method=self.PLATEGA_PAYMENT_METHOD,
platega_sbp_enabled=self.PLATEGA_SBP_ENABLED,
platega_crypto_enabled=self.PLATEGA_CRYPTO_ENABLED,
platega_sbp_method=self.platega_sbp_method_resolved,
platega_crypto_method=self.PLATEGA_CRYPTO_METHOD,
platega_return_url=self.PLATEGA_RETURN_URL,
platega_failed_url=self.PLATEGA_FAILED_URL,
severpay_enabled=self.SEVERPAY_ENABLED,
severpay_mid=self.SEVERPAY_MID,
severpay_token=self.SEVERPAY_TOKEN,
severpay_return_url=self.SEVERPAY_RETURN_URL,
severpay_base_url=self.SEVERPAY_BASE_URL,
severpay_lifetime_minutes=self.SEVERPAY_LIFETIME_MINUTES,
cryptopay_enabled=self.CRYPTOPAY_ENABLED,
cryptopay_token=self.CRYPTOPAY_TOKEN,
cryptopay_network=self.CRYPTOPAY_NETWORK,
cryptopay_currency_type=self.CRYPTOPAY_CURRENCY_TYPE,
cryptopay_asset=self.CRYPTOPAY_ASSET,
)
@computed_field
@property
def email_settings(self) -> EmailSettings:
return EmailSettings(
smtp_host=self.SMTP_HOST,
smtp_port=self.SMTP_PORT,
smtp_fallback_ports=self.SMTP_FALLBACK_PORTS,
smtp_timeout_seconds=self.SMTP_TIMEOUT_SECONDS,
smtp_username=self.SMTP_USERNAME,
smtp_password=self.SMTP_PASSWORD,
smtp_from_email=self.SMTP_FROM_EMAIL,
smtp_from_name=self.SMTP_FROM_NAME,
smtp_starttls=self.SMTP_STARTTLS,
smtp_use_ssl=self.SMTP_USE_SSL,
email_code_ttl_seconds=self.EMAIL_CODE_TTL_SECONDS,
email_code_resend_seconds=self.EMAIL_CODE_RESEND_SECONDS,
email_code_max_attempts=self.EMAIL_CODE_MAX_ATTEMPTS,
brute_force_max_failures=self.BRUTE_FORCE_MAX_FAILURES,
brute_force_window_seconds=self.BRUTE_FORCE_WINDOW_SECONDS,
brute_force_lock_seconds=self.BRUTE_FORCE_LOCK_SECONDS,
)
@computed_field
@property
def webapp_settings(self) -> WebAppSettings:
return WebAppSettings(
title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR,
logo_url=self.WEBAPP_LOGO_URL,
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
session_secret=self.WEBAPP_SESSION_SECRET,
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
auth_max_age_seconds=self.WEBAPP_AUTH_MAX_AGE_SECONDS,
login_token_ttl_seconds=self.WEBAPP_LOGIN_TOKEN_TTL_SECONDS,
server_host=self.WEBAPP_SERVER_HOST,
server_port=self.WEBAPP_SERVER_PORT,
enabled=self.WEBAPP_ENABLED,
trusted_proxies=self.trusted_proxies,
)
@computed_field
@property
def ADMIN_IDS(self) -> List[int]:
@@ -285,6 +541,21 @@ class Settings(BaseSettings):
return cleaned
return None
@computed_field
@property
def trusted_proxies(self) -> List[str]:
return _split_csv(self.TRUSTED_PROXIES)
@computed_field
@property
def freekassa_trusted_ips(self) -> List[str]:
return _split_csv(self.FREEKASSA_TRUSTED_IPS)
@computed_field
@property
def telegram_webhook_path(self) -> str:
return "/tg/webhook"
@computed_field
@property
def yookassa_webhook_path(self) -> str:
@@ -504,7 +775,8 @@ class Settings(BaseSettings):
"""
default_order = [
"freekassa",
"platega",
"platega_sbp",
"platega_crypto",
"severpay",
"yookassa",
"stars",
@@ -512,12 +784,57 @@ class Settings(BaseSettings):
]
if not self.PAYMENT_METHODS_ORDER:
return default_order
methods = []
methods: List[str] = []
for item in self.PAYMENT_METHODS_ORDER.split(","):
slug = item.strip().lower()
if slug:
methods.append(slug)
if not slug:
continue
if slug == "platega":
# Legacy slug — expand to the new sub-methods preserving order
if "platega_sbp" not in methods:
methods.append("platega_sbp")
if "platega_crypto" not in methods:
methods.append("platega_crypto")
continue
methods.append(slug)
return methods or default_order
@computed_field
@property
def platega_sbp_method_resolved(self) -> int:
"""SBP method ID, falling back to legacy PLATEGA_PAYMENT_METHOD when SBP-specific value is the default."""
if self.PLATEGA_SBP_METHOD != 2:
return self.PLATEGA_SBP_METHOD
return self.PLATEGA_PAYMENT_METHOD or 2
@computed_field
@property
def email_auth_configured(self) -> bool:
return bool(
self.SMTP_HOST
and self.SMTP_PORT
and self.SMTP_USERNAME
and self.SMTP_PASSWORD
and self.SMTP_FROM_EMAIL
)
@computed_field
@property
def smtp_ports_to_try(self) -> List[int]:
ports: List[int] = []
def add_port(value: Any) -> None:
try:
port = int(str(value).strip())
except (TypeError, ValueError):
return
if 0 < port <= 65535 and port not in ports:
ports.append(port)
add_port(self.SMTP_PORT)
for item in (self.SMTP_FALLBACK_PORTS or "").split(","):
add_port(item)
return ports
# Logging Configuration
LOG_LEVEL: str = Field(
@@ -536,6 +853,26 @@ class Settings(BaseSettings):
return "INFO"
return v
@field_validator('POSTGRES_USER', 'POSTGRES_PASSWORD', mode='before')
@classmethod
def validate_required_db_credentials(cls, v):
if isinstance(v, str):
v = v.strip()
if not v:
raise ValueError("must not be empty")
return v
@field_validator('WEBAPP_SESSION_SECRET', 'WEBHOOK_SECRET_TOKEN', mode='before')
@classmethod
def normalize_webapp_secrets(cls, v):
if isinstance(v, str):
v = v.strip()
if v:
return v
if v:
return v
return secrets.token_urlsafe(32)
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
@classmethod
def validate_optional_int_fields(cls, v):
@@ -552,6 +889,13 @@ class Settings(BaseSettings):
'CRYPT4_REDIRECT_URL',
'PRIVACY_POLICY_URL',
'USER_AGREEMENT_URL',
'SUBSCRIPTION_MINI_APP_URL',
'WEBAPP_LOGO_URL',
'SMTP_USERNAME',
'SMTP_PASSWORD',
'SMTP_FROM_EMAIL',
'SMTP_FROM_NAME',
'SMTP_FALLBACK_PORTS',
mode='before',
)
@classmethod
@@ -599,6 +943,14 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
)
if not os.getenv("WEBAPP_SESSION_SECRET"):
logging.warning(
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only."
)
if not os.getenv("WEBHOOK_SECRET_TOKEN"):
logging.warning(
"WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only."
)
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
+2
View File
@@ -6,6 +6,7 @@ from . import panel_sync_dal
from . import message_log_dal
from . import user_billing_dal
from . import ad_dal
from . import security_dal
__all__ = (
"user_dal",
@@ -16,6 +17,7 @@ __all__ = (
"message_log_dal",
"user_billing_dal",
"ad_dal",
"security_dal",
)
+160
View File
@@ -0,0 +1,160 @@
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import case, delete, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import SecurityThrottle
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
@dataclass(frozen=True)
class ThrottleDecision:
locked: bool
retry_after: Optional[int] = None
def _utc_now(value: Optional[datetime] = None) -> datetime:
if value is None:
value = datetime.now(timezone.utc)
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _retry_after_seconds(locked_until: Optional[datetime], now: datetime) -> Optional[int]:
if not locked_until:
return None
locked_until = _utc_now(locked_until)
remaining = int((locked_until - now).total_seconds())
return max(1, remaining) if remaining > 0 else None
async def get_throttle_state(
session: AsyncSession,
*,
scope: str,
identifier: str,
) -> Optional[SecurityThrottle]:
stmt = (
select(SecurityThrottle)
.where(
SecurityThrottle.scope == scope,
SecurityThrottle.identifier == identifier,
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def check_throttle(
session: AsyncSession,
*,
scope: str,
identifier: str,
now: Optional[datetime] = None,
) -> ThrottleDecision:
now = _utc_now(now)
row = await get_throttle_state(session, scope=scope, identifier=identifier)
if not row or not row.locked_until:
return ThrottleDecision(locked=False)
locked_until = _utc_now(row.locked_until)
if locked_until <= now:
return ThrottleDecision(locked=False)
return ThrottleDecision(
locked=True,
retry_after=_retry_after_seconds(locked_until, now),
)
async def record_throttle_failure(
session: AsyncSession,
*,
scope: str,
identifier: str,
max_failures: int,
window_seconds: int,
lock_seconds: int,
now: Optional[datetime] = None,
) -> ThrottleDecision:
now = _utc_now(now)
max_failures = max(1, int(max_failures))
window_seconds = max(1, int(window_seconds))
lock_seconds = max(1, int(lock_seconds))
window_cutoff = now - timedelta(seconds=window_seconds)
lock_until = now + timedelta(seconds=lock_seconds)
failure_count_expr = case(
(
or_(
SecurityThrottle.window_started_at.is_(None),
SecurityThrottle.window_started_at <= window_cutoff,
),
1,
),
else_=SecurityThrottle.failures + 1,
)
stmt = (
pg_insert(SecurityThrottle)
.values(
scope=scope,
identifier=identifier,
failures=1,
window_started_at=now,
last_attempt_at=now,
locked_until=lock_until if max_failures <= 1 else None,
)
.on_conflict_do_update(
index_elements=[SecurityThrottle.scope, SecurityThrottle.identifier],
set_={
"failures": failure_count_expr,
"window_started_at": case(
(
or_(
SecurityThrottle.window_started_at.is_(None),
SecurityThrottle.window_started_at <= window_cutoff,
),
now,
),
else_=SecurityThrottle.window_started_at,
),
"last_attempt_at": now,
"locked_until": case(
(failure_count_expr >= max_failures, lock_until),
else_=None,
),
},
)
.returning(SecurityThrottle.locked_until)
)
result = await session.execute(stmt)
locked_until = result.scalar_one_or_none()
locked_until = _utc_now(locked_until) if locked_until else None
if locked_until and locked_until > now:
return ThrottleDecision(
locked=True,
retry_after=_retry_after_seconds(locked_until, now),
)
return ThrottleDecision(locked=False)
async def clear_throttle_state(
session: AsyncSession,
*,
scope: str,
identifier: str,
) -> None:
stmt = delete(SecurityThrottle).where(
SecurityThrottle.scope == scope,
SecurityThrottle.identifier == identifier,
)
await session.execute(stmt)
+1 -1
View File
@@ -130,7 +130,7 @@ async def upsert_subscription(session: AsyncSession,
async def deactivate_other_active_subscriptions(
session: AsyncSession, panel_user_uuid: str,
current_panel_subscription_uuid: Optional[str]):
current_panel_subscription_uuid: Optional[str]) -> None:
stmt = (update(Subscription).where(
Subscription.panel_user_uuid == panel_user_uuid,
Subscription.is_active == True,
+339 -12
View File
@@ -7,7 +7,7 @@ from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_, or_, desc
from sqlalchemy.orm import aliased
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import (
@@ -24,6 +24,11 @@ from ..models import (
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
REFERRAL_CODE_LENGTH = 9
MAX_REFERRAL_CODE_ATTEMPTS = 25
MAX_EMAIL_USER_ID_ATTEMPTS = 25
class UserMergeConflictError(ValueError):
pass
def _generate_referral_code_candidate() -> str:
@@ -50,6 +55,14 @@ async def generate_unique_referral_code(session: AsyncSession) -> str:
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
async def generate_unique_email_user_id(session: AsyncSession) -> int:
for _ in range(MAX_EMAIL_USER_ID_ATTEMPTS):
candidate = -(secrets.randbelow(9_000_000_000_000_000) + 1)
if not await get_user_by_id(session, candidate):
return candidate
raise RuntimeError("Failed to generate a unique email user id after several attempts.")
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
"""
Ensure the provided user has a referral code, generating and persisting it if missing.
@@ -82,6 +95,23 @@ async def get_user_by_username(session: AsyncSession, username: str) -> Optional
return result.scalar_one_or_none()
async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]:
clean_email = (email or "").strip().lower()
if not clean_email:
return None
stmt = select(User).where(func.lower(User.email) == clean_email)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_telegram_id(
session: AsyncSession, telegram_id: int
) -> Optional[User]:
stmt = select(User).where(User.telegram_id == telegram_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_panel_uuid(
session: AsyncSession, panel_uuid: str
) -> Optional[User]:
@@ -135,6 +165,304 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
return user, created
async def create_email_user(
session: AsyncSession,
*,
email: str,
language_code: str,
email_verified_at: Optional[datetime] = None,
referred_by_id: Optional[int] = None,
) -> Tuple[User, bool]:
normalized_email = (email or "").strip().lower()
user_id = await generate_unique_email_user_id(session)
return await create_user(
session,
{
"user_id": user_id,
"email": normalized_email,
"email_verified_at": email_verified_at or datetime.now(timezone.utc),
"language_code": language_code,
"referred_by_id": referred_by_id,
"registration_date": datetime.now(timezone.utc),
},
)
async def _has_active_panel_subscription(
session: AsyncSession, user_id: int, panel_user_uuid: str
) -> bool:
stmt = (
select(Subscription.subscription_id)
.where(
Subscription.user_id == user_id,
Subscription.panel_user_uuid == panel_user_uuid,
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def _get_latest_subscription_for_user(
session: AsyncSession,
user_id: int,
panel_user_uuid: Optional[str] = None,
*,
active_only: bool = False,
) -> Optional[Subscription]:
stmt = select(Subscription).where(Subscription.user_id == user_id)
if panel_user_uuid is not None:
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
if active_only:
stmt = stmt.where(
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
stmt = stmt.order_by(Subscription.end_date.desc(), Subscription.subscription_id.desc()).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def _get_active_subscription_for_user(
session: AsyncSession,
user_id: int,
panel_user_uuid: Optional[str] = None,
) -> Optional[Subscription]:
return await _get_latest_subscription_for_user(
session,
user_id,
panel_user_uuid,
active_only=True,
)
async def merge_users(
session: AsyncSession,
*,
source_user_id: int,
target_user_id: int,
) -> User:
"""Merge source user data into target user and remove the source row."""
if source_user_id == target_user_id:
target = await get_user_by_id(session, target_user_id)
if not target:
raise ValueError("Target user not found.")
return target
source = await get_user_by_id(session, source_user_id)
target = await get_user_by_id(session, target_user_id)
if not source or not target:
raise ValueError("Both source and target users are required for merge.")
if source.email and target.email and source.email != target.email:
raise UserMergeConflictError("Both accounts already have different emails.")
if (
source.telegram_id
and target.telegram_id
and int(source.telegram_id) != int(target.telegram_id)
):
raise UserMergeConflictError("Both accounts already have different Telegram IDs.")
source_panel_uuid = source.panel_user_uuid
target_panel_uuid = target.panel_user_uuid
panel_uuid_to_keep = target_panel_uuid or source_panel_uuid
now = datetime.now(timezone.utc)
source_active_sub = await _get_active_subscription_for_user(
session, source_user_id, source_panel_uuid
)
target_active_sub = await _get_active_subscription_for_user(
session, target_user_id, target_panel_uuid
)
target_anchor_sub = target_active_sub
if not target_anchor_sub and target_panel_uuid:
target_anchor_sub = await _get_latest_subscription_for_user(
session, target_user_id, target_panel_uuid
)
if not target_anchor_sub and not target_panel_uuid:
target_anchor_sub = await _get_latest_subscription_for_user(session, target_user_id)
if (
source_active_sub
and target_anchor_sub
and source_panel_uuid
and target_panel_uuid
and source_panel_uuid != target_panel_uuid
):
source_end = source_active_sub.end_date
if source_end.tzinfo is None:
source_end = source_end.replace(tzinfo=timezone.utc)
target_end = target_anchor_sub.end_date
if target_end.tzinfo is None:
target_end = target_end.replace(tzinfo=timezone.utc)
source_remaining = max(timedelta(0), source_end - now)
if source_remaining > timedelta(0):
base_end = target_end if target_end > now else now
target_anchor_sub.end_date = base_end + source_remaining
target_anchor_sub.last_notification_sent = None
target_anchor_sub.is_active = True
target_anchor_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
source_active_sub.is_active = False
source_active_sub.skip_notifications = True
source_active_sub.last_notification_sent = None
source_active_sub.status_from_panel = "MERGED_INTO_ACCOUNT"
elif (
source_active_sub
and target_panel_uuid
and source_panel_uuid
and source_panel_uuid != target_panel_uuid
and not target_anchor_sub
):
source_active_sub.panel_user_uuid = target_panel_uuid
source_active_sub.last_notification_sent = None
source_active_sub.status_from_panel = "ACTIVE_EXTENDED_BY_MERGE"
email_to_move = source.email if source.email and not target.email else None
email_verified_at_to_move = (
source.email_verified_at
if source.email and (not target.email_verified_at or email_to_move)
else None
)
telegram_id_to_move = (
source.telegram_id if source.telegram_id and not target.telegram_id else None
)
referral_code_to_move = (
source.referral_code if source.referral_code and not target.referral_code else None
)
if email_to_move:
source.email = None
if telegram_id_to_move:
source.telegram_id = None
if referral_code_to_move:
source.referral_code = None
if email_to_move or source_panel_uuid or telegram_id_to_move or referral_code_to_move:
await session.flush()
if email_to_move:
target.email = email_to_move
if email_verified_at_to_move and not target.email_verified_at:
target.email_verified_at = email_verified_at_to_move
if telegram_id_to_move:
target.telegram_id = telegram_id_to_move
if panel_uuid_to_keep and not target.panel_user_uuid:
target.panel_user_uuid = panel_uuid_to_keep
if referral_code_to_move:
target.referral_code = referral_code_to_move
for attr in ("username", "first_name", "last_name", "language_code", "telegram_photo_url"):
if not getattr(target, attr) and getattr(source, attr):
setattr(target, attr, getattr(source, attr))
if not target.channel_subscription_verified and source.channel_subscription_verified is not None:
target.channel_subscription_verified = source.channel_subscription_verified
if not target.channel_subscription_checked_at and source.channel_subscription_checked_at:
target.channel_subscription_checked_at = source.channel_subscription_checked_at
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
target.channel_subscription_verified_for = source.channel_subscription_verified_for
if source.lifetime_used_traffic_bytes is not None:
target.lifetime_used_traffic_bytes = (
(target.lifetime_used_traffic_bytes or 0) + source.lifetime_used_traffic_bytes
)
if not target.referred_by_id and source.referred_by_id != target_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == source_user_id:
target.referred_by_id = source.referred_by_id
if target.referred_by_id == target_user_id:
target.referred_by_id = None
target_method_ids = select(UserPaymentMethod.provider_payment_method_id).where(
UserPaymentMethod.user_id == target_user_id
)
await session.execute(
delete(UserPaymentMethod).where(
UserPaymentMethod.user_id == source_user_id,
UserPaymentMethod.provider_payment_method_id.in_(target_method_ids),
)
)
target_promo_ids = select(PromoCodeActivation.promo_code_id).where(
PromoCodeActivation.user_id == target_user_id
)
await session.execute(
delete(PromoCodeActivation).where(
PromoCodeActivation.user_id == source_user_id,
PromoCodeActivation.promo_code_id.in_(target_promo_ids),
)
)
target_has_billing = (
await session.execute(
select(UserBilling.user_id).where(UserBilling.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_billing:
await session.execute(delete(UserBilling).where(UserBilling.user_id == source_user_id))
else:
await session.execute(
update(UserBilling)
.where(UserBilling.user_id == source_user_id)
.values(user_id=target_user_id)
)
target_has_attribution = (
await session.execute(
select(AdAttribution.user_id).where(AdAttribution.user_id == target_user_id)
)
).scalar_one_or_none()
if target_has_attribution:
await session.execute(
delete(AdAttribution).where(AdAttribution.user_id == source_user_id)
)
else:
await session.execute(
update(AdAttribution)
.where(AdAttribution.user_id == source_user_id)
.values(user_id=target_user_id)
)
subscription_update_values: Dict[str, Any] = {"user_id": target_user_id}
if panel_uuid_to_keep:
subscription_update_values["panel_user_uuid"] = panel_uuid_to_keep
await session.execute(
update(Subscription)
.where(Subscription.user_id == source_user_id)
.values(**subscription_update_values)
)
for model in (Payment, PromoCodeActivation, UserPaymentMethod):
await session.execute(
update(model)
.where(model.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.user_id == source_user_id)
.values(user_id=target_user_id)
)
await session.execute(
update(MessageLog)
.where(MessageLog.target_user_id == source_user_id)
.values(target_user_id=target_user_id)
)
await session.execute(
update(User)
.where(User.referred_by_id == source_user_id)
.values(referred_by_id=target_user_id)
)
await session.delete(source)
await session.flush()
await session.refresh(target)
return target
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
normalized = referral_code.strip().upper()
if not normalized:
@@ -301,23 +629,22 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
# Subquery for users with active subscription
active_subs_subq = (
select(Subscription.user_id)
.where(
and_(
Subscription.is_active == True,
Subscription.end_date > now,
)
)
).scalar_subquery()
active_subs = aliased(Subscription)
stmt = (
select(User.user_id)
.outerjoin(
active_subs,
and_(
active_subs.user_id == User.user_id,
active_subs.is_active == True,
active_subs.end_date > now,
),
)
.where(
and_(
User.is_banned == False,
~User.user_id.in_(active_subs_subq),
active_subs.user_id.is_(None),
)
)
)
+2
View File
@@ -20,6 +20,8 @@ def init_db_connection(settings: Settings) -> sessionmaker:
settings.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_size=20,
max_overflow=10,
)
local_async_session_factory = async_sessionmaker(
+209
View File
@@ -125,6 +125,190 @@ def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None:
)
)
def _migration_0005_add_email_auth_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "email" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR"))
if "email_verified_at" not in columns:
connection.execute(
text("ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ")
)
if "telegram_id" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN telegram_id BIGINT"))
connection.execute(
text(
"""
UPDATE users
SET telegram_id = user_id
WHERE telegram_id IS NULL
AND user_id > 0
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email
ON users (email)
WHERE email IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_telegram_id
ON users (telegram_id)
WHERE telegram_id IS NOT NULL
"""
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS email_verification_codes (
code_id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
code_hash VARCHAR NOT NULL,
purpose VARCHAR NOT NULL,
target_user_id BIGINT NULL REFERENCES users(user_id),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_lookup
ON email_verification_codes (email, purpose, target_user_id, created_at DESC)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_expires_at
ON email_verification_codes (expires_at)
"""
)
)
def _migration_0006_add_security_throttles(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS security_throttles (
throttle_id SERIAL PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
identifier VARCHAR(512) NOT NULL,
failures INTEGER NOT NULL DEFAULT 0,
window_started_at TIMESTAMPTZ NULL,
locked_until TIMESTAMPTZ NULL,
last_attempt_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NULL,
CONSTRAINT uq_security_throttles_scope_identifier UNIQUE (scope, identifier)
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_security_throttles_scope
ON security_throttles (scope)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_security_throttles_locked_until
ON security_throttles (locked_until)
"""
)
)
def _migration_0007_add_telegram_photo_url(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "telegram_photo_url" in columns:
return
connection.execute(
text("ALTER TABLE users ADD COLUMN telegram_photo_url TEXT")
)
def _migration_0008_add_email_verification_code_status(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
if "status" not in columns:
connection.execute(
text(
"ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'"
)
)
else:
connection.execute(
text(
"""
UPDATE email_verification_codes
SET status = 'active'
WHERE status IS NULL OR status = ''
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_status
ON email_verification_codes (status)
"""
)
)
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_is_active_end_date
ON subscriptions (is_active, end_date)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscriptions_user_id_is_active
ON subscriptions (user_id, is_active)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_payments_user_id_status
ON payments (user_id, status)
"""
)
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -146,6 +330,31 @@ MIGRATIONS: List[Migration] = [
description="Store lifetime traffic usage for users",
upgrade=_migration_0004_add_lifetime_used_traffic,
),
Migration(
id="0005_add_email_auth_fields",
description="Add email login identities and verification codes",
upgrade=_migration_0005_add_email_auth_fields,
),
Migration(
id="0006_add_security_throttles",
description="Add generic lockout tracking for brute-force protection",
upgrade=_migration_0006_add_security_throttles,
),
Migration(
id="0007_add_telegram_photo_url",
description="Store Telegram profile photo URLs for linked users",
upgrade=_migration_0007_add_telegram_photo_url,
),
Migration(
id="0008_add_email_verification_code_status",
description="Track superseded email verification codes explicitly",
upgrade=_migration_0008_add_email_verification_code_status,
),
Migration(
id="0009_add_composite_indexes",
description="Add composite indexes for subscription and payment lookups",
upgrade=_migration_0009_add_composite_indexes,
),
]
+52 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index
from sqlalchemy.orm import relationship, DeclarativeBase
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.sql import func
@@ -14,6 +14,10 @@ class User(Base):
user_id = Column(BigInteger, primary_key=True, index=True)
username = Column(String, nullable=True, index=True)
email = Column(String, nullable=True, unique=True, index=True)
email_verified_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=True)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
language_code = Column(String, default="ru")
@@ -57,6 +61,10 @@ class User(Base):
class Subscription(Base):
__tablename__ = "subscriptions"
__table_args__ = (
Index("ix_subscriptions_is_active_end_date", "is_active", "end_date"),
Index("ix_subscriptions_user_id_is_active", "user_id", "is_active"),
)
subscription_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger,
@@ -86,8 +94,51 @@ class Subscription(Base):
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>"
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
code_id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String, nullable=False, index=True)
code_hash = Column(String, nullable=False)
purpose = Column(String, nullable=False, index=True)
target_user_id = Column(
BigInteger,
ForeignKey("users.user_id"),
nullable=True,
index=True,
)
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
consumed_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String, nullable=False, default="active", index=True)
attempts = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
target_user = relationship("User")
class SecurityThrottle(Base):
__tablename__ = "security_throttles"
throttle_id = Column(Integer, primary_key=True, autoincrement=True)
scope = Column(String(64), nullable=False, index=True)
identifier = Column(String(512), nullable=False, index=True)
failures = Column(Integer, nullable=False, default=0)
window_started_at = Column(DateTime(timezone=True), nullable=True)
locked_until = Column(DateTime(timezone=True), nullable=True, index=True)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
__table_args__ = (
UniqueConstraint("scope", "identifier", name="uq_security_throttles_scope_identifier"),
)
class Payment(Base):
__tablename__ = "payments"
__table_args__ = (
Index("ix_payments_user_id_status", "user_id", "status"),
)
payment_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger,
+59
View File
@@ -0,0 +1,59 @@
services:
remnawave-minishop:
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
container_name: remnawave-minishop
hostname: remnawave-minishop
env_file:
- .env
environment:
- TZ=UTC
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
remnawave-minishop-db:
condition: service_healthy
remnawave-minishop-db:
image: postgres:17
container_name: remnawave-minishop-db
hostname: remnawave-minishop-db
env_file:
- .env
environment:
- TZ=UTC
volumes:
- remnawave-minishop-db-data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
caddy:
image: caddy:2-alpine
container_name: remnawave-minishop-caddy
hostname: remnawave-minishop-caddy
environment:
- TZ=UTC
- WEB_SERVER_PORT=${WEB_SERVER_PORT:-8080}
- WEBAPP_SERVER_PORT=${WEBAPP_SERVER_PORT:-8081}
depends_on:
- remnawave-minishop
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
volumes:
remnawave-minishop-db-data:
name: remnawave-minishop-db-data
caddy_data:
name: remnawave-minishop-caddy-data
caddy_config:
name: remnawave-minishop-caddy-config
+12 -11
View File
@@ -1,12 +1,13 @@
services:
remnawave-tg-shop:
image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
remnawave-minishop:
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
container_name: remnawave-minishop
hostname: remnawave-minishop
env_file:
- .env
ports:
- '127.0.0.1:8080:8080'
- '127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}'
networks:
- remnawave-network
environment:
@@ -15,17 +16,17 @@ services:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
- remnawave-tg-shop-db
remnawave-tg-shop-db:
- remnawave-minishop-db
remnawave-minishop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
container_name: remnawave-minishop-db
hostname: remnawave-minishop-db
env_file:
- .env
environment:
- TZ=UTC
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
- remnawave-minishop-db-data:/var/lib/postgresql/data
networks:
- remnawave-network
restart: unless-stopped
@@ -37,5 +38,5 @@ services:
networks:
remnawave-network: null
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
remnawave-minishop-db-data:
name: remnawave-minishop-db-data
+18 -14
View File
@@ -1,30 +1,34 @@
services:
remnawave-tg-shop:
# image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
build: .
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
remnawave-minishop:
image: ghcr.io/3252a8/remnawave-minishop:${IMAGE_TAG:-latest}
container_name: remnawave-minishop
hostname: remnawave-minishop
env_file:
- .env
networks:
- remnawave-network
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
remnawave-tg-shop-db:
remnawave-minishop-db:
condition: service_healthy
ports:
- 127.0.0.1:8080:8080
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
remnawave-tg-shop-db:
remnawave-minishop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
container_name: remnawave-minishop-db
hostname: remnawave-minishop-db
env_file:
- .env
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
- remnawave-minishop-db-data:/var/lib/postgresql/data
networks:
- remnawave-network
restart: unless-stopped
@@ -39,5 +43,5 @@ networks:
external: true
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
remnawave-minishop-db-data:
name: remnawave-minishop-db-data
+150
View File
@@ -0,0 +1,150 @@
# Миграция с `remnawave-tg-shop` на `remnawave-minishop`
Начиная с этой версии контейнеры и тома названы `remnawave-minishop*` вместо `remnawave-tg-shop*`. Старый и новый стеки используют **разные имена томов**, поэтому простой `docker compose up -d` после `git pull` создаст пустую БД. Эта инструкция описывает, как перенести данные.
Есть два пути:
- [Автоматический](#автоматический-способ-через-скрипт) — один скрипт, идемпотентный, проверяет состояние на каждом шаге.
- [Ручной](#ручной-способ) — команды, которые делает скрипт, если хочется понимать происходящее или выполнить выборочно.
В обоих случаях:
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай отката;
- сертификаты Caddy (если используется `docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit.
## Автоматический способ (через скрипт)
Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из корня старого репозитория:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из PowerShell, удобнее сначала открыть Git Bash.
Если вы уже подтянули новую версию и файл есть локально, можно запускать и так:
```bash
bash scripts/migrate_to_minishop.sh
```
По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку `main`. Можно переопределить через переменные окружения:
| Переменная | Назначение | По умолчанию |
| ----------------- | ----------------------------------------------------------------------- | ---------------------- |
| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория |
| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` |
| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` |
| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` |
| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) |
| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` |
Примеры:
```bash
# Caddy-вариант из raw
COMPOSE_FILE=docker-compose-caddy.yml \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# С переключением origin на форк 3252a8
NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# Без интерактива
ASSUME_YES=1 \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
Что делает скрипт:
1. **Останавливает текущий стек**: проверяет известные контейнеры старой и новой схемы и останавливает их, если они запущены.
2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе оставляет как есть.
3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`). Прерывается, если в рабочем дереве есть незакоммиченные изменения.
4. **Обновляет `.env`** и правит `POSTGRES_HOST`, если он ещё указывает на старый контейнер.
5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам создал тома и не ругался на уже существующий volume.
6. **Переносит тома** `remnawave-tg-shop-*``remnawave-minishop-*` через одноразовый `alpine`-контейнер. Если новый том уже непустой, копирование пропускается.
7. **Стартует новый стек** (`docker compose -f $COMPOSE_FILE up -d --remove-orphans`, а для локальной сборки ещё и `--build`) и печатает `docker compose ps`.
Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже выполненные шаги.
После того как убедитесь, что бот работает и данные на месте, удалите старые тома:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
> ⚠️ Если у вас есть внешний reverse proxy (Nginx и т.п.), не забудьте поправить в его конфиге `upstream`/`proxy_pass`: имя хоста контейнера изменилось с `remnawave-tg-shop` на `remnawave-minishop`. Скрипт не трогает внешние конфиги.
## Ручной способ
1. **Остановите старый стек и обновите код:**
```bash
docker compose down
git fetch origin
git checkout main
git pull --ff-only origin main
```
2. **Обновите `.env`:**
```bash
sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env
```
3. **Подготовьте новый стек без запуска:**
```bash
# Локальная сборка
docker compose up --no-start --build
# Или Caddy-вариант
docker compose -f docker-compose-caddy.yml up --no-start --build
# Или готовый образ
docker compose -f docker-compose-remote-server.yml up --no-start
```
4. **Перенесите том БД в новое имя:**
```bash
docker run --rm \
-v remnawave-tg-shop-db-data:/from:ro \
-v remnawave-minishop-db-data:/to \
alpine sh -c "cd /from && cp -a . /to"
```
5. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и состоянием ACME:
```bash
for v in caddy-data caddy-config; do
docker run --rm \
-v "remnawave-tg-shop-$v":/from:ro \
-v "remnawave-minishop-$v":/to \
alpine sh -c "cd /from && cp -a . /to"
done
```
6. **Запустите новый стек:**
```bash
docker compose up -d
# или
docker compose -f docker-compose-caddy.yml up -d --build
# или
docker compose -f docker-compose-remote-server.yml up -d
```
7. **Проверьте:**
```bash
docker compose ps
docker compose logs -f remnawave-minishop
```
8. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек стабилен:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
+14 -5
View File
@@ -5,9 +5,12 @@
"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.",
"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.",
"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}! 👋\nOpen \"Personal Account\" to set up the connection, view your subscription, or renew access.",
"menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase",
"menu_personal_account_button": "🔑 Personal Account",
"menu_my_subscription_inline": "🔐 My Subscription",
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
"menu_referral_inline": "🎁 Invite friend",
@@ -18,6 +21,7 @@
"menu_support_button": "💬 Support",
"menu_terms_button": "📄 Terms of Service",
"menu_info_button": "️ Information",
"bot_interface_menu_title": "Bot interface",
"info_links_message": "Choose a document:",
"privacy_policy_button": "🔒 Privacy Policy",
"user_agreement_button": "📄 User Agreement",
@@ -45,6 +49,8 @@
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
"pay_with_sbp_button": "📱 SBP",
"pay_with_platega_button": "💳 Platega (SBP/Cards)",
"pay_with_platega_sbp_button": "🏦 Pay via SBP",
"pay_with_platega_crypto_button": "🪙 Pay with crypto",
"pay_with_severpay_button": "💳 SeverPay",
"back_to_payment_methods_button": "⬅️ Back",
"pay_with_cryptopay_button": "💎 CryptoBot",
@@ -83,6 +89,7 @@
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
"promo_code_prompt": "Please enter your promo code:",
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_too_many_attempts": "Too many failed promo code attempts. Please try again in {seconds} sec.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
@@ -232,9 +239,9 @@
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
"admin_promo_edit_max_activations": "🔢 Max Activations",
"admin_promo_edit_validity": "⏰ Validity",
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
"admin_ban_user_prompt": "Enter user ID, @username, or email to ban:",
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
"admin_unban_user_prompt": "Enter user ID or @username to unban:",
"admin_unban_user_prompt": "Enter user ID, @username, or email to unban:",
"admin_user_not_banned": "⚠️ User is not banned",
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
"prev_page_button": "⬅️ Prev.",
@@ -251,7 +258,7 @@
"admin_export_logs_csv_button": "📄 Export to CSV",
"admin_no_logs_found": "No logs found.",
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
"admin_prompt_for_user_id_or_username_logs": "Enter user ID or @username to view logs:",
"admin_prompt_for_user_id_or_username_logs": "Enter user ID, @username, or email to view logs:",
"admin_log_user_not_found": "User \"{input}\" not found in bot database.",
"sync_started_simple": "🔄 Starting synchronization...",
"sync_success_simple": "✅ Synchronization completed successfully",
@@ -271,7 +278,7 @@
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID, @username, or email to search:",
"admin_user_subscription_info": "Subscription Information:",
"admin_user_reset_trial_button": "🔄 Reset Trial",
"admin_user_add_subscription_button": " Add Days",
@@ -367,6 +374,8 @@
"admin_user_id_label": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Name:</b>",
"admin_user_username_label": "📱 <b>Username:</b>",
"admin_user_email_label": "✉️ <b>Email:</b>",
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
"admin_user_language_label": "🌍 <b>Language:</b>",
"admin_user_registration_label": "📅 <b>Registration:</b>",
"admin_user_status_label": "🛡 <b>Status:</b>",
+14 -5
View File
@@ -5,9 +5,12 @@
"channel_subscription_verify_button": "Проверить подписку",
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?\n\nДля настройки подключения на устройстве перейдите в раздел \"Моя подписка\" и нажмите кнопку \"Подключиться\". При необходимости добавьте дни доступа в разделе \"Купить\".",
"webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.",
"webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в Web App и попробуйте еще раз.",
"main_menu_greeting": "Привет, {user_name}! 👋\nПерейдите в \"Личный кабинет\", чтобы настроить подключение, посмотреть подписку или продлить доступ.",
"menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить",
"menu_personal_account_button": "🔑 Личный кабинет",
"menu_my_subscription_inline": "🔐 Моя подписка",
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
"menu_referral_inline": "🎁 Пригласить друга",
@@ -18,6 +21,7 @@
"menu_support_button": "💬 Поддержка",
"menu_terms_button": "📄 Условия сервиса",
"menu_info_button": "ℹ️ Информация",
"bot_interface_menu_title": "Интерфейс в боте",
"info_links_message": "Выберите документ:",
"privacy_policy_button": "🔒 Политика конфиденциальности",
"user_agreement_button": "📄 Пользовательское соглашение",
@@ -45,6 +49,8 @@
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
"pay_with_sbp_button": "📱 СБП",
"pay_with_platega_button": "💳 Platega (СБП/карты)",
"pay_with_platega_sbp_button": "🏦 Оплата через СБП",
"pay_with_platega_crypto_button": "🪙 Оплата криптой",
"pay_with_severpay_button": "💳 SeverPay",
"back_to_payment_methods_button": "⬅️ Назад",
"pay_with_cryptopay_button": "💎 CryptoBot",
@@ -83,6 +89,7 @@
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_too_many_attempts": "Слишком много неудачных попыток ввода промокода. Повторите через {seconds} сек.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
@@ -241,9 +248,9 @@
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
"admin_promo_edit_max_activations": "🔢 Макс. активации",
"admin_promo_edit_validity": "⏰ Срок действия",
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
"admin_ban_user_prompt": "Введите ID, @username или email пользователя для блокировки:",
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:",
"admin_unban_user_prompt": "Введите ID, @username или email пользователя для разблокировки:",
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
"prev_page_button": "⬅️ Пред.",
@@ -260,7 +267,7 @@
"admin_export_logs_csv_button": "📄 Экспорт в CSV",
"admin_no_logs_found": "Логи не найдены.",
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
"admin_prompt_for_user_id_or_username_logs": "Введите ID, @username или email пользователя для просмотра его логов:",
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
"sync_started_simple": "🔄 Начинаю синхронизацию...",
"sync_success_simple": "✅ Синхронизация успешно завершена",
@@ -271,7 +278,7 @@
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
"error_displaying_statistics": "Ошибка отображения статистики.",
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя, @username или email для поиска:",
"admin_user_subscription_info": "Информация о подписке:",
"admin_user_reset_trial_button": "🔄 Сбросить триал",
"admin_user_add_subscription_button": "➕ Добавить дни",
@@ -367,6 +374,8 @@
"admin_user_id_label": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Имя:</b>",
"admin_user_username_label": "📱 <b>Username:</b>",
"admin_user_email_label": "✉️ <b>Email:</b>",
"admin_user_telegram_id_label": "📨 <b>Telegram ID:</b>",
"admin_user_language_label": "🌍 <b>Язык:</b>",
"admin_user_registration_label": "📅 <b>Регистрация:</b>",
"admin_user_status_label": "🛡 <b>Статус:</b>",
+1 -13
View File
@@ -11,22 +11,10 @@ from db.database_setup import init_db, init_db_connection
def _resolve_log_level(value: str) -> int:
if not value:
return logging.INFO
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return logging.INFO
if normalized.isdigit():
return int(normalized)
level = getattr(logging, normalized.upper(), None)
if isinstance(level, int):
return level
return logging.INFO
return getattr(logging, value.upper(), logging.INFO)
async def main():
load_dotenv()
settings = get_settings()
session_factory = init_db_connection(settings)
+1578
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"private": true,
"scripts": {
"build:webapp:css": "tailwindcss -i ./bot/app/web/templates/subscription_webapp.tailwind.css -o ./bot/app/web/templates/subscription_webapp.css --minify",
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
"build:webapp": "npm run build:webapp:css && npm run build:webapp:js"
},
"devDependencies": {
"@tailwindcss/cli": "4.2.4",
"esbuild": "^0.28.0",
"tailwindcss": "4.2.4"
}
}
+1
View File
@@ -5,6 +5,7 @@ pydantic==2.12.5
yookassa==3.9.0
httpx>=0.27.0
pydantic_settings==2.12.0
email-validator==2.3.0
sqlalchemy[asyncio]==2.0.45
asyncpg==0.31.0
aiocryptopay==0.4.8
+159
View File
@@ -0,0 +1,159 @@
import re
html_path = "bot/app/web/templates/subscription_webapp.html"
with open(html_path, 'r', encoding='utf-8') as f:
html = f.read()
replacements = {
r'class="flex items-start justify-between gap-3"': 'class="panel-head"',
r'class="text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]"': 'class="section-title"',
r'class="mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]"': 'class="section-caption"',
r'class="min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]"': 'class="metric-label"',
r'class="min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]"': 'class="metric-value"',
r'class="m-0 text-\[32px\] font-extrabold leading-\[1\.08\] text-\[var\(--text-primary\)\]"': 'class="main-value"',
r'class="mt-1\.5 mb-0 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="main-caption"',
r'class="login-text login-status hidden m-0 min-h-5 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="status-text hidden"',
r'class="login-text login-status hidden"': 'class="status-text hidden"',
r'class="actions grid min-w-0 grid-cols-\[minmax\(0,1fr\)_48px\] gap-2"': 'class="actions-row"',
r'class="hidden flex flex-wrap justify-center gap-x-4 gap-y-1\.5"': 'class="legal-links hidden"',
r'class="flex min-h-12 items-center justify-between gap-3"': 'class="app-header"',
r'class="flex min-w-0 items-center gap-2\.5"': 'class="app-header-title"',
r'class="flex min-w-0 items-center gap-2"': 'class="app-header-actions"',
r'class="flex min-w-0 items-center justify-between gap-2\.5"': 'class="panel-head-sm"',
r'class="flex min-w-0 items-center justify-center gap-3\.5"': 'class="login-head"',
}
for pattern, repl in replacements.items():
html = re.sub(pattern, repl, html)
with open(html_path, 'w', encoding='utf-8') as f:
f.write(html)
js_path = "bot/app/web/templates/subscription_webapp.js"
with open(js_path, 'r', encoding='utf-8') as f:
js = f.read()
js_replacements = {
r"panelHead: 'flex items-start justify-between gap-3',": "panelHead: 'panel-head',",
r"flowCaption: 'mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]',": "flowCaption: 'section-caption',",
r"sectionTitle: 'text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]',": "sectionTitle: 'section-title',",
r"metricLabel: 'min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]',": "metricLabel: 'metric-label',",
r"metricValue: 'min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]',": "metricValue: 'metric-value',",
r"referralLinkRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,1fr\)_48px\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "referralLinkRow: 'referral-link-row',",
r"referralLinkValue: 'mt-1 font-\[family-name:var\(--font-mono\)\] text-xs font-bold leading-\[1\.35\] text-\[var\(--text-primary\)\]',": "referralLinkValue: 'referral-link-value',",
r"bonusRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,0\.8fr\)_minmax\(0,1\.2fr\)\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "bonusRow: 'bonus-row',",
r"empty: 'rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]',": "empty: 'empty-state',",
r"planCard: 'flex min-h-16 w-full min-w-0 items-center justify-between gap-3 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-left text-\[var\(--text-primary\)\] transition-\[transform,border-color,background,box-shadow\] hover:-translate-y-0\.5 hover:border-\[color-mix\(in_srgb,var\(--accent\)_42%,var\(--border\)\)\] hover:bg-\[var\(--bg-card-hover\)\]',": "planCard: 'plan-card',",
r"planCardActive: 'border-\[var\(--accent\)\] bg-\[color-mix\(in_srgb,var\(--accent\)_8%,transparent\)\] ring-1 ring-\[color-mix\(in_srgb,var\(--accent\)_58%,transparent\)\] shadow-\[0_12px_30px_rgba\(0,0,0,0\.2\)\]',": "planCardActive: 'plan-card-active',",
r"planName: 'block text-\[15px\] font-extrabold leading-tight',": "planName: 'plan-name',",
r"planMeta: 'mt-1 block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold leading-\[1\.3\] text-\[var\(--text-muted\)\]',": "planMeta: 'plan-meta',",
r"planPrice: 'block max-w-\[48%\] flex-none text-right font-\[family-name:var\(--font-mono\)\] text-\[15px\] font-extrabold leading-tight text-\[var\(--accent\)\]',": "planPrice: 'plan-price',",
r"notice: 'rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]',": "notice: 'notice',",
r"stepNum: 'block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-extrabold leading-\[1\.1\] text-current',": "stepNum: 'step-num',",
r"stepName: 'mt-1 block text-xs font-extrabold leading-\[1\.15\] text-current'": "stepName: 'step-name'"
}
for pattern, repl in js_replacements.items():
js = re.sub(pattern, repl, js)
with open(js_path, 'w', encoding='utf-8') as f:
f.write(js)
# CSS additions
css_path = "bot/app/web/templates/subscription_webapp.tailwind.css"
with open(css_path, 'r', encoding='utf-8') as f:
css = f.read()
new_components = """
.panel-head {
@apply flex items-start justify-between gap-3;
}
.section-title {
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
}
.section-caption {
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
}
.metric-label {
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
}
.metric-value {
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
}
.referral-link-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.referral-link-value {
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
}
.bonus-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.empty-state {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.plan-card {
@apply flex min-h-16 w-full min-w-0 items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-left text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
transition-duration: 200ms;
}
.plan-card-active {
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
}
.plan-name {
@apply block text-[15px] font-extrabold leading-tight;
}
.plan-meta {
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
}
.plan-price {
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
}
.notice {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.step-num {
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
}
.step-name {
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
}
.main-value {
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
}
.main-caption {
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.status-text {
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.legal-links {
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5;
}
.actions-row {
@apply grid min-w-0 grid-cols-[minmax(0,1fr)_48px] gap-2;
}
.app-header {
@apply flex min-h-12 items-center justify-between gap-3;
}
.app-header-title {
@apply flex min-w-0 items-center gap-2.5;
}
.app-header-actions {
@apply flex min-w-0 items-center gap-2;
}
.panel-head-sm {
@apply flex min-w-0 items-center justify-between gap-2.5;
}
.login-head {
@apply flex min-w-0 items-center justify-center gap-3.5;
}
"""
css_split = css.split('.btn {')
new_css = css_split[0] + new_components + '\n .btn {' + css_split[1]
with open(css_path, 'w', encoding='utf-8') as f:
f.write(new_css)
print("Done")
+8
View File
@@ -0,0 +1,8 @@
import re
with open('bot/app/web/templates/subscription_webapp.html', 'r', encoding='utf-8') as f:
html = f.read()
long_classes = set(re.findall(r'class="([^"]{40,})"', html))
for cls in long_classes:
print(f"--- LONG CLASS ({len(cls)} chars) ---\n{cls}\n")
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transform } from "esbuild";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const sourcePath = path.join(
repoRoot,
"bot",
"app",
"web",
"templates",
"subscription_webapp.js",
);
function normalizeLineEndings(value) {
return value.replace(/\r\n/g, "\n");
}
function stripMarkedBlock(source, startMarker, endMarker) {
const start = source.indexOf(startMarker);
if (start === -1) {
return source;
}
const end = source.indexOf(endMarker, start);
if (end === -1) {
return source.slice(0, start);
}
return source.slice(0, start) + source.slice(end + endMarker.length);
}
function stripFallbackI18n(source) {
const fallbackStart = source.indexOf(" const FALLBACK_I18N = {");
const i18nLine = " const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || FALLBACK_I18N;";
const i18nLineIndex = source.indexOf(i18nLine);
if (fallbackStart === -1 || i18nLineIndex === -1 || i18nLineIndex < fallbackStart) {
return source;
}
return (
source.slice(0, fallbackStart)
+ " const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || {};\n"
+ source.slice(i18nLineIndex + i18nLine.length)
);
}
async function removeOldMinifiedAssets(assetDir, keepName) {
const entries = await readdir(assetDir, { withFileTypes: true });
await Promise.all(
entries
.filter(
(entry) => entry.isFile() && /^subscription_webapp\.min\.[0-9a-f]{8}\.js$/.test(entry.name) && entry.name !== keepName,
)
.map((entry) => unlink(path.join(assetDir, entry.name))),
);
}
async function main() {
const rawSource = await readFile(sourcePath, "utf8");
const withoutMocks = stripMarkedBlock(
normalizeLineEndings(rawSource),
"/* WEBAPP_DEV_MOCK_START */",
"/* WEBAPP_DEV_MOCK_END */",
);
const strippedSource = stripFallbackI18n(withoutMocks);
const result = await transform(strippedSource, {
charset: "utf8",
legalComments: "none",
loader: "js",
minify: true,
target: "es2018",
});
const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`;
const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8);
const outputPath = path.join(
path.dirname(sourcePath),
`subscription_webapp.min.${hash}.js`,
);
await removeOldMinifiedAssets(path.dirname(sourcePath), path.basename(outputPath));
await writeFile(outputPath, code, "utf8");
console.log(`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)`);
}
await main();
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=""
OLD_PREFIX="remnawave-tg-shop"
NEW_PREFIX="remnawave-minishop"
OLD_DB_VOLUME="${OLD_PREFIX}-db-data"
NEW_DB_VOLUME="${NEW_PREFIX}-db-data"
OLD_CADDY_VOLUMES=("${OLD_PREFIX}-caddy-data" "${OLD_PREFIX}-caddy-config")
NEW_CADDY_VOLUMES=("${NEW_PREFIX}-caddy-data" "${NEW_PREFIX}-caddy-config")
KNOWN_CONTAINERS=(
"${OLD_PREFIX}"
"${OLD_PREFIX}-db"
"${OLD_PREFIX}-caddy"
"${NEW_PREFIX}"
"${NEW_PREFIX}-db"
"${NEW_PREFIX}-caddy"
)
log() {
printf '%s\n' "$*"
}
die() {
printf 'Ошибка: %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Не найдено обязательное средство \`$1\` в PATH."
}
resolve_root() {
if [[ -n "${PROJECT_ROOT:-}" ]]; then
[[ -d "$PROJECT_ROOT" ]] || die "PROJECT_ROOT не существует: $PROJECT_ROOT"
(cd -- "$PROJECT_ROOT" >/dev/null && pwd -P)
return
fi
local git_root
if git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
printf '%s\n' "$git_root"
return
fi
pwd -P
}
run() {
log "+ $*"
"$@"
}
container_exists() {
docker inspect "$1" >/dev/null 2>&1
}
container_running() {
[[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" == "true" ]]
}
stop_container() {
local name="$1"
if ! container_exists "$name"; then
return 1
fi
if container_running "$name"; then
docker stop "$name" >/dev/null
fi
docker rm "$name" >/dev/null
}
volume_exists() {
docker volume inspect "$1" >/dev/null 2>&1
}
volume_is_empty() {
docker run --rm -v "$1:/data" alpine sh -c 'test -z "$(find /data -mindepth 1 -print -quit)"' >/dev/null 2>&1
}
copy_volume() {
local source="$1"
local target="$2"
if ! volume_exists "$source"; then
log " - Пропускаю том \`$source\`: исходный том не найден."
return 1
fi
if volume_exists "$target" && ! volume_is_empty "$target"; then
log " - Пропускаю том \`$target\`: он уже не пустой."
return 1
fi
if ! volume_exists "$target"; then
die "Целевой том \`$target\` не создан Compose. Сначала нужно подготовить новый стек в режиме \`--no-start\`."
fi
docker run --rm -v "$source:/from:ro" -v "$target:/to" alpine sh -c 'cd /from && cp -a . /to/'
}
is_old_postgres_host() {
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${OLD_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
}
is_new_postgres_host() {
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${NEW_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
}
update_postgres_host() {
if is_old_postgres_host; then
sed -i.bak -E "s|^([[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*)${OLD_PREFIX}-db([[:space:]]*(#.*)?)$|\\1${NEW_PREFIX}-db\\2|" "$ROOT/.env"
log " - \`.env\` обновлён, резервная копия сохранена в \`.env.bak\`."
elif is_new_postgres_host; then
log " - \`POSTGRES_HOST\` уже указывает на новый контейнер, ничего менять не нужно."
else
log " - \`POSTGRES_HOST\` не похож на старую схему, пропускаю изменение."
fi
}
main() {
require_cmd git
require_cmd docker
docker info >/dev/null
ROOT="$(resolve_root)"
local compose_file="${COMPOSE_FILE:-docker-compose.yml}"
local target_branch="${TARGET_BRANCH:-main}"
local git_remote="${GIT_REMOTE:-origin}"
local new_origin_url="${NEW_ORIGIN_URL:-}"
local assume_yes="${ASSUME_YES:-0}"
local current_origin
local current_branch
local remote_ref
local head_commit
local compose_has_build=0
local compose_has_caddy=0
local -a compose_cmd
local -a running_containers=()
local -a summary=()
local -a up_args
local name
local source
local target
local answer
if [[ $compose_file != /* ]]; then
compose_file="$ROOT/$compose_file"
fi
[[ -f "$compose_file" ]] || die "Compose-файл не найден: $compose_file"
[[ -e "$ROOT/.git" ]] || die "Скрипт нужно запускать из корня git-репозитория."
[[ -f "$ROOT/.env" ]] || die "Не найден \`.env\` в корне репозитория."
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose)
else
die "Не найден ни \`docker compose\`, ни \`docker-compose\`."
fi
if [[ -n "$(git -C "$ROOT" status --porcelain=v1)" ]]; then
die "В рабочем дереве есть незакоммиченные изменения. Сначала сохраните их, чтобы миграция не затёрла чужие правки."
fi
if grep -Eq '^[[:space:]]*build:[[:space:]]*' "$compose_file"; then
compose_has_build=1
fi
if grep -Eq '^[[:space:]]*caddy:[[:space:]]*$' "$compose_file"; then
compose_has_caddy=1
fi
for name in "${KNOWN_CONTAINERS[@]}"; do
if container_exists "$name"; then
running_containers+=("$name")
fi
done
current_origin="$(git -C "$ROOT" remote get-url "$git_remote")"
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
summary+=( "обновить $git_remote с \`$current_origin\` на \`$new_origin_url\`" )
fi
summary+=( "скачать ветку \`$target_branch\` из \`$git_remote\`" )
if volume_exists "$OLD_DB_VOLUME"; then
summary+=( "проверить том БД \`$OLD_DB_VOLUME\` и перенести в \`$NEW_DB_VOLUME\` при необходимости" )
fi
if ((compose_has_caddy)); then
for i in 0 1; do
source="${OLD_CADDY_VOLUMES[$i]}"
target="${NEW_CADDY_VOLUMES[$i]}"
if volume_exists "$source"; then
summary+=( "проверить том \`$source\` и перенести в \`$target\` при необходимости" )
fi
done
fi
if is_old_postgres_host; then
summary+=( "обновить \`POSTGRES_HOST\` в \`.env\`" )
fi
summary+=( "подготовить новый стек через Compose в режиме \`--no-start\`" )
summary+=( "запустить compose-файл \`$(basename "$compose_file")\`" )
if [[ "$assume_yes" != "1" ]]; then
if [[ ! -t 0 ]]; then
die "Скрипт ожидает интерактивное подтверждение. Запустите с \`ASSUME_YES=1\` для неинтерактивного режима."
fi
log "План миграции:"
for name in "${summary[@]}"; do
log " - $name"
done
read -r -p "Продолжить? [y/N]: " answer
case "$answer" in
y|Y|yes|YES|Yes)
;;
*)
die "Миграция отменена пользователем."
;;
esac
fi
log "1. Останавливаю старый стек"
if ((${#running_containers[@]})); then
for name in "${running_containers[@]}"; do
stop_container "$name"
log " - контейнер \`$name\` остановлен/удалён"
done
else
log " - запущенных контейнеров старой схемы не найдено"
fi
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
log "2. Обновляю origin"
run git -C "$ROOT" remote set-url "$git_remote" "$new_origin_url"
else
log "2. Origin уже актуален, пропускаю"
fi
log "3. Обновляю git до ветки \`$target_branch\`"
run git -C "$ROOT" fetch "$git_remote" "$target_branch"
current_branch="$(git -C "$ROOT" branch --show-current || true)"
if [[ -z "$current_branch" ]]; then
current_branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
fi
if [[ "$current_branch" != "$target_branch" ]]; then
if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$target_branch"; then
run git -C "$ROOT" switch "$target_branch"
else
run git -C "$ROOT" switch -c "$target_branch" --track "$git_remote/$target_branch"
fi
else
log " - уже на ветке \`$target_branch\`"
fi
remote_ref="$(git -C "$ROOT" rev-parse "$git_remote/$target_branch")"
head_commit="$(git -C "$ROOT" rev-parse HEAD)"
if [[ "$head_commit" != "$remote_ref" ]]; then
run git -C "$ROOT" pull --ff-only "$git_remote" "$target_branch"
else
log " - локальная ветка уже совпадает с удалённой, \`git pull\` не нужен"
fi
log "4. Обновляю \`.env\`"
update_postgres_host
log "5. Подготавливаю новый стек через Compose"
if ((compose_has_build)); then
run "${compose_cmd[@]}" -f "$compose_file" up --no-start --build
else
run "${compose_cmd[@]}" -f "$compose_file" up --no-start
fi
log "6. Переношу тома"
if copy_volume "$OLD_DB_VOLUME" "$NEW_DB_VOLUME"; then
log " - БД перенесена в \`$NEW_DB_VOLUME\`"
fi
if ((compose_has_caddy)); then
for i in 0 1; do
source="${OLD_CADDY_VOLUMES[$i]}"
target="${NEW_CADDY_VOLUMES[$i]}"
if copy_volume "$source" "$target"; then
log " - \`$source\` перенесён в \`$target\`"
fi
done
fi
log "7. Запускаю новый стек"
if ((compose_has_build)); then
up_args=(up -d --build --remove-orphans)
else
up_args=(up -d --remove-orphans)
fi
run "${compose_cmd[@]}" -f "$compose_file" "${up_args[@]}"
run "${compose_cmd[@]}" -f "$compose_file" ps
log "Готово."
}
main "$@"
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from urllib.request import Request, urlopen
SOURCE_URL = "https://telegram.org/js/telegram-web-app.js"
TARGET_PATH = (
Path(__file__).resolve().parents[1]
/ "bot"
/ "app"
/ "web"
/ "templates"
/ "telegram-web-app.js"
)
def _download(source_url: str) -> bytes:
request = Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
)
with urlopen(request, timeout=30) as response:
return response.read()
def main() -> int:
parser = argparse.ArgumentParser(
description="Download the latest Telegram Web App SDK into the local templates directory."
)
parser.add_argument(
"--source-url",
default=SOURCE_URL,
help="Telegram Web App SDK URL to download from.",
)
parser.add_argument(
"--target",
type=Path,
default=TARGET_PATH,
help="Output path for the vendored SDK.",
)
args = parser.parse_args()
data = _download(args.source_url)
args.target.parent.mkdir(parents=True, exist_ok=True)
args.target.write_bytes(data)
print(f"Wrote {len(data)} bytes to {args.target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from urllib.request import Request, urlopen
SOURCE_URL = "https://telegram.org/js/telegram-widget.js?23"
TARGET_PATH = (
Path(__file__).resolve().parents[1]
/ "bot"
/ "app"
/ "web"
/ "templates"
/ "telegram-widget.js"
)
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
def _download(source_url: str) -> bytes:
request = Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
)
with urlopen(request, timeout=30) as response:
return response.read()
def _normalize_widget_sdk(data: bytes) -> bytes:
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
text = data.decode("utf-8")
normalized = text.replace(
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
_PATCHED_WIDGET_ORIGIN_SNIPPET,
1,
)
return normalized.encode("utf-8")
def main() -> int:
parser = argparse.ArgumentParser(
description="Download the latest Telegram Login Widget SDK into the local templates directory."
)
parser.add_argument(
"--source-url",
default=SOURCE_URL,
help="Telegram Login Widget SDK URL to download from.",
)
parser.add_argument(
"--target",
type=Path,
default=TARGET_PATH,
help="Output path for the vendored SDK.",
)
args = parser.parse_args()
data = _normalize_widget_sdk(_download(args.source_url))
args.target.parent.mkdir(parents=True, exist_ok=True)
args.target.write_bytes(data)
print(f"Wrote {len(data)} bytes to {args.target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+234
View File
@@ -0,0 +1,234 @@
import hashlib
import hmac
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from aiohttp import web
from bot.app.web import subscription_webapp
from bot.app.web.webapp_auth import create_webapp_session_token
from bot.services.crypto_pay_service import CryptoPayService
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.freekassa_service import FreeKassaService
from bot.utils.request_security import request_client_ip
class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
async def test_request_client_ip_uses_last_forwarded_for_value_for_trusted_proxy(self):
request = SimpleNamespace(
remote="127.0.0.1",
headers={"X-Forwarded-For": "203.0.113.10, 198.51.100.7"},
)
self.assertEqual(
request_client_ip(request, trusted_proxies=["127.0.0.1"]),
"198.51.100.7",
)
async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self):
request = SimpleNamespace(
app={
"bot": object(),
"i18n": object(),
"settings": SimpleNamespace(trusted_proxies=["127.0.0.1"]),
"panel_service": object(),
"subscription_service": object(),
"referral_service": object(),
"lknpd_service": None,
"async_session_factory": object(),
},
headers={},
remote="203.0.113.50",
json=AsyncMock(side_effect=AssertionError("request.json() must not be called")),
)
response = await yookassa_webhook_route(request)
self.assertEqual(response.status, 403)
request.json.assert_not_awaited()
class FreeKassaServiceTests(unittest.TestCase):
def _make_service(self) -> FreeKassaService:
settings = SimpleNamespace(
FREEKASSA_ENABLED=True,
FREEKASSA_MERCHANT_ID="123456",
FREEKASSA_API_KEY="api-key",
FREEKASSA_SECOND_SECRET="second-secret",
DEFAULT_CURRENCY_SYMBOL="RUB",
FREEKASSA_PAYMENT_IP="203.0.113.10",
FREEKASSA_PAYMENT_METHOD_ID=44,
FREEKASSA_TRUSTED_IPS="127.0.0.1,203.0.113.0/24",
trusted_proxies=["127.0.0.1"],
freekassa_trusted_ips=["127.0.0.1", "203.0.113.0/24"],
)
return FreeKassaService(
bot=object(),
settings=settings,
i18n=object(),
async_session_factory=object(),
subscription_service=object(),
referral_service=object(),
)
def test_validate_signature_accepts_hmac_sha256_raw_body(self):
service = self._make_service()
raw_body = b'{"amount":"199.00","o":"42"}'
expected_signature = hmac.new(
service.second_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
self.assertTrue(service._validate_signature(raw_body, expected_signature))
def test_validate_signature_rejects_wrong_signature(self):
service = self._make_service()
self.assertFalse(service._validate_signature(b"payload", "not-a-signature"))
def test_webhook_rejects_unauthorized_ip_before_body_read(self):
service = self._make_service()
request = SimpleNamespace(
remote="198.51.100.250",
headers={},
read=AsyncMock(side_effect=AssertionError("request.read() must not be called")),
)
response = asyncio_run(service.webhook_route(request))
self.assertEqual(response.status, 403)
request.read.assert_not_awaited()
class CryptoPayServiceTests(unittest.TestCase):
def _make_service(self) -> CryptoPayService:
service = CryptoPayService.__new__(CryptoPayService)
service.token = "cryptopay-token"
return service
def test_validate_webhook_signature_accepts_valid_signature(self):
service = self._make_service()
raw_body = b'{"payload":"42"}'
expected_signature = hmac.new(
hashlib.sha256(service.token.encode("utf-8")).digest(),
raw_body,
hashlib.sha256,
).hexdigest()
self.assertTrue(service._validate_webhook_signature(raw_body, expected_signature))
def test_validate_webhook_signature_rejects_invalid_signature(self):
service = self._make_service()
self.assertFalse(service._validate_webhook_signature(b"payload", "not-a-signature"))
class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
def test_require_user_id_falls_back_to_cookie_session(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
)
token = create_webapp_session_token(settings, 321)
request = SimpleNamespace(
app={"settings": settings},
headers={},
cookies={"rw_webapp_session": token},
)
self.assertEqual(subscription_webapp._require_user_id(request), 321)
async def test_csrf_middleware_rejects_mismatched_token_when_cookie_session_exists(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
)
request = SimpleNamespace(
method="POST",
path="/api/payments",
headers={"X-CSRF-Token": "bad-token"},
cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"},
app={"settings": settings},
)
handler = AsyncMock(return_value=web.Response(text="ok"))
response = await subscription_webapp._csrf_protection_middleware(request, handler)
self.assertEqual(response.status, 403)
handler.assert_not_awaited()
async def test_csrf_middleware_allows_matching_token_when_cookie_session_exists(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
)
request = SimpleNamespace(
method="POST",
path="/api/payments",
headers={"X-CSRF-Token": "good-token"},
cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"},
app={"settings": settings},
)
handler = AsyncMock(return_value=web.Response(text="ok"))
response = await subscription_webapp._csrf_protection_middleware(request, handler)
self.assertEqual(response.text, "ok")
handler.assert_awaited_once()
async def test_csrf_middleware_allows_valid_bearer_authorization_for_compatibility(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
)
token = create_webapp_session_token(settings, 321)
request = SimpleNamespace(
method="POST",
path="/api/payments",
headers={
"Authorization": f"Bearer {token}",
"X-CSRF-Token": "bad-token",
},
cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"},
app={"settings": settings},
)
handler = AsyncMock(return_value=web.Response(text="ok"))
response = await subscription_webapp._csrf_protection_middleware(request, handler)
self.assertEqual(response.text, "ok")
handler.assert_awaited_once()
def test_email_payload_rejects_overlong_email(self):
long_email = ("a" * 245) + "@example.com"
model, response = subscription_webapp._validate_model_payload(
subscription_webapp.WebAppEmailPayload,
{"email": long_email},
)
self.assertIsNone(model)
self.assertEqual(response.status, 400)
self.assertIn("email_too_long", response.text)
def test_payment_payload_rejects_overlong_description(self):
model, response = subscription_webapp._validate_model_payload(
subscription_webapp.WebAppPaymentCreatePayload,
{
"method": "platega",
"months": 3,
"description": "x" * 4097,
},
)
self.assertIsNone(model)
self.assertEqual(response.status, 400)
self.assertIn("description_too_long", response.text)
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
+28
View File
@@ -0,0 +1,28 @@
import unittest
from pydantic import ValidationError
from config.settings import Settings
class SettingsTests(unittest.TestCase):
def test_blank_postgres_password_is_rejected(self):
with self.assertRaises(ValidationError):
Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="",
)
def test_webapp_secrets_are_generated_when_missing(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
)
self.assertTrue(settings.WEBAPP_SESSION_SECRET)
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
+133
View File
@@ -0,0 +1,133 @@
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql.dml import Delete, Update
from db.dal import user_dal
class FakeResult:
def __init__(self, scalar_value=None, rowcount=1):
self._scalar_value = scalar_value
self.rowcount = rowcount
def scalar_one_or_none(self):
return self._scalar_value
def scalars(self):
return self
def all(self):
if self._scalar_value is None:
return []
if isinstance(self._scalar_value, list):
return self._scalar_value
return [self._scalar_value]
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
async def test_get_user_ids_without_active_subscription_uses_left_join_null_check(self):
session = SimpleNamespace(
execute=AsyncMock(return_value=FakeResult([2, 3])),
)
result = await user_dal.get_user_ids_without_active_subscription(session)
self.assertEqual(result, [2, 3])
stmt = session.execute.await_args.args[0]
sql = str(
stmt.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
).upper()
self.assertIn("LEFT OUTER JOIN", sql)
self.assertIn("IS NULL", sql)
async def test_merge_users_uses_bulk_updates_for_related_tables(self):
source = SimpleNamespace(
user_id=1,
email="source@example.com",
telegram_id=111,
panel_user_uuid="panel-source",
email_verified_at=datetime.now(timezone.utc),
username="source-user",
first_name="Source",
last_name="User",
language_code="ru",
telegram_photo_url="https://example.com/source.jpg",
channel_subscription_verified=True,
channel_subscription_checked_at=datetime.now(timezone.utc),
channel_subscription_verified_for=1,
lifetime_used_traffic_bytes=512,
referred_by_id=999,
referral_code="SRC123",
)
target = SimpleNamespace(
user_id=2,
email=None,
telegram_id=None,
panel_user_uuid=None,
email_verified_at=None,
username=None,
first_name=None,
last_name=None,
language_code=None,
telegram_photo_url=None,
channel_subscription_verified=False,
channel_subscription_checked_at=None,
channel_subscription_verified_for=None,
lifetime_used_traffic_bytes=128,
referred_by_id=None,
referral_code=None,
)
session = SimpleNamespace(
execute=AsyncMock(side_effect=lambda stmt: FakeResult()),
delete=AsyncMock(),
flush=AsyncMock(),
refresh=AsyncMock(),
)
async def fake_get_user_by_id(_session, user_id):
if user_id == source.user_id:
return source
if user_id == target.user_id:
return target
return None
with (
patch("db.dal.user_dal.get_user_by_id", side_effect=fake_get_user_by_id),
patch("db.dal.user_dal._get_active_subscription_for_user", return_value=None),
patch("db.dal.user_dal._get_latest_subscription_for_user", return_value=None),
):
merged = await user_dal.merge_users(
session,
source_user_id=source.user_id,
target_user_id=target.user_id,
)
self.assertIs(merged, target)
update_tables = []
delete_tables = []
for call in session.execute.await_args_list:
stmt = call.args[0]
if isinstance(stmt, Update):
update_tables.append(stmt.table.name)
elif isinstance(stmt, Delete):
delete_tables.append(stmt.table.name)
self.assertIn("user_billing", update_tables)
self.assertIn("ad_attributions", update_tables)
self.assertIn("subscriptions", update_tables)
self.assertIn("payments", update_tables)
self.assertIn("promo_code_activations", update_tables)
self.assertIn("user_payment_methods", update_tables)
self.assertIn("message_logs", update_tables)
self.assertIn("users", update_tables)
self.assertIn("user_payment_methods", delete_tables)
self.assertIn("promo_code_activations", delete_tables)
session.delete.assert_awaited_once_with(source)
+44
View File
@@ -0,0 +1,44 @@
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from bot.app.web import subscription_webapp
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
def test_resolve_webapp_js_asset_name_prefers_latest_minified_build(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
(asset_dir / "subscription_webapp.js").write_text("console.log('fallback');", encoding="utf-8")
old_asset = asset_dir / "subscription_webapp.min.11111111.js"
new_asset = asset_dir / "subscription_webapp.min.22222222.js"
old_asset.write_text("console.log('old');", encoding="utf-8")
new_asset.write_text("console.log('new');", encoding="utf-8")
os.utime(old_asset, (1, 1))
os.utime(new_asset, (2, 2))
with patch.object(subscription_webapp, "ASSET_DIR", asset_dir):
self.assertEqual(
subscription_webapp._resolve_webapp_js_asset_name(),
"subscription_webapp.min.22222222.js",
)
async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)
minified_asset = asset_dir / "subscription_webapp.min.abcdef12.js"
minified_asset.write_text("console.log('minified');", encoding="utf-8")
request = SimpleNamespace(
app={"settings": SimpleNamespace(WEBAPP_ENABLED=True)},
match_info={"asset_hash": "abcdef12"},
)
with patch.object(subscription_webapp, "ASSET_DIR", asset_dir):
response = await subscription_webapp.js_asset_route(request)
self.assertEqual(response.headers["Cache-Control"], "public, max-age=31536000, immutable")
self.assertEqual(response.text, "console.log('minified');")